#!/usr/bin/env python3 from __future__ import annotations import ast import json import os import re from dataclasses import dataclass from pathlib import Path META_ROOT = Path(__file__).resolve().parents[2] CORE_ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve() REPOS_ROOT = Path(os.environ.get("GOVOPLAN_REPOS_ROOT", CORE_ROOT.parent)).resolve() REPOS = { "core": CORE_ROOT / "src" / "govoplan_core", "access": REPOS_ROOT / "govoplan-access" / "src" / "govoplan_access", "admin": REPOS_ROOT / "govoplan-admin" / "src" / "govoplan_admin", "addresses": REPOS_ROOT / "govoplan-addresses" / "src" / "govoplan_addresses", "tenancy": REPOS_ROOT / "govoplan-tenancy" / "src" / "govoplan_tenancy", "organizations": REPOS_ROOT / "govoplan-organizations" / "src" / "govoplan_organizations", "identity": REPOS_ROOT / "govoplan-identity" / "src" / "govoplan_identity", "policy": REPOS_ROOT / "govoplan-policy" / "src" / "govoplan_policy", "audit": REPOS_ROOT / "govoplan-audit" / "src" / "govoplan_audit", "dashboard": REPOS_ROOT / "govoplan-dashboard" / "src" / "govoplan_dashboard", "files": REPOS_ROOT / "govoplan-files" / "src" / "govoplan_files", "mail": REPOS_ROOT / "govoplan-mail" / "src" / "govoplan_mail", "campaign": REPOS_ROOT / "govoplan-campaign" / "src" / "govoplan_campaign", } PREFIXES = { "core": "govoplan_core", "access": "govoplan_access", "admin": "govoplan_admin", "addresses": "govoplan_addresses", "tenancy": "govoplan_tenancy", "organizations": "govoplan_organizations", "identity": "govoplan_identity", "policy": "govoplan_policy", "audit": "govoplan_audit", "dashboard": "govoplan_dashboard", "files": "govoplan_files", "mail": "govoplan_mail", "campaign": "govoplan_campaign", } FEATURE_OWNERS = ("addresses", "files", "mail", "campaign") PLATFORM_OWNERS = ("admin", "tenancy", "organizations", "identity", "policy", "audit", "dashboard") WEBUI_REPOS = { "core": CORE_ROOT / "webui", "addresses": REPOS_ROOT / "govoplan-addresses" / "webui", "files": REPOS_ROOT / "govoplan-files" / "webui", "mail": REPOS_ROOT / "govoplan-mail" / "webui", "campaign": REPOS_ROOT / "govoplan-campaign" / "webui", } WEBUI_PACKAGES = { "core": "@govoplan/core-webui", "addresses": "@govoplan/addresses-webui", "files": "@govoplan/files-webui", "mail": "@govoplan/mail-webui", "campaign": "@govoplan/campaign-webui", } WEBUI_IMPORT_RE = re.compile( r"(?:from\s+|import\s*\(\s*|require\s*\(\s*|export\s+[^;]*?\s+from\s+)" r"[\"'](?P@govoplan/[a-z0-9_-]+-webui)(?:/[A-Za-z0-9_./-]+)?[\"']" r"|import\s+[\"'](?P@govoplan/[a-z0-9_-]+-webui)(?:/[A-Za-z0-9_./-]+)?[\"']" ) @dataclass(frozen=True) class AllowlistedImport: owner: str relative_path: str imported_owner: str reason: str # Transitional exceptions. This should remain empty; add entries only with a # dated removal plan and a capability/API contract issue. ALLOWLIST: tuple[AllowlistedImport, ...] = () ALLOWLIST_KEYS = {(item.owner, item.relative_path, item.imported_owner) for item in ALLOWLIST} @dataclass(frozen=True) class Violation: kind: str owner: str path: Path lineno: int imported: str imported_owner: str def _import_owner(module_name: str) -> str | None: for owner, prefix in PREFIXES.items(): if module_name == prefix or module_name.startswith(f"{prefix}."): return owner return None def _imports(path: Path) -> list[tuple[int, str]]: tree = ast.parse(path.read_text(), filename=str(path)) result: list[tuple[int, str]] = [] for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: result.append((node.lineno, alias.name)) elif isinstance(node, ast.ImportFrom) and node.module: result.append((node.lineno, node.module)) return result def _relative(owner: str, path: Path) -> str: return path.relative_to(REPOS[owner]).as_posix() def _allowed(owner: str, path: Path, imported_owner: str) -> bool: return (owner, _relative(owner, path), imported_owner) in ALLOWLIST_KEYS def _violations() -> list[Violation]: violations: list[Violation] = [] for owner, root in REPOS.items(): if not root.exists(): continue for path in root.rglob("*.py"): if "__pycache__" in path.parts: continue for lineno, imported in _imports(path): imported_owner = _import_owner(imported) if imported_owner is None or imported_owner == owner: continue if owner == "core" and imported_owner in FEATURE_OWNERS: if not _allowed(owner, path, imported_owner): violations.append(Violation("python", owner, path, lineno, imported, imported_owner)) elif owner == "access" and imported_owner in FEATURE_OWNERS: violations.append(Violation("python", owner, path, lineno, imported, imported_owner)) elif owner in PLATFORM_OWNERS and imported_owner == "access": if not _allowed(owner, path, imported_owner): violations.append(Violation("python", owner, path, lineno, imported, imported_owner)) elif owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS: if not _allowed(owner, path, imported_owner): violations.append(Violation("python", owner, path, lineno, imported, imported_owner)) elif owner in FEATURE_OWNERS and imported_owner == "access": violations.append(Violation("python", owner, path, lineno, imported, imported_owner)) violations.extend(_webui_violations()) return violations def _webui_package_owner(package_name: str) -> str | None: for owner, package in WEBUI_PACKAGES.items(): if package_name == package: return owner return None def _webui_source_files(root: Path) -> list[Path]: source_root = root / "src" if not source_root.exists(): return [] return [ path for path in source_root.rglob("*") if path.suffix in {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"} and "node_modules" not in path.parts and "dist" not in path.parts ] def _webui_dependency_fields(payload: object) -> dict[str, object]: if not isinstance(payload, dict): return {} result: dict[str, object] = {} for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"): value = payload.get(key) if isinstance(value, dict): result.update(value) return result def _webui_package_json_violations(owner: str, root: Path) -> list[Violation]: violations: list[Violation] = [] package_paths = [root / "package.json"] if root.name == "webui": package_paths.append(root.parent / "package.json") for package_path in tuple(dict.fromkeys(package_paths)): if not package_path.exists(): continue try: payload = json.loads(package_path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: return [Violation("webui-package", owner, package_path, exc.lineno, "invalid package.json", owner)] for package_name in _webui_dependency_fields(payload): imported_owner = _webui_package_owner(str(package_name)) if imported_owner is None or imported_owner == owner: continue if owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS: violations.append(Violation("webui-package", owner, package_path, 1, str(package_name), imported_owner)) return violations def _webui_source_violations(owner: str, root: Path) -> list[Violation]: violations: list[Violation] = [] for path in _webui_source_files(root): text = path.read_text(encoding="utf-8") for match in WEBUI_IMPORT_RE.finditer(text): package_name = match.group("package") or match.group("side_effect_package") imported_owner = _webui_package_owner(package_name) if imported_owner is None or imported_owner == owner: continue lineno = text.count("\n", 0, match.start()) + 1 if owner == "core" and imported_owner in FEATURE_OWNERS: violations.append(Violation("webui-source", owner, path, lineno, package_name, imported_owner)) elif owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS: violations.append(Violation("webui-source", owner, path, lineno, package_name, imported_owner)) return violations def _webui_violations() -> list[Violation]: violations: list[Violation] = [] for owner, root in WEBUI_REPOS.items(): if not root.exists(): continue violations.extend(_webui_package_json_violations(owner, root)) violations.extend(_webui_source_violations(owner, root)) return violations def main() -> int: violations = _violations() if not violations: print(f"Dependency boundary check passed ({len(ALLOWLIST)} transitional exceptions tracked)") return 0 print("Dependency boundary violations:") for item in violations: print( f"- {item.path}:{item.lineno}: {item.owner} {item.kind} imports {item.imported} " f"({item.imported_owner}); use kernel capability/API/event contracts instead" ) print("\nIf this is unavoidable transitional debt, add a reasoned ALLOWLIST entry.") return 1 if __name__ == "__main__": raise SystemExit(main())