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>
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user