205 lines
7.1 KiB
Python
205 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate the module manifest as a build/layering contract.
|
|
|
|
The CMake manifest is intentionally the only module declaration source. This
|
|
check is kept in Python so it can run before a toolchain is installed and so a
|
|
bad section marker cannot be hidden by CMake's parser. A declaration must have
|
|
an explicit ``LAYER`` and must sit below a matching ``# L<n>`` section marker.
|
|
The check also compares the manifest with ``src/modules``; a new module cannot
|
|
silently compile outside the profile resolver.
|
|
|
|
Usage::
|
|
|
|
python3 scripts/check_module_manifest.py
|
|
python3 scripts/check_module_manifest.py --json
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
MANIFEST = ROOT / "cmake" / "module_manifest.cmake"
|
|
MODULES = ROOT / "src" / "modules"
|
|
|
|
DECLARATION_START = re.compile(r"^\s*eve_declare_module\s*\(")
|
|
NAME_RE = re.compile(r"\bNAME\s+([A-Za-z0-9_.-]+)")
|
|
LAYER_RE = re.compile(r"\bLAYER\s+(-?\d+)")
|
|
SECTION_RE = re.compile(r"^\s*#\s*((?:L-?\d+\s*(?:/|,)\s*)*L-?\d+)\b")
|
|
MANIFEST_INCLUDE_RE = re.compile(
|
|
r"^\s*include\(\$\{CMAKE_CURRENT_LIST_DIR\}/(module_manifest/[^)]+\.cmake)\)\s*$",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Declaration:
|
|
name: str
|
|
layer: int | None
|
|
section_layers: tuple[int, ...]
|
|
line: int
|
|
core: bool
|
|
header_only: bool
|
|
|
|
|
|
def _section_layers(line: str) -> tuple[int, ...] | None:
|
|
"""Return layer numbers from a heading, or None for an ordinary comment."""
|
|
|
|
match = SECTION_RE.match(line)
|
|
if not match:
|
|
return None
|
|
return tuple(int(value) for value in re.findall(r"L(-?\d+)", match.group(1)))
|
|
|
|
|
|
def _balanced_block(lines: list[str], start: int) -> tuple[str, int]:
|
|
"""Read one CMake command without interpreting comments or quoted strings."""
|
|
|
|
block: list[str] = []
|
|
depth = 0
|
|
quoted = False
|
|
escaped = False
|
|
for index in range(start, len(lines)):
|
|
line = lines[index]
|
|
block.append(line)
|
|
for char in line:
|
|
if escaped:
|
|
escaped = False
|
|
continue
|
|
if char == "\\" and quoted:
|
|
escaped = True
|
|
continue
|
|
if char == '"':
|
|
quoted = not quoted
|
|
elif not quoted:
|
|
if char == "(":
|
|
depth += 1
|
|
elif char == ")":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return "".join(block), index
|
|
return "".join(block), len(lines) - 1
|
|
|
|
|
|
def manifest_files(path: Path = MANIFEST) -> list[Path]:
|
|
"""Return the entrypoint and its ordered declaration fragments."""
|
|
|
|
text = path.read_text(encoding="utf-8")
|
|
return [path, *(path.parent / match for match in MANIFEST_INCLUDE_RE.findall(text))]
|
|
|
|
|
|
def parse_manifest(path: Path = MANIFEST) -> list[Declaration]:
|
|
"""Parse declarations and the section marker active at each declaration."""
|
|
|
|
declarations: list[Declaration] = []
|
|
for manifest_file in manifest_files(path):
|
|
lines = manifest_file.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
section: tuple[int, ...] = ()
|
|
index = 0
|
|
while index < len(lines):
|
|
marker = _section_layers(lines[index])
|
|
if marker is not None:
|
|
section = marker
|
|
if DECLARATION_START.match(lines[index]):
|
|
block, end = _balanced_block(lines, index)
|
|
name = NAME_RE.search(block)
|
|
layer = LAYER_RE.search(block)
|
|
declarations.append(
|
|
Declaration(
|
|
name=name.group(1) if name else "",
|
|
layer=int(layer.group(1)) if layer else None,
|
|
section_layers=section,
|
|
line=index + 1,
|
|
core=bool(re.search(r"\bCORE\b", block)),
|
|
header_only=bool(re.search(r"\bHEADER_ONLY\b", block)),
|
|
)
|
|
)
|
|
index = end + 1
|
|
continue
|
|
index += 1
|
|
return declarations
|
|
|
|
|
|
def source_modules(root: Path = ROOT) -> set[str]:
|
|
modules_dir = root / "src" / "modules"
|
|
return {path.name for path in modules_dir.iterdir() if path.is_dir()}
|
|
|
|
|
|
def validate(
|
|
declarations: list[Declaration],
|
|
modules: set[str],
|
|
) -> list[str]:
|
|
"""Return human-readable errors; an empty list means the contract passes."""
|
|
|
|
errors: list[str] = []
|
|
seen: set[str] = set()
|
|
for declaration in declarations:
|
|
if not declaration.name:
|
|
errors.append(f"line {declaration.line}: declaration has no NAME")
|
|
continue
|
|
if declaration.name in seen:
|
|
errors.append(f"line {declaration.line}: duplicate module '{declaration.name}'")
|
|
seen.add(declaration.name)
|
|
if declaration.layer is None:
|
|
errors.append(f"line {declaration.line}: {declaration.name} has no LAYER")
|
|
elif not declaration.section_layers:
|
|
errors.append(
|
|
f"line {declaration.line}: {declaration.name} is outside an L<n> section"
|
|
)
|
|
elif declaration.layer not in declaration.section_layers:
|
|
expected = "/".join(f"L{value}" for value in declaration.section_layers)
|
|
errors.append(
|
|
f"line {declaration.line}: {declaration.name} declares L{declaration.layer} "
|
|
f"inside {expected} section"
|
|
)
|
|
if not declaration.core and declaration.name not in modules:
|
|
errors.append(
|
|
f"line {declaration.line}: {declaration.name} has no src/modules/{declaration.name}"
|
|
)
|
|
|
|
declared = {declaration.name for declaration in declarations if declaration.name and not declaration.core}
|
|
for missing in sorted(modules - declared):
|
|
errors.append(f"src/modules/{missing}: no declaration in cmake/module_manifest.cmake")
|
|
return errors
|
|
|
|
|
|
def report(declarations: list[Declaration], errors: list[str], as_json: bool) -> int:
|
|
if as_json:
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"declarations": [asdict(item) for item in declarations],
|
|
"errors": errors,
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
)
|
|
else:
|
|
for error in errors:
|
|
print(f"error: {error}", file=sys.stderr)
|
|
if not errors:
|
|
print(f"module manifest OK: {len(declarations)} declarations")
|
|
return 1 if errors else 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--manifest", type=Path, default=MANIFEST)
|
|
parser.add_argument("--modules-dir", type=Path, default=MODULES)
|
|
parser.add_argument("--json", action="store_true", help="emit machine-readable output")
|
|
args = parser.parse_args(argv)
|
|
declarations = parse_manifest(args.manifest)
|
|
modules = {
|
|
path.name for path in args.modules_dir.iterdir() if path.is_dir()
|
|
}
|
|
return report(declarations, validate(declarations, modules), args.json)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|