"""M22 (item 12) — AST-based one-directional import lint. Enforces guardrail 8: the renderer-agnostic / substrate-agnostic core modules must never depend on the extraction layer. Concretely, none of:: loosecanvas.contracts loosecanvas.context_packer loosecanvas.validator loosecanvas.reducer loosecanvas.graph_repository loosecanvas.response_format may import anything from ``loosecanvas.extractors`` (any submodule). The dependency flows one way only: extractors -> core, never core -> extractors. Run as a CLI (exit 0 clean, 1 on violations):: .venv\\Scripts\\python.exe scripts\\lint_import_dependencies.py """ from __future__ import annotations import ast import sys from pathlib import Path # Core modules that must stay free of any extraction-layer dependency. _CORE_MODULES = ( "contracts", "context_packer", "validator", "reducer", "graph_repository", "response_format", ) _FORBIDDEN_PREFIX = "loosecanvas.extractors" def _src_root(explicit: Path | None = None) -> Path: """Resolve ``src/loosecanvas`` from an explicit root or this file's location.""" if explicit is not None: return explicit repo_root = Path(__file__).resolve().parents[1] return repo_root / "src" / "loosecanvas" def _imports_extractors(tree: ast.AST) -> list[str]: """Return the forbidden ``loosecanvas.extractors`` import targets found in ``tree``.""" hits: list[str] = [] for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: if alias.name == _FORBIDDEN_PREFIX or alias.name.startswith( f"{_FORBIDDEN_PREFIX}." ): hits.append(alias.name) elif isinstance(node, ast.ImportFrom): module = node.module or "" if module == _FORBIDDEN_PREFIX or module.startswith( f"{_FORBIDDEN_PREFIX}." ): hits.append(module) return hits def find_forbidden_imports(src_root: Path | None = None) -> list[str]: """Scan the core modules and return one violation string per forbidden import. A violation reads ``" imports "``. An empty list means the one-directional dependency holds. Missing core files are reported as violations so the check fails loudly rather than silently passing. """ root = _src_root(src_root) violations: list[str] = [] for module in _CORE_MODULES: path = root / f"{module}.py" if not path.is_file(): violations.append(f"{module}: source file not found at {path}") continue tree = ast.parse(path.read_text(encoding="utf-8")) for target in _imports_extractors(tree): violations.append(f"loosecanvas.{module} imports {target}") return violations def main() -> int: """Print any violations and return a process exit code (0 clean, 1 on violations).""" violations = find_forbidden_imports() if violations: print("Import-dependency lint FAILED (core must not import extractors):") for line in violations: print(f" - {line}") return 1 print("Import-dependency lint passed: core modules import no extractors.") return 0 if __name__ == "__main__": sys.exit(main())