Files
EVEngine/scripts/module_depgraph.py
西风逍遥游 31880f0021 feat(examples): gate examples in CI and make repository scripts UTF-8 on every host
`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.
2026-09-15 20:20:23 +08:00

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())