Files
govoplan/tools/checks/check-manifest-shapes.py
T
zemion d78b13f9d3
Dependency Audit / dependency-audit (push) Failing after 10s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Failing after 9s
feat: implement institutional governance and recovery architecture
2026-08-01 17:46:53 +02:00

217 lines
8.0 KiB
Python

#!/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 re
import sys
import tomllib
from pathlib import Path
META_ROOT = Path(__file__).resolve().parents[2]
MODULE_NAME_PATTERN = re.compile(
r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*"
)
REQUIRED_DOCUMENTATION_TYPES = frozenset({"admin", "user"})
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.",
)
parser.add_argument(
"--require-architecture",
action="store_true",
help=(
"Reject every module that has not adopted the versioned architecture "
"declaration. GovOPlaN release and focused checks enable this gate."
),
)
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)
if MODULE_NAME_PATTERN.fullmatch(module_name) is None:
errors.append(
f"{repository_name}: manifest path does not map to a safe Python module name: {module_name!r}"
)
continue
try:
# Module names are derived from repository-owned manifest paths and
# constrained to canonical Python identifiers immediately above.
loaded_module = importlib.import_module(module_name) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
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
documented_types = {
documentation_type
for topic in manifest.documentation
for documentation_type in topic.documentation_types
}
missing_documentation_types = sorted(REQUIRED_DOCUMENTATION_TYPES - documented_types)
if missing_documentation_types:
errors.append(
f"{repository_name}: module {manifest.id!r} is missing static documentation for "
f"{', '.join(missing_documentation_types)}; add manifest DocumentationTopic entries "
"even when runtime documentation providers are registered"
)
continue
repository_root = manifest_path.parents[3]
if manifest.architecture is None:
if args.require_architecture:
errors.append(
f"{repository_name}: module {manifest.id!r} has no architecture declaration"
)
continue
else:
errors.extend(
_architecture_evidence_errors(
repository_name=repository_name,
repository_root=repository_root,
manifest=manifest,
)
)
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."
)
declared = sum(manifest.architecture is not None for manifest in manifests)
print(
f"Architecture declaration coverage: {declared}/{len(manifests)} modules "
f"({(declared / len(manifests) * 100):.1f}%)."
)
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()}
def _architecture_evidence_errors(
*,
repository_name: str,
repository_root: Path,
manifest: object,
) -> list[str]:
architecture = getattr(manifest, "architecture", None)
if architecture is None:
return []
errors: list[str] = []
references = [
(f"{item.kind} evidence", item.reference)
for item in architecture.evidence
]
for category in ("migration", "upgrade", "recovery", "security", "operations"):
references.extend(
(f"{category} documentation", reference)
for reference in getattr(architecture.documentation, category)
)
for label, reference in references:
if not _looks_like_repository_reference(reference):
continue
candidate = (repository_root / reference).resolve()
try:
candidate.relative_to(repository_root.resolve())
except ValueError:
errors.append(
f"{repository_name}: {label} escapes the repository: {reference!r}"
)
continue
if not candidate.exists():
errors.append(
f"{repository_name}: {label} does not exist: {reference!r}"
)
return errors
def _looks_like_repository_reference(reference: str) -> bool:
normalized = reference.strip()
if not normalized or "://" in normalized:
return False
return (
"/" in normalized
or normalized.startswith("README")
or normalized.endswith((".md", ".py", ".json", ".yaml", ".yml"))
)
if __name__ == "__main__":
raise SystemExit(main())