217 lines
8.0 KiB
Python
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())
|