loosecanvas / scripts /lint_import_dependencies.py
Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c
Raw
History Blame Contribute Delete
3.34 kB
"""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 ``"<module> imports <forbidden_target>"``. 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())