Files
govoplan/tools/checks/check_dependency_boundaries.py

285 lines
10 KiB
Python

#!/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<package>@govoplan/[a-z0-9_-]+-webui)(?:/[A-Za-z0-9_./-]+)?[\"']"
r"|import\s+[\"'](?P<side_effect_package>@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())