#!/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())