#!/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() def _discover_python_repos( repos_root: Path = REPOS_ROOT, ) -> tuple[dict[str, Path], dict[str, str], tuple[str, ...]]: repos: dict[str, Path] = {} prefixes: dict[str, str] = {} errors: list[str] = [] for repo_root in sorted(repos_root.glob("govoplan*")): source_root = repo_root / "src" if not source_root.is_dir(): continue packages = sorted( path for path in source_root.glob("govoplan_*") if path.is_dir() and (path / "__init__.py").is_file() ) if len(packages) != 1: discovered = ", ".join(path.name for path in packages) or "none" errors.append( f"{repo_root.name}: expected exactly one importable govoplan_* package " f"under {source_root}, found {len(packages)} ({discovered})" ) continue owner = repo_root.name prefix = packages[0].name duplicate_owner = next( (candidate_owner for candidate_owner, candidate_prefix in prefixes.items() if candidate_prefix == prefix), None, ) if duplicate_owner is not None: errors.append( f"{owner}: import package {prefix!r} is already owned by {duplicate_owner}; " "Python package ownership must be unique" ) continue repos[owner] = packages[0] prefixes[owner] = prefix return repos, prefixes, tuple(errors) def _discover_webui_repos( repos_root: Path = REPOS_ROOT, ) -> tuple[dict[str, Path], dict[str, str], tuple[str, ...]]: repos: dict[str, Path] = {} packages: dict[str, str] = {} errors: list[str] = [] for repo_root in sorted(repos_root.glob("govoplan*")): webui_root = repo_root / "webui" if not webui_root.is_dir(): continue package_path = webui_root / "package.json" if not package_path.is_file(): errors.append(f"{repo_root.name}: WebUI directory has no package.json: {package_path}") continue try: payload = json.loads(package_path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: errors.append(f"{repo_root.name}: invalid {package_path}: {exc}") continue package_name = payload.get("name") if isinstance(payload, dict) else None if not isinstance(package_name, str) or not package_name: errors.append(f"{repo_root.name}: {package_path} must declare a non-empty package name") continue owner = repo_root.name duplicate_owner = next( (candidate_owner for candidate_owner, candidate_package in packages.items() if candidate_package == package_name), None, ) if duplicate_owner is not None: errors.append( f"{owner}: WebUI package {package_name!r} is already owned by {duplicate_owner}; " "WebUI package ownership must be unique" ) continue repos[owner] = webui_root packages[owner] = package_name return repos, packages, tuple(errors) REPOS, PREFIXES, PYTHON_DISCOVERY_ERRORS = _discover_python_repos() WEBUI_REPOS, WEBUI_PACKAGES, WEBUI_DISCOVERY_ERRORS = _discover_webui_repos() CORE_OWNER = CORE_ROOT.name 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 imported_owner != CORE_OWNER and not _allowed(owner, path, imported_owner): 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 # The core WebUI package is also the development composition host; # package declarations make independently built module frontends # available to Vite without authorizing source-level imports. if owner == CORE_OWNER: continue if imported_owner != CORE_OWNER: 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 imported_owner != CORE_OWNER: 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: discovery_errors = (*PYTHON_DISCOVERY_ERRORS, *WEBUI_DISCOVERY_ERRORS) if discovery_errors: print("Dependency boundary discovery errors:") for error in discovery_errors: print(f"- {error}") print("\nThe boundary scan is incomplete; fix package discovery before accepting this gate.") return 1 violations = _violations() if not violations: print( "Dependency boundary check passed " f"({len(REPOS)} Python repos, {len(WEBUI_REPOS)} WebUI repos, " f"{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())