Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c | """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()) | |