144 lines
5.5 KiB
Python
144 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
REPOS = {
|
|
"core": ROOT / "src" / "govoplan_core",
|
|
"access": ROOT.parent / "govoplan-access" / "src" / "govoplan_access",
|
|
"admin": ROOT.parent / "govoplan-admin" / "src" / "govoplan_admin",
|
|
"tenancy": ROOT.parent / "govoplan-tenancy" / "src" / "govoplan_tenancy",
|
|
"organizations": ROOT.parent / "govoplan-organizations" / "src" / "govoplan_organizations",
|
|
"identity": ROOT.parent / "govoplan-identity" / "src" / "govoplan_identity",
|
|
"policy": ROOT.parent / "govoplan-policy" / "src" / "govoplan_policy",
|
|
"audit": ROOT.parent / "govoplan-audit" / "src" / "govoplan_audit",
|
|
"dashboard": ROOT.parent / "govoplan-dashboard" / "src" / "govoplan_dashboard",
|
|
"files": ROOT.parent / "govoplan-files" / "src" / "govoplan_files",
|
|
"mail": ROOT.parent / "govoplan-mail" / "src" / "govoplan_mail",
|
|
"campaign": ROOT.parent / "govoplan-campaign" / "src" / "govoplan_campaign",
|
|
}
|
|
PREFIXES = {
|
|
"core": "govoplan_core",
|
|
"access": "govoplan_access",
|
|
"admin": "govoplan_admin",
|
|
"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 = ("files", "mail", "campaign")
|
|
PLATFORM_OWNERS = ("admin", "tenancy", "organizations", "identity", "policy", "audit", "dashboard")
|
|
PUBLIC_ACCESS_IMPORTS = ("govoplan_access.auth",)
|
|
|
|
|
|
@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:
|
|
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 _is_public_access_import(imported: str) -> bool:
|
|
return any(imported == item or imported.startswith(f"{item}.") for item in PUBLIC_ACCESS_IMPORTS)
|
|
|
|
|
|
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 == "access" and _is_public_access_import(imported):
|
|
continue
|
|
if owner == "core" and imported_owner in FEATURE_OWNERS:
|
|
if not _allowed(owner, path, imported_owner):
|
|
violations.append(Violation(owner, path, lineno, imported, imported_owner))
|
|
elif owner == "access" and imported_owner in FEATURE_OWNERS:
|
|
violations.append(Violation(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(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(owner, path, lineno, imported, imported_owner))
|
|
elif owner in FEATURE_OWNERS and imported_owner == "access":
|
|
violations.append(Violation(owner, path, lineno, imported, imported_owner))
|
|
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} 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())
|