|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Standalone import DAG enforcement -- runs in CI alongside lint and typecheck. |
| 3 | +
|
| 4 | +Verifies the architectural invariant: data flows forward through the pipeline |
| 5 | +(perception -> brain -> routines -> motor) and no module imports upward. |
| 6 | +Brain decision modules must not import environment-specific code (eq/). |
| 7 | +
|
| 8 | +Exit code 0 = all constraints satisfied. |
| 9 | +Exit code 1 = violations found (printed to stderr). |
| 10 | +
|
| 11 | +Usage: |
| 12 | + python3 scripts/check_import_dag.py |
| 13 | +""" |
| 14 | + |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import ast |
| 18 | +import sys |
| 19 | +from pathlib import Path |
| 20 | + |
| 21 | +SRC = Path(__file__).resolve().parent.parent / "src" |
| 22 | + |
| 23 | + |
| 24 | +def _collect_imports(filepath: Path) -> list[str]: |
| 25 | + """Parse a Python file and return all absolute imported module paths.""" |
| 26 | + try: |
| 27 | + tree = ast.parse(filepath.read_text(encoding="utf-8"), filename=str(filepath)) |
| 28 | + except SyntaxError: |
| 29 | + return [] |
| 30 | + modules: list[str] = [] |
| 31 | + for node in ast.walk(tree): |
| 32 | + if isinstance(node, ast.Import): |
| 33 | + for alias in node.names: |
| 34 | + modules.append(alias.name) |
| 35 | + elif isinstance(node, ast.ImportFrom): |
| 36 | + if node.module and node.level == 0: |
| 37 | + modules.append(node.module) |
| 38 | + return modules |
| 39 | + |
| 40 | + |
| 41 | +def _imports_for_package(package: str) -> dict[str, list[str]]: |
| 42 | + pkg_dir = SRC / package |
| 43 | + if not pkg_dir.is_dir(): |
| 44 | + return {} |
| 45 | + result: dict[str, list[str]] = {} |
| 46 | + for py_file in pkg_dir.rglob("*.py"): |
| 47 | + rel = str(py_file.relative_to(SRC)) |
| 48 | + result[rel] = _collect_imports(py_file) |
| 49 | + return result |
| 50 | + |
| 51 | + |
| 52 | +def check_routines_no_decision() -> list[str]: |
| 53 | + """Routines must not import brain.decision, brain.rules, or brain.runner.""" |
| 54 | + forbidden = {"brain.decision", "brain.rules", "brain.runner"} |
| 55 | + violations = [] |
| 56 | + for filepath, imports in _imports_for_package("routines").items(): |
| 57 | + for imp in imports: |
| 58 | + for prefix in forbidden: |
| 59 | + if imp == prefix or imp.startswith(prefix + "."): |
| 60 | + if "base.py" in filepath and "brain.rules.survival" in imp: |
| 61 | + continue |
| 62 | + violations.append(f"{filepath}: imports {imp}") |
| 63 | + return violations |
| 64 | + |
| 65 | + |
| 66 | +def check_motor_no_brain() -> list[str]: |
| 67 | + """Motor must not import brain or routines.""" |
| 68 | + forbidden_prefixes = {"brain", "routines"} |
| 69 | + violations = [] |
| 70 | + for filepath, imports in _imports_for_package("motor").items(): |
| 71 | + for imp in imports: |
| 72 | + if imp.split(".")[0] in forbidden_prefixes: |
| 73 | + violations.append(f"{filepath}: imports {imp}") |
| 74 | + return violations |
| 75 | + |
| 76 | + |
| 77 | +def check_brain_no_runtime() -> list[str]: |
| 78 | + """Brain must not import runtime.server or runtime.orchestrator.""" |
| 79 | + forbidden = {"runtime.server", "runtime.orchestrator"} |
| 80 | + violations = [] |
| 81 | + for filepath, imports in _imports_for_package("brain").items(): |
| 82 | + for imp in imports: |
| 83 | + for prefix in forbidden: |
| 84 | + if imp == prefix or imp.startswith(prefix + "."): |
| 85 | + violations.append(f"{filepath}: imports {imp}") |
| 86 | + return violations |
| 87 | + |
| 88 | + |
| 89 | +def check_brain_decision_no_eq() -> list[str]: |
| 90 | + """Brain analytical modules must not import eq/ (environment-specific). |
| 91 | +
|
| 92 | + brain/runner/ is the orchestration layer and may import eq/ for |
| 93 | + startup configuration. brain/rules/ bridges environment concepts to |
| 94 | + decision logic and may import eq.loadout (spell lookups). All other |
| 95 | + brain subdirectories (scoring, goap, learning, world) must use |
| 96 | + core.types abstractions only. |
| 97 | + """ |
| 98 | + # Analytical subdirectories that must be environment-free |
| 99 | + pure_dirs = {"brain/scoring", "brain/goap", "brain/learning", "brain/world"} |
| 100 | + violations = [] |
| 101 | + for filepath, imports in _imports_for_package("brain").items(): |
| 102 | + in_pure = any(filepath.startswith(d) for d in pure_dirs) |
| 103 | + if not in_pure: |
| 104 | + continue |
| 105 | + for imp in imports: |
| 106 | + if imp.split(".")[0] == "eq": |
| 107 | + violations.append(f"{filepath}: imports {imp}") |
| 108 | + return violations |
| 109 | + |
| 110 | + |
| 111 | +def check_perception_no_upper() -> list[str]: |
| 112 | + """Perception must not import brain, routines, or runtime.""" |
| 113 | + forbidden_prefixes = {"brain", "routines", "runtime"} |
| 114 | + allowed_exceptions = {("perception/log_parser.py", "nav.zone_graph")} |
| 115 | + violations = [] |
| 116 | + for filepath, imports in _imports_for_package("perception").items(): |
| 117 | + for imp in imports: |
| 118 | + if imp.split(".")[0] in forbidden_prefixes: |
| 119 | + if (filepath, imp) not in allowed_exceptions: |
| 120 | + violations.append(f"{filepath}: imports {imp}") |
| 121 | + return violations |
| 122 | + |
| 123 | + |
| 124 | +def main() -> int: |
| 125 | + checks = [ |
| 126 | + ("routines -> decision", check_routines_no_decision), |
| 127 | + ("motor -> brain/routines", check_motor_no_brain), |
| 128 | + ("brain -> runtime", check_brain_no_runtime), |
| 129 | + ("brain decision -> eq", check_brain_decision_no_eq), |
| 130 | + ("perception -> upper layers", check_perception_no_upper), |
| 131 | + ] |
| 132 | + |
| 133 | + all_violations: list[str] = [] |
| 134 | + for label, check_fn in checks: |
| 135 | + violations = check_fn() |
| 136 | + if violations: |
| 137 | + all_violations.append(f"\n{label} ({len(violations)} violations):") |
| 138 | + for v in violations: |
| 139 | + all_violations.append(f" {v}") |
| 140 | + |
| 141 | + if all_violations: |
| 142 | + print("Import DAG violations found:", file=sys.stderr) |
| 143 | + for line in all_violations: |
| 144 | + print(line, file=sys.stderr) |
| 145 | + return 1 |
| 146 | + |
| 147 | + print(f"Import DAG: all {len(checks)} constraints satisfied.") |
| 148 | + return 0 |
| 149 | + |
| 150 | + |
| 151 | +if __name__ == "__main__": |
| 152 | + sys.exit(main()) |
0 commit comments