`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.
295 lines
12 KiB
Python
Executable File
295 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Cross-module dependency report for src/modules.
|
|
|
|
Scans #include directives and reports the module dependency graph, its
|
|
topological layering and any dependency cycles. See
|
|
docs/dev/模块编排与裁剪架构.md for how the output is interpreted.
|
|
|
|
python3 scripts/module_depgraph.py # graph + layers
|
|
python3 scripts/module_depgraph.py --cycles # cycles only
|
|
python3 scripts/module_depgraph.py --check # exit 1 on unlisted back-edge
|
|
python3 scripts/module_depgraph.py --check-layers
|
|
# exit 1 when an include edge
|
|
# climbs above the LAYER the
|
|
# module declares in the
|
|
# module manifest
|
|
|
|
Only sub-path includes ("map/Map.h") count as a cross-module dependency;
|
|
a bare <map> / <thread> / <filesystem> is a standard library header.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import sys
|
|
from collections import defaultdict
|
|
|
|
import check_module_manifest
|
|
import utf8_stdio
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
MODULES_DIR = os.path.join(REPO, "src", "modules")
|
|
|
|
INCLUDE_RE = re.compile(r'^\s*#\s*include\s*[<"]([^">]+)[">]', re.M)
|
|
SOURCE_EXT = (".cpp", ".cc", ".h", ".hpp")
|
|
|
|
# Upward edges that break the layering. Each entry is a known debt item; --check
|
|
# fails on anything outside this list so no new back-edge slips in, and reports
|
|
# entries that are no longer needed so the list shrinks as debt is paid off.
|
|
#
|
|
# All three original back-edges are fixed:
|
|
# window -> graphics: the window queries the IWindowSurfaceHost capability
|
|
# (common/WindowSurfaceHost.h) instead of holding a Graphics pointer.
|
|
# window -> image: Window::setIconRGBA takes raw RGBA8 pixels; the image
|
|
# module does not cross the window boundary.
|
|
# scene -> graphics: the Camera3D-based picking entry points are implemented
|
|
# in graphics/ScenePicking.cpp (graphics -> scene is a legal downward edge).
|
|
KNOWN_BACK_EDGES = set()
|
|
|
|
def declared_layers():
|
|
"""Return {module: declared LAYER} parsed from cmake/module_manifest.cmake.
|
|
|
|
The manifest is the single source of truth for the *declared* module
|
|
layers. The computed layers printed by the default report are derived from
|
|
the include graph instead; this check exists to keep the two in step.
|
|
"""
|
|
result = {}
|
|
for declaration in check_module_manifest.parse_manifest():
|
|
if declaration.name and declaration.layer is not None:
|
|
result[declaration.name] = declaration.layer
|
|
return result
|
|
|
|
|
|
def module_source_roots():
|
|
"""Return {module_name: abs path} from the manifest DIR field."""
|
|
roots = {}
|
|
for declaration in check_module_manifest.parse_manifest():
|
|
if declaration.core or not declaration.name:
|
|
continue
|
|
roots[declaration.name] = os.path.join(MODULES_DIR, *declaration.dir.split("/"))
|
|
return roots
|
|
|
|
|
|
def include_prefixes(roots):
|
|
"""Longest-first (dir_posix, module_name) so nested facets win."""
|
|
prefixes = []
|
|
for name, path in roots.items():
|
|
rel = os.path.relpath(path, MODULES_DIR).replace("\\", "/")
|
|
prefixes.append((rel, name))
|
|
prefixes.sort(key=lambda item: (-len(item[0]), item[0]))
|
|
return prefixes
|
|
|
|
|
|
def resolve_include_module(include, prefixes):
|
|
for prefix, name in prefixes:
|
|
if include == prefix or include.startswith(prefix + "/"):
|
|
return name
|
|
return None
|
|
|
|
|
|
def scan():
|
|
"""Return (modules, edges, header_edges).
|
|
|
|
edges[a][b] is the set of b's headers included anywhere in a;
|
|
header_edges[a][b] narrows that to includes in a's own headers, which
|
|
leak into a's public API and are harder to decouple.
|
|
"""
|
|
roots = module_source_roots()
|
|
modules = sorted(roots)
|
|
prefixes = include_prefixes(roots)
|
|
edges = defaultdict(lambda: defaultdict(set))
|
|
header_edges = defaultdict(lambda: defaultdict(set))
|
|
|
|
for module, root in roots.items():
|
|
if not os.path.isdir(root):
|
|
continue
|
|
nested_heads = set()
|
|
root_norm = os.path.normpath(root)
|
|
for other, other_root in roots.items():
|
|
if other == module or not os.path.isdir(other_root):
|
|
continue
|
|
rel = os.path.relpath(os.path.normpath(other_root), root_norm)
|
|
if rel == "." or rel.startswith(".."):
|
|
continue
|
|
nested_heads.add(rel.split(os.sep)[0])
|
|
for dirpath, dirnames, files in os.walk(root):
|
|
if os.path.normpath(dirpath) == root_norm:
|
|
dirnames[:] = [d for d in dirnames if d not in nested_heads]
|
|
for name in files:
|
|
if not name.endswith(SOURCE_EXT):
|
|
continue
|
|
path = os.path.join(dirpath, name)
|
|
with open(path, encoding="utf-8", errors="ignore") as handle:
|
|
text = handle.read()
|
|
for include in INCLUDE_RE.findall(text):
|
|
if "/" not in include:
|
|
continue
|
|
head = resolve_include_module(include, prefixes)
|
|
if not head or head == module:
|
|
continue
|
|
edges[module][head].add(include)
|
|
if name.endswith((".h", ".hpp")):
|
|
header_edges[module][head].add(include)
|
|
return modules, edges, header_edges
|
|
|
|
|
|
def layers(modules, edges, ignore):
|
|
"""Longest-path depth per module once `ignore` edges are dropped."""
|
|
graph = {a: {b for b in deps if (a, b) not in ignore} for a, deps in edges.items()}
|
|
depth = {}
|
|
|
|
def visit(node, stack):
|
|
if node in depth:
|
|
return depth[node]
|
|
if node in stack:
|
|
return 0
|
|
depth[node] = 0
|
|
depth[node] = max((visit(d, stack | {node}) + 1 for d in graph.get(node, ())), default=0)
|
|
return depth[node]
|
|
|
|
for module in modules:
|
|
visit(module, set())
|
|
return depth
|
|
|
|
|
|
def cycles(edges, ignore=frozenset()):
|
|
graph = {a: {b for b in deps if (a, b) not in ignore} for a, deps in edges.items()}
|
|
found = []
|
|
|
|
def walk(start, node, path, seen):
|
|
for nxt in sorted(graph.get(node, ())):
|
|
if nxt == start and len(path) > 1:
|
|
found.append(list(path))
|
|
# Only extend through nodes ordered after `start` so each cycle is
|
|
# reported once, from its alphabetically first member.
|
|
elif nxt not in seen and nxt > start:
|
|
walk(start, nxt, path + [nxt], seen | {nxt})
|
|
|
|
for start in sorted(graph):
|
|
walk(start, start, [start], {start})
|
|
return found
|
|
|
|
|
|
def check_declared_layers(edges, header_edges):
|
|
"""Exit 1 when an include edge climbs above the declared module LAYER.
|
|
|
|
The manifest declares each module's layer (cmake/module_manifest.cmake).
|
|
A module may only include headers from modules declared at the same or a
|
|
lower layer; an edge toward a *strictly higher* layer is a layering
|
|
violation and is reported (KNOWN_BACK_EDGES whitelists accepted debt).
|
|
Same-layer edges (e.g. window -> image) are allowed, matching the DEPS the
|
|
manifest itself declares.
|
|
"""
|
|
layers_map = declared_layers()
|
|
missing = sorted(m for m in set(edges) | {b for deps in edges.values() for b in deps}
|
|
if m not in layers_map)
|
|
if missing:
|
|
print("warning: modules missing a LAYER declaration in the manifest: "
|
|
+ ", ".join(missing), file=sys.stderr)
|
|
|
|
actual = {(a, b) for a, deps in edges.items() for b in deps}
|
|
offenders = sorted(
|
|
(a, b) for (a, b) in actual
|
|
if a in layers_map and b in layers_map
|
|
and layers_map[b] > layers_map[a]
|
|
and (a, b) not in KNOWN_BACK_EDGES
|
|
)
|
|
stale = sorted(
|
|
(a, b) for (a, b) in KNOWN_BACK_EDGES
|
|
if (a, b) in actual
|
|
and (a not in layers_map or b not in layers_map
|
|
or layers_map[b] <= layers_map[a])
|
|
)
|
|
|
|
for a, b in offenders:
|
|
marker = "*" if (a, b) in header_edges else " "
|
|
print(f"error: layer violation {a}(L{layers_map[a]}) -> {b}(L{layers_map[b]}){marker}",
|
|
file=sys.stderr)
|
|
for a, b in stale:
|
|
print(f"note: {a} -> {b} no longer violates the declared layers; "
|
|
"drop it from KNOWN_BACK_EDGES", file=sys.stderr)
|
|
|
|
if offenders:
|
|
return 1
|
|
print(f"ok: no declared-layer violations "
|
|
f"({len(KNOWN_BACK_EDGES)} known back-edges, {len(stale)} now stale)")
|
|
return 0
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--cycles", action="store_true", help="report dependency cycles only")
|
|
parser.add_argument("--check", action="store_true",
|
|
help="fail when a back-edge outside KNOWN_BACK_EDGES appears")
|
|
parser.add_argument("--check-layers", action="store_true",
|
|
help="fail when an include edge climbs above the module's "
|
|
"declared manifest LAYER")
|
|
args = parser.parse_args()
|
|
|
|
modules, edges, header_edges = scan()
|
|
|
|
if args.cycles:
|
|
found = cycles(edges)
|
|
print(f"dependency cycles: {len(found)}")
|
|
for cycle in sorted(found, key=len):
|
|
print(" " + " -> ".join(cycle) + " -> " + cycle[0])
|
|
remaining = cycles(edges, KNOWN_BACK_EDGES)
|
|
print(f"\nafter removing {len(KNOWN_BACK_EDGES)} known back-edges: {len(remaining)}")
|
|
return 0
|
|
|
|
depth = layers(modules, edges, KNOWN_BACK_EDGES)
|
|
|
|
if args.check:
|
|
actual = {(a, b) for a, deps in edges.items() for b in deps}
|
|
# A back-edge points from a lower layer up to a higher one.
|
|
offenders = sorted(
|
|
(a, b) for (a, b) in actual
|
|
if depth[a] <= depth[b] and (a, b) not in KNOWN_BACK_EDGES
|
|
)
|
|
stale = sorted(KNOWN_BACK_EDGES - actual)
|
|
for a, b in offenders:
|
|
print(f"error: layering violation {a}(L{depth[a]}) -> {b}(L{depth[b]})", file=sys.stderr)
|
|
for a, b in stale:
|
|
print(f"note: {a} -> {b} is fixed; drop it from KNOWN_BACK_EDGES", file=sys.stderr)
|
|
if offenders:
|
|
return 1
|
|
print(f"ok: no new back-edges ({len(KNOWN_BACK_EDGES)} known, {len(stale)} now stale)")
|
|
return 0
|
|
|
|
if args.check_layers:
|
|
return check_declared_layers(edges, header_edges)
|
|
|
|
by_layer = defaultdict(list)
|
|
for module in modules:
|
|
by_layer[depth[module]].append(module)
|
|
print("=== layers (back-edges removed) ===")
|
|
for layer in sorted(by_layer):
|
|
print(f"L{layer} ({len(by_layer[layer])}): " + ", ".join(sorted(by_layer[layer])))
|
|
|
|
print("\n=== dependencies (* = leaks into the module's own headers) ===")
|
|
for module in sorted(modules, key=lambda m: (-len(edges[m]), m)):
|
|
if not edges[module]:
|
|
continue
|
|
deps = [b + ("*" if b in header_edges[module] else "") for b in sorted(edges[module])]
|
|
print(f"{module:16s} L{depth[module]} ({len(deps)}): " + ", ".join(deps))
|
|
|
|
reverse = defaultdict(set)
|
|
for a, deps in edges.items():
|
|
for b in deps:
|
|
reverse[b].add(a)
|
|
print("\n=== dependents ===")
|
|
for module in sorted(modules, key=lambda m: (-len(reverse[m]), m)):
|
|
if reverse[module]:
|
|
print(f"{module:16s} ({len(reverse[module])}): " + ", ".join(sorted(reverse[module])))
|
|
|
|
isolated = [m for m in modules if not edges[m] and not reverse[m]]
|
|
print("\n=== isolated (trimmable with no graph impact) ===")
|
|
print(", ".join(isolated) if isolated else "(none)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
utf8_stdio.enable_utf8_stdio()
|
|
sys.exit(main())
|