#!/usr/bin/env python3 """CASAN Check 3 — Clean Architecture Guard (R01-T03). Statically walks the AST of every Python file in the pure-Python layers and fails when a file imports something the layer is not allowed to depend on. Why AST instead of ``grep``: a regex over source text cannot tell an import apart from the same words appearing inside a docstring, a comment or a string literal (this repo has several docstrings that legitimately mention ``PySide6``). ``ast`` sees only real ``import`` / ``from … import`` nodes, so the check has no false positives and needs no ``# noqa`` escape hatches. Rules enforced (see docs/architecture/ADR-001-layered-architecture.md): * **I1** ``domain/`` and ``application/`` must be 100% pure Python — no Qt. * **I2** ``domain/`` must not import ``application/``, ``infrastructure/``, ``presentation/`` or the legacy ``ui/``. * **I3** ``application/`` must not import ``presentation/`` or ``ui/``. Usage:: python scripts/check_imports.py # scan the whole repo python scripts/check_imports.py domain # scan one layer only Exit code is 0 when clean and 1 when at least one violation is found, so it can be wired straight into CI / ``scripts/run_quality_gate.py`` (R10-T02). """ from __future__ import annotations import argparse import ast import sys from dataclasses import dataclass from pathlib import Path from typing import Dict, Iterable, List, Sequence, Tuple # Repository root = parent of this scripts/ folder. Everything below is resolved # relative to it so the checker works no matter what the checkout folder is # named or which directory the developer runs it from. REPO_ROOT = Path(__file__).resolve().parents[1] # The distribution package name. Absolute imports may be written either as # ``from cowork_local.ui import x`` or ``from ui import x`` depending on how the # module was reached; we normalise the prefix away so both spellings are caught. PACKAGE_NAME = "cowork_local" # Any import whose first dotted segment is one of these is a GUI toolkit. QT_ROOTS = frozenset({"PySide6", "PySide2", "PyQt5", "PyQt6", "shiboken6", "shiboken2"}) # Per-layer rules: layer directory -> top-level package names it may not import. # Kept as a plain table so adding a layer later is a one-line change and the # rules stay readable next to the ADR they implement. LAYER_RULES: Dict[str, frozenset] = { # I1 + I2: domain is the innermost layer and depends on nothing but stdlib. "domain": frozenset({"application", "infrastructure", "presentation", "ui", "core"}), # I1 + I3: application may use domain, but never anything that draws pixels. "application": frozenset({"presentation", "ui"}), } # Directories that are never production code and therefore never scanned. SKIP_DIRS = frozenset({".git", "__pycache__", ".pytest_cache", "tests", "build", "dist"}) @dataclass(frozen=True) class Violation: """One forbidden import, carrying enough context to fix it without grepping.""" path: Path line: int imported: str rule: str def render(self) -> str: """Format as ``file:line: message`` — the shape editors turn into a clickable link, so a CI failure lands the developer on the exact line.""" rel = self.path.relative_to(REPO_ROOT).as_posix() # ASCII-only on purpose: this line is printed to a console that may run a # legacy code page (cp932 on the team's Windows boxes), where a non-ASCII # dash raises UnicodeEncodeError and would crash the gate on the very # failure path it exists to report. return f"{rel}:{self.line}: imports '{self.imported}' - {self.rule}" def iter_python_files(layer_dir: Path) -> Iterable[Path]: """Yield every production ``.py`` file under ``layer_dir``. Test files are excluded on purpose: a test for a pure-Python service is allowed to import Qt (an integration test may need a headless widget), and holding tests to the production rule would push people to disable the gate. """ if not layer_dir.is_dir(): return for path in sorted(layer_dir.rglob("*.py")): # Reject a path as soon as ANY of its parent folder names is skippable, # which also covers nested __pycache__ inside a sub-package. if any(part in SKIP_DIRS for part in path.parts): continue yield path def module_parts(path: Path) -> List[str]: """Dotted package path of ``path`` relative to the repo root, as a list. ``domain/agents/agent_event.py`` -> ``["domain", "agents", "agent_event"]`` ``domain/agents/__init__.py`` -> ``["domain", "agents"]`` Needed to resolve *relative* imports: ``from ..models import X`` inside ``domain/agents/foo.py`` really means ``domain.models``, and only the file's own position tells us that. """ rel = path.relative_to(REPO_ROOT) parts = list(rel.parts) if parts[-1] == "__init__.py": parts.pop() else: parts[-1] = parts[-1][: -len(".py")] return parts def resolve_relative(parts: Sequence[str], level: int, module: str) -> str: """Turn a relative import into the absolute top-level package it points at. ``level`` is the number of leading dots. Level 1 means "the package this module lives in", so we drop the module's own name plus ``level - 1`` further parents. Returns the FIRST segment of the resolved path, because the rules are expressed in terms of top-level layers. Walking off the top of the tree (more dots than there are parents) yields an empty string, which simply never matches a rule — a malformed import like that is a syntax/packaging problem, not an architecture violation. """ base = list(parts[:-1]) # the package containing this module if level > 1: drop = level - 1 if drop > len(base): return "" base = base[: len(base) - drop] tail = module.split(".") if module else [] resolved = base + tail return resolved[0] if resolved else "" def top_level(name: str) -> str: """First dotted segment of an absolute import, with the distribution package prefix stripped so ``cowork_local.ui.chat_panel`` and ``ui.chat_panel`` are treated as the same dependency.""" segments = name.split(".") if segments and segments[0] == PACKAGE_NAME: segments = segments[1:] return segments[0] if segments else "" def imported_roots(tree: ast.AST, parts: Sequence[str]) -> Iterable[Tuple[str, int, str]]: """Yield ``(top_level_package, line_number, as_written)`` for every import. ``as_written`` is kept so the error message shows what the developer actually typed rather than the normalised root, which makes the violation obvious at a glance. ``ast.walk`` (not just the module body) is deliberate: this repo defers many heavy imports into function bodies to keep app start-up fast, and a function-local ``from PySide6 import QtWidgets`` breaks the layer exactly the same way a top-level one does. """ for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: yield top_level(alias.name), node.lineno, alias.name elif isinstance(node, ast.ImportFrom): if node.level: written = "." * node.level + (node.module or "") yield resolve_relative(parts, node.level, node.module or ""), node.lineno, written else: module = node.module or "" yield top_level(module), node.lineno, module def check_file(path: Path, layer: str, banned: frozenset) -> List[Violation]: """Collect every rule violation in one file. A file that cannot be parsed is reported as a violation rather than skipped: silently passing a file the checker could not read would make the gate lie. """ try: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) except (SyntaxError, UnicodeDecodeError) as exc: return [Violation(path, getattr(exc, "lineno", 0) or 0, "", f"cannot be parsed by the architecture guard ({exc})")] parts = module_parts(path) out: List[Violation] = [] for root, lineno, written in imported_roots(tree, parts): if root in QT_ROOTS: out.append(Violation(path, lineno, written, f"'{layer}/' must be 100% pure Python (ADR-001 I1)")) elif root in banned: out.append(Violation(path, lineno, written, f"'{layer}/' must not depend on '{root}/' (ADR-001 I2/I3)")) return out def run(layers: Sequence[str]) -> List[Violation]: """Scan the requested layers and return every violation found, in file order.""" found: List[Violation] = [] for layer in layers: banned = LAYER_RULES[layer] for path in iter_python_files(REPO_ROOT / layer): found.extend(check_file(path, layer, banned)) return found def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser( description="CASAN Check 3 - Clean Architecture Guard (see ADR-001).") parser.add_argument( "layers", nargs="*", choices=sorted(LAYER_RULES) or None, default=None, help="Layers to scan (default: every layer with a rule).", ) args = parser.parse_args(argv) layers = args.layers or sorted(LAYER_RULES) violations = run(layers) scanned = sum(1 for layer in layers for _ in iter_python_files(REPO_ROOT / layer)) if violations: print(f"FAIL - {len(violations)} architecture violation(s) in {scanned} file(s):\n") for v in violations: print(" " + v.render()) # Point at the rationale instead of just the rule id, so someone hitting # this for the first time knows where the decision was made. print("\nSee docs/architecture/ADR-001-layered-architecture.md") return 1 print(f"PASS - 0 Qt imports in {', '.join(layers)} ({scanned} file(s) scanned)") return 0 if __name__ == "__main__": sys.exit(main())