"""Unit tests for the Clean Architecture Guard, ``scripts/check_imports.py`` (R01-T03). The guard is what makes ADR-001 enforceable rather than aspirational, so it needs its own tests: a guard that silently passes everything is worse than no guard, because the CASAN Gate would then report a green architecture that isn't. Both directions are covered - it must FLAG real violations (including the function-local and relative import spellings this codebase actually uses) and it must NOT flag legal code (Qt named only in a docstring, domain importing stdlib). """ from __future__ import annotations import importlib.util import sys from pathlib import Path import pytest _GUARD_PATH = Path(__file__).resolve().parents[2] / "scripts" / "check_imports.py" def _load_guard(): """Import ``scripts/check_imports.py`` by path. ``scripts/`` is deliberately not a package (it holds standalone CLI tools), so a normal import statement cannot reach it. """ name = "_check_imports_under_test" spec = importlib.util.spec_from_file_location(name, _GUARD_PATH) module = importlib.util.module_from_spec(spec) # Registered before exec_module because @dataclass resolves a class's own # module out of sys.modules while processing annotations; without this the # guard's Violation dataclass fails to build under a by-path import. sys.modules[name] = module spec.loader.exec_module(module) return module guard = _load_guard() @pytest.fixture def fake_repo(tmp_path: Path, monkeypatch): """A throwaway repo root the guard scans instead of the real one. Pointing ``REPO_ROOT`` at a tmp dir keeps these tests independent of the actual state of ``domain/`` and ``application/`` - otherwise adding a real module later could flip a guard test red for no reason. """ monkeypatch.setattr(guard, "REPO_ROOT", tmp_path) return tmp_path def _write(root: Path, rel: str, source: str) -> Path: path = root / rel path.parent.mkdir(parents=True, exist_ok=True) path.write_text(source, encoding="utf-8") return path # --------------------------------------------------------------------------- # # Violations that must be caught # --------------------------------------------------------------------------- # def test_top_level_qt_import_in_domain_is_flagged(fake_repo): _write(fake_repo, "domain/agents/bad.py", "from PySide6 import QtWidgets\n") violations = guard.run(["domain"]) assert len(violations) == 1 assert "PySide6" in violations[0].imported assert "pure Python" in violations[0].rule def test_function_local_qt_import_is_flagged(fake_repo): """This repo defers heavy imports into function bodies to speed up start-up, so the guard walks the whole tree - a deferred Qt import breaks the layer exactly as much as a top-level one.""" _write(fake_repo, "application/conversations/bad.py", "def build():\n import PySide6.QtCore\n return PySide6\n") violations = guard.run(["application"]) assert len(violations) == 1 assert violations[0].line == 2 def test_application_importing_ui_is_flagged(fake_repo): _write(fake_repo, "application/conversations/bad.py", "from cowork_local.ui.chat_panel import ChatPanel\n") violations = guard.run(["application"]) assert len(violations) == 1 assert "ui/" in violations[0].rule def test_relative_import_that_escapes_the_layer_is_flagged(fake_repo): """``from ...ui import x`` inside ``domain/agents/`` resolves to the top-level ``ui`` package. Only relative-import resolution catches this - the text ``ui`` never appears as an absolute module name.""" _write(fake_repo, "domain/agents/bad.py", "from ...ui import widgets\n") violations = guard.run(["domain"]) assert len(violations) == 1 assert violations[0].imported == "...ui" def test_domain_importing_core_is_flagged(fake_repo): """``domain/`` is the innermost layer: it may not reach back into the legacy ``core/`` package either, or the dependency arrow would point outward.""" _write(fake_repo, "domain/models/bad.py", "from cowork_local.core import history\n") violations = guard.run(["domain"]) assert len(violations) == 1 def test_unparseable_file_is_reported_rather_than_skipped(fake_repo): """A file the guard cannot read must fail the gate. Skipping it would let a broken file smuggle any import past the check.""" _write(fake_repo, "domain/agents/broken.py", "def oops(:\n") violations = guard.run(["domain"]) assert len(violations) == 1 assert violations[0].imported == "" # --------------------------------------------------------------------------- # # Legal code that must NOT be flagged # --------------------------------------------------------------------------- # def test_qt_mentioned_only_in_a_docstring_is_not_flagged(fake_repo): """The whole reason the guard parses an AST instead of grepping: several real modules explain in prose that they must not import PySide6.""" _write(fake_repo, "domain/agents/ok.py", '"""This layer must never import PySide6 or PyQt6."""\n' 'QT = "PySide6" # a string, not an import\n') assert guard.run(["domain"]) == [] def test_stdlib_and_intra_layer_imports_are_allowed(fake_repo): _write(fake_repo, "domain/agents/ok.py", "import json\n" "from dataclasses import dataclass\n" "from ..models.provider_descriptor import ProviderDescriptor\n") assert guard.run(["domain"]) == [] def test_application_may_import_domain_and_infrastructure(fake_repo): """Application orchestrates: reaching down to domain is the point, and wiring an infrastructure adapter is allowed (only UI is forbidden).""" _write(fake_repo, "application/model_routing/ok.py", "from cowork_local.domain.models import provider_descriptor\n" "from cowork_local.infrastructure.providers import provider_registry\n") assert guard.run(["application"]) == [] def test_tests_folder_inside_a_layer_is_not_scanned(fake_repo): """A test living next to the code may legitimately import Qt; holding tests to the production rule would only teach people to disable the gate.""" _write(fake_repo, "domain/tests/test_thing.py", "from PySide6 import QtWidgets\n") assert guard.run(["domain"]) == [] # --------------------------------------------------------------------------- # # Reporting / exit codes - what CI actually consumes # --------------------------------------------------------------------------- # def test_main_returns_nonzero_and_prints_ascii_only_on_failure(fake_repo, capsys): """The team's Windows consoles run a legacy code page (cp932): a non-ASCII character in the failure output would raise UnicodeEncodeError and crash the gate on the very path it exists to report.""" _write(fake_repo, "domain/agents/bad.py", "from PySide6 import QtWidgets\n") exit_code = guard.main(["domain"]) out = capsys.readouterr().out assert exit_code == 1 assert "FAIL" in out assert "domain/agents/bad.py:1" in out out.encode("cp932") # raises if any character is unprintable on the target console def test_main_returns_zero_on_a_clean_tree(fake_repo, capsys): _write(fake_repo, "domain/agents/ok.py", "import json\n") exit_code = guard.main(["domain"]) assert exit_code == 0 assert "PASS" in capsys.readouterr().out