`examples/<name>/` was a tested surface in name only. The source-quality job had no examples check at all, and scripts/smoke_examples.sh accepted any process that had not printed one of its error markers: a project that died during startup, exited after a single frame, or crashed without a marker was reported as PASS. Nothing stopped a malformed or unregistered example from reaching dev, which is how 14 examples arrived without READMEs and 20 were never registered in the overview table. Source side - scripts/check_examples.py classifies every directory under examples/ as either runnable (main.nut plus a config.nut that assigns the engine `config` table) or as a reviewed non-runnable sample listed in NON_RUNNABLE_EXAMPLES with a reason. Both kinds must own a README.md and appear in examples/README.md. Stale exemptions and overview links to missing directories fail as well. - `make check/examples` and a new source-quality CI step run the gate. - scripts/tests/test_check_examples.py covers it and pins each failure mode against fixtures. Runtime side - scripts/smoke_examples.sh now enforces liveness, not just marker absence: every runnable example must still be running after MIN_RUN_SECONDS (default 2s; CI passes 2s), and a process that dies inside that window fails whatever its exit code. A clean self-exit after the window passes with an explicit note instead of being silently accepted as a pass. - The Linux CI step sets MIN_RUN_SECONDS=2 and keeps RUN_SECONDS=6, so the job's wall clock is unchanged (13.7 min of its 20 min budget on dev). Windows / GBK - scripts/utf8_stdio.py re-encodes stdout/stderr as UTF-8 and every scripts/*.py program calls it as the first statement of its __main__ block. On a Chinese Windows a redirected stream falls back to the ANSI code page (cp936), and printing a character that code page cannot encode (arrows, superscripts, bullet separators, emoji - all common in this repository) raised UnicodeEncodeError and turned a checker into a traceback. - The mirror image is input: read_text() without encoding= decoded UTF-8 repository text as cp936 and raised UnicodeDecodeError. - scripts/tests/test_utf8_stdio.py enforces both conventions for every script and test module; text file I/O and child-process decoding now pass encoding="utf-8" explicitly. - Unit-test invocations run with `python3 -X utf8` so the runner's own output never follows the host code page. Violations the new gates found on top of dev, fixed here: 14 examples shipped without a README (fluid3d, mesh-impact-lab, mesh-modifier-lab, spline-tube-lab on the earlier dev tip, plus pcg-depth-of-field, pcg-detail-overwrite, pcg-layer-culling, pcg-location-system, pcg-runtime-orchestration, pcg-scene-player-culling, pcg-snow-wind, pcg-thunder-strike, pcg-ui-scaler and pcg-underwater added by #397), 20 were missing from the overview table (those 14 plus archspace-editor, geometry-stroke-lab, interior-mapping, layered-animation, triplanar-decal, terrain-stamping), and scripts/export_meshret_onnx.py had no UTF-8 stdio guard. Verified locally: `python -m ruff check scripts` (All checks passed); scripts/check_test_manifest.py; scripts/check_quality_metadata.py; scripts/check_architecture_contracts.py --base HEAD; scripts/check_nodiscard.py; `make -n check/examples`; scripts/check_examples.py (125 examples: 118 runnable, 7 reviewed non-runnable); and `python -X utf8 -m unittest discover -s scripts/tests -p "test_*.py"` (155 tests, OK). The runtime half of the contract needs a Linux headless build, so it is verified by the CI examples smoke step rather than locally.
276 lines
9.4 KiB
Python
276 lines
9.4 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`` (including nested
|
|
``DIR`` paths such as ``animation/editing``); 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
|
|
import utf8_stdio
|
|
|
|
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_.-]+)")
|
|
DIR_RE = re.compile(r"\bDIR\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
|
|
dir: str
|
|
layer: int | None
|
|
section_layers: tuple[int, ...]
|
|
line: int
|
|
core: bool
|
|
header_only: bool
|
|
|
|
|
|
SATELLITE_FACETS = (
|
|
"streaming",
|
|
"graphics",
|
|
"physics",
|
|
"procgen",
|
|
"import",
|
|
"replay",
|
|
"scene",
|
|
"thread",
|
|
)
|
|
SPECIAL_DIRS = {
|
|
"buildingfx": "building/fx",
|
|
}
|
|
HOST_DIR_ALIASES = {
|
|
"biome": "procgen/biome",
|
|
"domain_gizmo": "editor/gizmo",
|
|
"localization": "i18n",
|
|
"material": "graphics/material",
|
|
"lighting": "graphics/lighting",
|
|
"input": "editor/input",
|
|
"queue": "editor/queue",
|
|
"level": "map/level",
|
|
"sceneloader": "scene/loader",
|
|
}
|
|
|
|
|
|
def package_root(stem: str) -> str:
|
|
return HOST_DIR_ALIASES.get(stem, stem)
|
|
|
|
|
|
def default_module_dir(name: str) -> str:
|
|
"""Match cmake/modules.cmake DIR inference for nested packages."""
|
|
|
|
if name.endswith("_graphics_editing"):
|
|
return f"{package_root(name[: -len('_graphics_editing')])}/graphics_editing"
|
|
if name.endswith("_editing"):
|
|
return f"{package_root(name[: -len('_editing')])}/editing"
|
|
if name.endswith("_editor"):
|
|
return f"{package_root(name[: -len('_editor')])}/editor"
|
|
if name in SPECIAL_DIRS:
|
|
return SPECIAL_DIRS[name]
|
|
for facet in SATELLITE_FACETS:
|
|
suffix = f"_{facet}"
|
|
if name.endswith(suffix):
|
|
return f"{package_root(name[: -len(suffix)])}/{facet}"
|
|
return package_root(name)
|
|
|
|
|
|
def is_authoring_facet(name: str, directory: str | None = None) -> bool:
|
|
"""True when a runtime-only profile must drop this module by directory."""
|
|
|
|
leaf = Path(directory or default_module_dir(name)).name
|
|
return leaf in {"editing", "editor", "graphics_editing"} or name.endswith("_target")
|
|
|
|
|
|
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)
|
|
declared_name = name.group(1) if name else ""
|
|
declared_dir = DIR_RE.search(block)
|
|
declarations.append(
|
|
Declaration(
|
|
name=declared_name,
|
|
dir=(
|
|
declared_dir.group(1)
|
|
if declared_dir
|
|
else default_module_dir(declared_name)
|
|
),
|
|
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]:
|
|
"""Return top-level directory names under src/modules (containers included)."""
|
|
|
|
modules_dir = root / "src" / "modules"
|
|
return {path.name for path in modules_dir.iterdir() if path.is_dir()}
|
|
|
|
|
|
def validate(
|
|
declarations: list[Declaration],
|
|
modules_root: Path = MODULES,
|
|
) -> list[str]:
|
|
"""Return human-readable errors; an empty list means the contract passes."""
|
|
|
|
modules = {path.name for path in modules_root.iterdir() if path.is_dir()}
|
|
errors: list[str] = []
|
|
seen: set[str] = set()
|
|
justified_tops: 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:
|
|
justified_tops.add(Path(declaration.dir).parts[0])
|
|
module_path = modules_root / Path(*declaration.dir.split("/"))
|
|
if not module_path.is_dir():
|
|
errors.append(
|
|
f"line {declaration.line}: {declaration.name} has no "
|
|
f"src/modules/{declaration.dir}"
|
|
)
|
|
|
|
for missing in sorted(modules - justified_tops):
|
|
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)
|
|
return report(declarations, validate(declarations, args.modules_dir), args.json)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
utf8_stdio.enable_utf8_stdio()
|
|
sys.exit(main())
|