Files
govoplan/tools/checks/check-manifest-shapes.py
T
zemion e2f505eeab
Dependency Audit / dependency-audit (push) Successful in 1m57s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 12m24s
feat: enforce shared UI foundations and pin reference journey
2026-08-18 21:32:25 +02:00

388 lines
14 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"})
CANONICAL_PRODUCT_AREAS = {
"work": (
"i18n:govoplan-core.product_area.work",
"list-checks",
"i18n:govoplan-core.product_area.work_description",
10,
),
"services-cases": (
"i18n:govoplan-core.product_area.services_cases",
"landmark",
"i18n:govoplan-core.product_area.services_cases_description",
20,
),
"records-documents": (
"i18n:govoplan-core.product_area.records_documents",
"folder",
"i18n:govoplan-core.product_area.records_documents_description",
30,
),
"communication": (
"i18n:govoplan-core.product_area.communication",
"mail",
"i18n:govoplan-core.product_area.communication_description",
40,
),
"meetings-decisions": (
"i18n:govoplan-core.product_area.meetings_decisions",
"calendar",
"i18n:govoplan-core.product_area.meetings_decisions_description",
50,
),
"data-assurance": (
"i18n:govoplan-core.product_area.data_assurance",
"database-zap",
"i18n:govoplan-core.product_area.data_assurance_description",
60,
),
"people-responsibility": (
"i18n:govoplan-core.product_area.people_responsibility",
"users",
"i18n:govoplan-core.product_area.people_responsibility_description",
70,
),
}
# These surfaces are intentionally global, administrative, security-policy, or
# shell infrastructure. They remain discoverable through their dedicated shell
# affordance or through "All available tools" instead of a business area.
PRODUCT_AREA_EXEMPT_MODULES = frozenset(
{
"access",
"admin",
"audit",
"dashboard",
"docs",
"encryption",
"identity_trust",
"ops",
"policy",
"quick_access",
"search",
"tenancy",
"views",
}
)
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
frontend = manifest.frontend
has_user_facing_surface = frontend is not None and bool(
frontend.routes
or frontend.public_routes
or frontend.nav_items
or frontend.settings_routes
)
if (
has_user_facing_surface
and not frontend.product_areas
and manifest.id not in PRODUCT_AREA_EXEMPT_MODULES
):
errors.append(
f"{repository_name}: user-facing module {manifest.id!r} has no "
"ProductAreaContribution and is not an explicit global/technical exemption"
)
if frontend is not None:
for contribution in frontend.product_areas:
expected = CANONICAL_PRODUCT_AREAS.get(contribution.id)
actual = (
contribution.label,
contribution.icon,
contribution.description,
contribution.order,
)
if expected is None:
errors.append(
f"{repository_name}: module {manifest.id!r} uses unknown product "
f"area {contribution.id!r}"
)
elif actual != expected:
errors.append(
f"{repository_name}: module {manifest.id!r} redefines canonical "
f"product area {contribution.id!r}; expected {expected!r}, found {actual!r}"
)
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,
)
)
errors.extend(
_information_governance_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
contributed_product_areas = {
contribution.id
for manifest in manifests
if manifest.frontend is not None
for contribution in manifest.frontend.product_areas
}
missing_product_areas = sorted(
set(CANONICAL_PRODUCT_AREAS) - contributed_product_areas
)
if missing_product_areas:
print(
"Canonical product areas have no contributing module: "
+ ", ".join(missing_product_areas),
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}%)."
)
governance_counts: dict[str, int] = {}
for manifest in manifests:
for dimension in manifest.information_governance.dimensions.values():
governance_counts[dimension.adoption] = (
governance_counts.get(dimension.adoption, 0) + 1
)
print(
"Information-governance adoption: "
+ ", ".join(
f"{status}={count}"
for status, count in sorted(governance_counts.items())
)
+ "."
)
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 _information_governance_evidence_errors(
*,
repository_name: str,
repository_root: Path,
manifest: object,
) -> list[str]:
declaration = getattr(manifest, "information_governance", None)
if declaration is None:
return [
f"{repository_name}: module has no information-governance declaration"
]
errors: list[str] = []
for dimension_name, dimension in declaration.dimensions.items():
for reference in dimension.evidence:
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}: {dimension_name} evidence escapes the "
f"repository: {reference!r}"
)
continue
if not candidate.exists():
errors.append(
f"{repository_name}: {dimension_name} evidence does not exist: "
f"{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())