diff --git a/tests/test_ui_boundaries.py b/tests/test_ui_boundaries.py index a067d3a..102f6bd 100644 --- a/tests/test_ui_boundaries.py +++ b/tests/test_ui_boundaries.py @@ -15,23 +15,15 @@ UI_DIR = Path(__file__).resolve().parents[1] / "src" / "transcription" / "ui" PAGES_DIR = UI_DIR / "pages" COMPONENTS_DIR = UI_DIR / "components" -# Names a page must not pull in: they hand the page a session, a transaction, ORM -# loader introspection, or process-global configuration. -FORBIDDEN_PAGE_IMPORTS = frozenset( - { - "session_scope", - "transaction_scope", - "get_session_factory", - "resolve_session_factory", - "get_settings", - "get_engine", - "upgrade_schema", - "create_all", - } -) - FORBIDDEN_PAGE_MODULES = frozenset({"sqlalchemy", "sqlmodel"}) +# Sensitive modules are allowlisted, not blocklisted, so newly added persistence +# helpers cannot slip through by using an unlisted name. +PAGE_IMPORT_ALLOWLIST: dict[tuple[str, int], frozenset[str]] = { + ("db.session", 3): frozenset({"SessionFactoryDep"}), + ("transcription.config", 0): frozenset({"Settings"}), +} + def _page_paths() -> list[Path]: return sorted(path for path in PAGES_DIR.glob("*.py") if path.stem != "__init__") @@ -57,6 +49,21 @@ def _imports(tree: ast.Module) -> tuple[set[str], set[str]]: return names, modules +def _page_allowlist_violations(tree: ast.Module) -> set[str]: + violations: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom) or not node.module: + continue + allowed = PAGE_IMPORT_ALLOWLIST.get((node.module, node.level)) + if allowed is None: + continue + for alias in node.names: + imported_name = alias.name + if imported_name not in allowed: + violations.add(f"{'.' * node.level}{node.module}.{imported_name}") + return violations + + def test_page_modules_are_discovered(): """Guard the guard: the rules below are meaningless if nothing is scanned.""" discovered = {path.stem for path in _page_paths()} @@ -67,8 +74,10 @@ def test_no_page_imports_persistence_or_process_globals(): """HIGH-07: pages orchestrate services; they do not own sessions or settings.""" violations: dict[str, list[str]] = {} for path in _page_paths(): - names, modules = _imports(ast.parse(path.read_text(encoding="utf-8"))) - found = sorted((names & FORBIDDEN_PAGE_IMPORTS) | (modules & FORBIDDEN_PAGE_MODULES)) + tree = ast.parse(path.read_text(encoding="utf-8")) + _, modules = _imports(tree) + allowlist_violations = _page_allowlist_violations(tree) + found = sorted((modules & FORBIDDEN_PAGE_MODULES) | allowlist_violations) if found: violations[path.stem] = found assert violations == {} @@ -79,7 +88,8 @@ def test_no_component_resolves_request_or_application_state(): violations: dict[str, list[str]] = {} for path in _component_paths(): names, modules = _imports(ast.parse(path.read_text(encoding="utf-8"))) - found = sorted((names & FORBIDDEN_PAGE_IMPORTS) | (modules & (FORBIDDEN_PAGE_MODULES | {"fastapi"}))) + forbidden_names = {"get_settings", "get_engine", "session_scope", "transaction_scope"} + found = sorted((names & forbidden_names) | (modules & (FORBIDDEN_PAGE_MODULES | {"fastapi"}))) if found: violations[str(path.relative_to(COMPONENTS_DIR))] = found assert violations == {}