285 lines
10 KiB
Python
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())
|