Files
EVEngine/scripts/module_depgraph.py
T
Cursor Agentand西风逍遥游 f7aacbc4a9 refactor(event): let modules translate their own platform events
The SDL pump did the translation itself, so it had to know about keyboard,
joystick, touch, window, graphics, audio and ui: eight upward dependencies from
one of the engine's lowest modules, and the source of every remaining
dependency cycle.

Each module now registers an IPlatformEventSink for the events it owns and the
pump only dispatches. Key and text events move to keyboard/sdl, joystick and
game-controller events (including the hotplug device table) to joystick/sdl,
finger events to touch/sdl, resize and the mobile background/foreground surface
watch to window/sdl, the ImGui observer to ui, and Audio::pump to an
end-of-pump hook in audio. The pump keeps only quit and window-close, which are
about the event loop rather than any one module.

Sinks run in priority order -- observer, then surface, then input -- so ImGui
still sees events first and the window is resized before anything reads its
size. The mobile surface teardown keeps using SDL_AddEventWatch rather than the
poll path, since by the time SDL_PollEvent returns the native surface is gone;
the watch is now installed by the window module on its first pump.

Also removes exceptionIfInRenderPass, whose body has been commented out since
it was written and whose only remaining effect was a graphics include.

The engine dependency graph now has zero cycles, down from 26, and the back-edge
whitelist is down to 2 (window -> graphics, window -> image). Layering is also
right way up: event sits at L0 with the input modules above it at L1/L2.

Verified: unit tests for event/input/ui/audio/thread pass, and the example
smoke suite gives the same 19 pass / 9 fail as the pre-change baseline (those 9
fail on main too, from script API drift in the examples themselves).

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>
2026-08-18 13:46:04 +00:00

179 lines
6.8 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
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
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.
KNOWN_BACK_EDGES = {
# window/Window.cpp, window/sdl/Window.cpp: the window owns the Graphics
# pointer and decodes its own icon image.
("window", "graphics"),
("window", "image"),
}
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.
"""
modules = sorted(
d for d in os.listdir(MODULES_DIR) if os.path.isdir(os.path.join(MODULES_DIR, d))
)
known = set(modules)
edges = defaultdict(lambda: defaultdict(set))
header_edges = defaultdict(lambda: defaultdict(set))
for module in modules:
for dirpath, _, files in os.walk(os.path.join(MODULES_DIR, module)):
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):
head, _, _ = include.partition("/")
if not _:
continue
if head not in known 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 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")
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
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__":
sys.exit(main())