Files
cowork-local/scripts/check_imports.py
T
anhtnm1andClaude Opus 5 bbc09f628a feat(R01): architecture foundation, offline fakes and characterization net
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>
2026-08-21 10:05:50 +09:00

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())