chore(checks): discover workspace module boundaries

This commit is contained in:
2026-07-20 20:01:14 +02:00
parent 4b217c7aba
commit 2e21397868
5 changed files with 375 additions and 93 deletions

View File

@@ -0,0 +1,144 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
META_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = META_ROOT / "tools" / "checks" / "check_dependency_boundaries.py"
def load_boundary_module():
spec = importlib.util.spec_from_file_location("check_dependency_boundaries", SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load {SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class DependencyBoundaryDiscoveryTests(unittest.TestCase):
def test_editable_install_metadata_is_not_treated_as_a_source_package(self) -> None:
boundary = load_boundary_module()
with tempfile.TemporaryDirectory(prefix="govoplan-boundary-discovery-") as directory:
root = Path(directory)
package = root / "govoplan-example" / "src" / "govoplan_example"
package.mkdir(parents=True)
(package / "__init__.py").write_text("", encoding="utf-8")
(package.parent / "govoplan_example.egg-info").mkdir()
repos, prefixes, errors = boundary._discover_python_repos(root)
self.assertEqual(errors, ())
self.assertEqual(repos, {"govoplan-example": package})
self.assertEqual(prefixes, {"govoplan-example": "govoplan_example"})
def test_ambiguous_or_missing_source_packages_fail_discovery(self) -> None:
boundary = load_boundary_module()
with tempfile.TemporaryDirectory(prefix="govoplan-boundary-discovery-") as directory:
root = Path(directory)
missing_source = root / "govoplan-missing" / "src"
missing_source.mkdir(parents=True)
(missing_source / "govoplan_missing.egg-info").mkdir()
ambiguous_source = root / "govoplan-ambiguous" / "src"
for name in ("govoplan_alpha", "govoplan_beta"):
package = ambiguous_source / name
package.mkdir(parents=True)
(package / "__init__.py").write_text("", encoding="utf-8")
repos, prefixes, errors = boundary._discover_python_repos(root)
self.assertEqual(repos, {})
self.assertEqual(prefixes, {})
self.assertEqual(len(errors), 2)
self.assertTrue(any("govoplan-missing" in error and "found 0" in error for error in errors))
self.assertTrue(any("govoplan-ambiguous" in error and "found 2" in error for error in errors))
def test_duplicate_python_package_ownership_fails_discovery(self) -> None:
boundary = load_boundary_module()
with tempfile.TemporaryDirectory(prefix="govoplan-boundary-discovery-") as directory:
root = Path(directory)
for owner in ("govoplan-first", "govoplan-second"):
package = root / owner / "src" / "govoplan_shared"
package.mkdir(parents=True)
(package / "__init__.py").write_text("", encoding="utf-8")
repos, prefixes, errors = boundary._discover_python_repos(root)
self.assertEqual(set(repos), {"govoplan-first"})
self.assertEqual(prefixes, {"govoplan-first": "govoplan_shared"})
self.assertEqual(len(errors), 1)
self.assertIn("already owned", errors[0])
def test_workspace_discovery_covers_changed_capability_repositories(self) -> None:
boundary = load_boundary_module()
self.assertEqual(boundary.PYTHON_DISCOVERY_ERRORS, ())
self.assertGreaterEqual(len(boundary.REPOS), 40)
for owner in (
"govoplan-core",
"govoplan-identity",
"govoplan-idm",
"govoplan-calendar",
"govoplan-poll",
"govoplan-scheduling",
):
self.assertIn(owner, boundary.REPOS)
def test_webui_discovery_is_fail_closed(self) -> None:
boundary = load_boundary_module()
with tempfile.TemporaryDirectory(prefix="govoplan-boundary-webui-") as directory:
root = Path(directory)
good = root / "govoplan-good" / "webui"
good.mkdir(parents=True)
(good / "package.json").write_text(
json.dumps({"name": "@govoplan/good-webui"}),
encoding="utf-8",
)
missing = root / "govoplan-missing" / "webui"
missing.mkdir(parents=True)
invalid = root / "govoplan-invalid" / "webui"
invalid.mkdir(parents=True)
(invalid / "package.json").write_text("{", encoding="utf-8")
unnamed = root / "govoplan-unnamed" / "webui"
unnamed.mkdir(parents=True)
(unnamed / "package.json").write_text("{}", encoding="utf-8")
duplicate = root / "govoplan-zduplicate" / "webui"
duplicate.mkdir(parents=True)
(duplicate / "package.json").write_text(
json.dumps({"name": "@govoplan/good-webui"}),
encoding="utf-8",
)
repos, packages, errors = boundary._discover_webui_repos(root)
self.assertEqual(repos, {"govoplan-good": good})
self.assertEqual(packages, {"govoplan-good": "@govoplan/good-webui"})
self.assertEqual(len(errors), 4)
self.assertTrue(any("govoplan-missing" in error and "no package.json" in error for error in errors))
self.assertTrue(any("govoplan-invalid" in error and "invalid" in error for error in errors))
self.assertTrue(any("govoplan-unnamed" in error and "package name" in error for error in errors))
self.assertTrue(any("govoplan-zduplicate" in error and "already owned" in error for error in errors))
def test_workspace_webui_discovery_covers_composition_repositories(self) -> None:
boundary = load_boundary_module()
self.assertEqual(boundary.WEBUI_DISCOVERY_ERRORS, ())
self.assertGreaterEqual(len(boundary.WEBUI_REPOS), 17)
for owner in (
"govoplan-core",
"govoplan-calendar",
"govoplan-scheduling",
"govoplan-notifications",
):
self.assertIn(owner, boundary.WEBUI_REPOS)
if __name__ == "__main__":
unittest.main()

View File

@@ -37,23 +37,10 @@ import pathlib
import sys import sys
root = pathlib.Path.cwd() root = pathlib.Path.cwd()
scan_roots = [ scan_roots = [root / "tests"] + [
root / "src", repo / "src"
root / "tests", for repo in sorted(root.parent.glob("govoplan*"))
root.parent / "govoplan-access" / "src", if (repo / "src").is_dir()
root.parent / "govoplan-admin" / "src",
root.parent / "govoplan-addresses" / "src",
root.parent / "govoplan-audit" / "src",
root.parent / "govoplan-calendar" / "src",
root.parent / "govoplan-campaign" / "src",
root.parent / "govoplan-dashboard" / "src",
root.parent / "govoplan-files" / "src",
root.parent / "govoplan-identity" / "src",
root.parent / "govoplan-mail" / "src",
root.parent / "govoplan-ops" / "src",
root.parent / "govoplan-organizations" / "src",
root.parent / "govoplan-policy" / "src",
root.parent / "govoplan-tenancy" / "src",
] ]
deprecated_tokens = { deprecated_tokens = {
"HTTP_422_UNPROCESSABLE_ENTITY": "Use HTTP_422_UNPROCESSABLE_CONTENT; the numeric status remains 422.", "HTTP_422_UNPROCESSABLE_ENTITY": "Use HTTP_422_UNPROCESSABLE_CONTENT; the numeric status remains 422.",

View File

@@ -28,27 +28,24 @@ cd "$ROOT"
GOVOPLAN_CORE_ROOT="$ROOT" PYTHON="$PYTHON" CHECK_TESTCLIENT_DEPRECATIONS=1 bash "$META_ROOT/tools/checks/check-dependency-hygiene.sh" GOVOPLAN_CORE_ROOT="$ROOT" PYTHON="$PYTHON" CHECK_TESTCLIENT_DEPRECATIONS=1 bash "$META_ROOT/tools/checks/check-dependency-hygiene.sh"
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact "$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
PYTHONDONTWRITEBYTECODE=1 "$PYTHON" "$META_ROOT/tools/checks/check-manifest-shapes.py"
"$PYTHON" - <<'PY' "$PYTHON" - <<'PY'
import ast import ast
import pathlib import pathlib
import sys import sys
repos_root = pathlib.Path("/mnt/DATA/git")
roots = [ roots = [
pathlib.Path("/mnt/DATA/git/govoplan-core/src"), repo / "src"
pathlib.Path("/mnt/DATA/git/govoplan-access/src"), for repo in sorted(repos_root.glob("govoplan*"))
pathlib.Path("/mnt/DATA/git/govoplan-admin/src"), if (repo / "src").is_dir()
pathlib.Path("/mnt/DATA/git/govoplan-addresses/src"),
pathlib.Path("/mnt/DATA/git/govoplan-tenancy/src"),
pathlib.Path("/mnt/DATA/git/govoplan-organizations/src"),
pathlib.Path("/mnt/DATA/git/govoplan-identity/src"),
pathlib.Path("/mnt/DATA/git/govoplan-policy/src"),
pathlib.Path("/mnt/DATA/git/govoplan-audit/src"),
pathlib.Path("/mnt/DATA/git/govoplan-mail/src"),
pathlib.Path("/mnt/DATA/git/govoplan-files/src"),
pathlib.Path("/mnt/DATA/git/govoplan-campaign/src"),
pathlib.Path("/mnt/DATA/git/govoplan-mail/tests"),
] ]
roots.extend(
repo / "tests"
for repo in sorted(repos_root.glob("govoplan*"))
if (repo / "tests").is_dir()
)
errors = [] errors = []
count = 0 count = 0

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Load and validate every available GovOPlaN module manifest from source."""
from __future__ import annotations
import argparse
import importlib
import json
import sys
import tomllib
from pathlib import Path
META_ROOT = Path(__file__).resolve().parents[2]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--workspace-root",
type=Path,
default=None,
help="Directory containing the GovOPlaN repositories. Defaults to repositories.json default_parent.",
)
args = parser.parse_args()
catalog = json.loads((META_ROOT / "repositories.json").read_text(encoding="utf-8"))
workspace_root = (args.workspace_root or Path(catalog["default_parent"])).resolve()
repositories = tuple(catalog["repositories"])
source_roots: list[Path] = []
manifest_sources: list[tuple[str, Path, Path]] = []
for repository in repositories:
repository_root = workspace_root / repository["path"]
source_root = repository_root / "src"
if not source_root.is_dir():
continue
source_roots.append(source_root)
manifest_sources.extend(
(repository["name"], source_root, manifest_path)
for manifest_path in sorted(source_root.glob("*/backend/manifest.py"))
)
if not manifest_sources:
print(f"No module manifests found below {workspace_root}.", file=sys.stderr)
return 1
core_source = workspace_root / "govoplan-core" / "src"
ordered_source_roots = [core_source, *(root for root in source_roots if root != core_source)]
sys.path[:0] = [str(root) for root in ordered_source_roots]
from govoplan_core.core.modules import ModuleManifest # noqa: PLC0415
from govoplan_core.core.registry import PlatformRegistry, RegistryError # noqa: PLC0415
manifests: list[ModuleManifest] = []
errors: list[str] = []
for repository_name, source_root, manifest_path in manifest_sources:
module_name = ".".join(manifest_path.relative_to(source_root).with_suffix("").parts)
try:
loaded_module = importlib.import_module(module_name)
get_manifest = getattr(loaded_module, "get_manifest")
manifest = get_manifest()
except Exception as exc: # pragma: no cover - rendered as a check failure
errors.append(f"{repository_name}: could not load {module_name}: {exc}")
continue
if not isinstance(manifest, ModuleManifest):
errors.append(f"{repository_name}: {module_name}.get_manifest() did not return ModuleManifest")
continue
entry_points = _module_entry_points(manifest_path.parents[3] / "pyproject.toml")
expected_target = f"{module_name}:get_manifest"
actual_target = entry_points.get(manifest.id)
if actual_target != expected_target:
declared = ", ".join(f"{name}={target}" for name, target in sorted(entry_points.items())) or "none"
errors.append(
f"{repository_name}: module {manifest.id!r} must declare entry point "
f"{manifest.id}={expected_target}; found {declared}"
)
continue
manifests.append(manifest)
if errors:
print("\n".join(errors), file=sys.stderr)
return 1
registry = PlatformRegistry()
try:
for manifest in manifests:
registry.register(manifest)
snapshot = registry.validate()
except RegistryError as exc:
print(f"Manifest registry validation failed: {exc}", file=sys.stderr)
return 1
print(
f"Manifest registry check passed: {len(snapshot.manifests)} manifests "
f"from {len(manifest_sources)} source files."
)
return 0
def _module_entry_points(pyproject_path: Path) -> dict[str, str]:
if not pyproject_path.is_file():
return {}
pyproject = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
values = pyproject.get("project", {}).get("entry-points", {}).get("govoplan.modules", {})
return {str(name): str(target) for name, target in values.items()}
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -11,52 +11,89 @@ from pathlib import Path
META_ROOT = Path(__file__).resolve().parents[2] META_ROOT = Path(__file__).resolve().parents[2]
CORE_ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve() 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_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", def _discover_python_repos(
"admin": REPOS_ROOT / "govoplan-admin" / "src" / "govoplan_admin", repos_root: Path = REPOS_ROOT,
"addresses": REPOS_ROOT / "govoplan-addresses" / "src" / "govoplan_addresses", ) -> tuple[dict[str, Path], dict[str, str], tuple[str, ...]]:
"tenancy": REPOS_ROOT / "govoplan-tenancy" / "src" / "govoplan_tenancy", repos: dict[str, Path] = {}
"organizations": REPOS_ROOT / "govoplan-organizations" / "src" / "govoplan_organizations", prefixes: dict[str, str] = {}
"identity": REPOS_ROOT / "govoplan-identity" / "src" / "govoplan_identity", errors: list[str] = []
"policy": REPOS_ROOT / "govoplan-policy" / "src" / "govoplan_policy", for repo_root in sorted(repos_root.glob("govoplan*")):
"audit": REPOS_ROOT / "govoplan-audit" / "src" / "govoplan_audit", source_root = repo_root / "src"
"dashboard": REPOS_ROOT / "govoplan-dashboard" / "src" / "govoplan_dashboard", if not source_root.is_dir():
"files": REPOS_ROOT / "govoplan-files" / "src" / "govoplan_files", continue
"mail": REPOS_ROOT / "govoplan-mail" / "src" / "govoplan_mail", packages = sorted(
"campaign": REPOS_ROOT / "govoplan-campaign" / "src" / "govoplan_campaign", path
} for path in source_root.glob("govoplan_*")
PREFIXES = { if path.is_dir() and (path / "__init__.py").is_file()
"core": "govoplan_core", )
"access": "govoplan_access", if len(packages) != 1:
"admin": "govoplan_admin", discovered = ", ".join(path.name for path in packages) or "none"
"addresses": "govoplan_addresses", errors.append(
"tenancy": "govoplan_tenancy", f"{repo_root.name}: expected exactly one importable govoplan_* package "
"organizations": "govoplan_organizations", f"under {source_root}, found {len(packages)} ({discovered})"
"identity": "govoplan_identity", )
"policy": "govoplan_policy", continue
"audit": "govoplan_audit", owner = repo_root.name
"dashboard": "govoplan_dashboard", prefix = packages[0].name
"files": "govoplan_files", duplicate_owner = next(
"mail": "govoplan_mail", (candidate_owner for candidate_owner, candidate_prefix in prefixes.items() if candidate_prefix == prefix),
"campaign": "govoplan_campaign", None,
} )
FEATURE_OWNERS = ("addresses", "files", "mail", "campaign") if duplicate_owner is not None:
PLATFORM_OWNERS = ("admin", "tenancy", "organizations", "identity", "policy", "audit", "dashboard") errors.append(
WEBUI_REPOS = { f"{owner}: import package {prefix!r} is already owned by {duplicate_owner}; "
"core": CORE_ROOT / "webui", "Python package ownership must be unique"
"addresses": REPOS_ROOT / "govoplan-addresses" / "webui", )
"files": REPOS_ROOT / "govoplan-files" / "webui", continue
"mail": REPOS_ROOT / "govoplan-mail" / "webui", repos[owner] = packages[0]
"campaign": REPOS_ROOT / "govoplan-campaign" / "webui", prefixes[owner] = prefix
} return repos, prefixes, tuple(errors)
WEBUI_PACKAGES = {
"core": "@govoplan/core-webui",
"addresses": "@govoplan/addresses-webui", def _discover_webui_repos(
"files": "@govoplan/files-webui", repos_root: Path = REPOS_ROOT,
"mail": "@govoplan/mail-webui", ) -> tuple[dict[str, Path], dict[str, str], tuple[str, ...]]:
"campaign": "@govoplan/campaign-webui", 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( WEBUI_IMPORT_RE = re.compile(
r"(?:from\s+|import\s*\(\s*|require\s*\(\s*|export\s+[^;]*?\s+from\s+)" 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"[\"'](?P<package>@govoplan/[a-z0-9_-]+-webui)(?:/[A-Za-z0-9_./-]+)?[\"']"
@@ -127,18 +164,7 @@ def _violations() -> list[Violation]:
imported_owner = _import_owner(imported) imported_owner = _import_owner(imported)
if imported_owner is None or imported_owner == owner: if imported_owner is None or imported_owner == owner:
continue continue
if owner == "core" and imported_owner in FEATURE_OWNERS: if imported_owner != CORE_OWNER and not _allowed(owner, path, imported_owner):
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.append(Violation("python", owner, path, lineno, imported, imported_owner))
violations.extend(_webui_violations()) violations.extend(_webui_violations())
return violations return violations
@@ -191,7 +217,12 @@ def _webui_package_json_violations(owner: str, root: Path) -> list[Violation]:
imported_owner = _webui_package_owner(str(package_name)) imported_owner = _webui_package_owner(str(package_name))
if imported_owner is None or imported_owner == owner: if imported_owner is None or imported_owner == owner:
continue continue
if owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS: # 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)) violations.append(Violation("webui-package", owner, package_path, 1, str(package_name), imported_owner))
return violations return violations
@@ -206,9 +237,7 @@ def _webui_source_violations(owner: str, root: Path) -> list[Violation]:
if imported_owner is None or imported_owner == owner: if imported_owner is None or imported_owner == owner:
continue continue
lineno = text.count("\n", 0, match.start()) + 1 lineno = text.count("\n", 0, match.start()) + 1
if owner == "core" and imported_owner in FEATURE_OWNERS: if imported_owner != CORE_OWNER:
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)) violations.append(Violation("webui-source", owner, path, lineno, package_name, imported_owner))
return violations return violations
@@ -224,9 +253,21 @@ def _webui_violations() -> list[Violation]:
def main() -> int: 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() violations = _violations()
if not violations: if not violations:
print(f"Dependency boundary check passed ({len(ALLOWLIST)} transitional exceptions tracked)") print(
"Dependency boundary check passed "
f"({len(REPOS)} Python repos, {len(WEBUI_REPOS)} WebUI repos, "
f"{len(ALLOWLIST)} transitional exceptions tracked)"
)
return 0 return 0
print("Dependency boundary violations:") print("Dependency boundary violations:")