EPIC R01 (Team Duy) - safety net before the parallel refactor starts.
R01-T01 docs/architecture/ADR-001-layered-architecture.md
4-tier boundaries, allowed dependency directions, invariants I1-I6 and
the strangler-fig migration strategy.
R01-T02 tests/fakes/{fake_provider,fake_tool_executor}.py
Scripted, offline Provider and extra-tool executor doubles.
R01-T03 scripts/check_imports.py
AST-based Clean Architecture Guard (CASAN Check 3). Also covers relative
imports and function-local imports; ASCII-only output for cp932 consoles.
R01-T04 tests/characterization/test_run_cowork.py
13 snapshot tests pinning run_cowork's current observable contract before
EPIC R04 moves its orchestration into application/.
R01-T05 docs/architecture/dormant-code.md
Import-graph scan: 43 unimported modules verified down to 6 genuinely
dormant items (~1887 LOC); the rest run via subprocess/CLI entry points.
tests/conftest.py binds `cowork_local` to THIS checkout by absolute path -
previously sys.path discovery could import a sibling checkout and the suite
would silently test the wrong code.
Suite: 104 passed, 1.08s (2 pre-existing failures in test_config_security.py
remain - config.py still ships a hardcoded default password, EPIC R02/Team Nam).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
240 lines
9.9 KiB
Python
240 lines
9.9 KiB
Python
#!/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, "<unparseable>",
|
|
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())
|