intermediate commit
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 16s
Security Audit / security-audit (push) Failing after 11s

This commit is contained in:
2026-07-14 13:32:09 +02:00
parent dd796b4d3c
commit 05ae81d641
58 changed files with 7526 additions and 66 deletions

View File

@@ -0,0 +1,445 @@
"""Manifest contract extraction and validation for release planning.
The static scanner intentionally parses manifest source files with ``ast``
instead of importing module packages. That keeps contract checks usable before
``pip install`` has been rerun for a changed ``pyproject.toml`` or entry point.
"""
from __future__ import annotations
import ast
import re
from pathlib import Path
from .model import (
CompatibilityIssue,
InterfaceProviderSnapshot,
InterfaceRequirementSnapshot,
ModuleContractSnapshot,
RepositorySnapshot,
)
def collect_contracts(repositories: tuple[RepositorySnapshot, ...]) -> tuple[ModuleContractSnapshot, ...]:
contracts: list[ModuleContractSnapshot] = []
for repo in repositories:
if not repo.exists or not repo.is_git:
continue
root = Path(repo.absolute_path)
for manifest in sorted(root.glob("src/**/backend/manifest.py")):
contract = parse_manifest_contract(manifest, repo_name=repo.spec.name)
if contract is not None:
contracts.append(contract)
return tuple(contracts)
def validate_contracts(contracts: tuple[ModuleContractSnapshot, ...]) -> tuple[CompatibilityIssue, ...]:
"""Validate the statically collected module interface graph.
This mirrors the runtime interface closure validation in
``govoplan_core.core.registry`` but reports all issues at once for CI and
release planning.
"""
issues: list[CompatibilityIssue] = []
providers: dict[str, list[tuple[ModuleContractSnapshot, InterfaceProviderSnapshot]]] = {}
module_ids: dict[str, list[ModuleContractSnapshot]] = {}
for contract in contracts:
module_ids.setdefault(contract.module_id, []).append(contract)
local_provider_names: set[str] = set()
local_requirement_names: set[str] = set()
for provider in contract.provides_interfaces:
if provider.name in local_provider_names:
issues.append(
CompatibilityIssue(
severity="blocker",
code="duplicate_provided_interface",
message=f"{contract.module_id} provides interface {provider.name!r} more than once.",
repo=contract.repo,
module_id=contract.module_id,
interface=provider.name,
)
)
local_provider_names.add(provider.name)
if not valid_interface_name(provider.name):
issues.append(
CompatibilityIssue(
severity="blocker",
code="invalid_provided_interface_name",
message=f"{contract.module_id} provides invalid interface name {provider.name!r}.",
repo=contract.repo,
module_id=contract.module_id,
interface=provider.name,
)
)
if not provider.version.strip():
issues.append(
CompatibilityIssue(
severity="blocker",
code="missing_provided_interface_version",
message=f"{contract.module_id} provides interface {provider.name!r} without a version.",
repo=contract.repo,
module_id=contract.module_id,
interface=provider.name,
)
)
providers.setdefault(provider.name, []).append((contract, provider))
for requirement in contract.requires_interfaces:
if requirement.name in local_requirement_names:
issues.append(
CompatibilityIssue(
severity="blocker",
code="duplicate_required_interface",
message=f"{contract.module_id} requires interface {requirement.name!r} more than once.",
repo=contract.repo,
module_id=contract.module_id,
interface=requirement.name,
)
)
local_requirement_names.add(requirement.name)
if not valid_interface_name(requirement.name):
issues.append(
CompatibilityIssue(
severity="blocker",
code="invalid_required_interface_name",
message=f"{contract.module_id} requires invalid interface name {requirement.name!r}.",
repo=contract.repo,
module_id=contract.module_id,
interface=requirement.name,
)
)
if requirement.version_min is not None and not requirement.version_min.strip():
issues.append(
CompatibilityIssue(
severity="blocker",
code="blank_required_interface_minimum",
message=f"{contract.module_id} has a blank minimum version for {requirement.name!r}.",
repo=contract.repo,
module_id=contract.module_id,
interface=requirement.name,
)
)
if requirement.version_max_exclusive is not None and not requirement.version_max_exclusive.strip():
issues.append(
CompatibilityIssue(
severity="blocker",
code="blank_required_interface_maximum",
message=f"{contract.module_id} has a blank maximum version for {requirement.name!r}.",
repo=contract.repo,
module_id=contract.module_id,
interface=requirement.name,
)
)
if not version_range_is_valid(
version_min=requirement.version_min,
version_max_exclusive=requirement.version_max_exclusive,
):
issues.append(
CompatibilityIssue(
severity="blocker",
code="invalid_required_interface_range",
message=(
f"{contract.module_id} requires {requirement.name!r} with invalid range "
f"{format_version_range(version_min=requirement.version_min, version_max_exclusive=requirement.version_max_exclusive)}."
),
repo=contract.repo,
module_id=contract.module_id,
interface=requirement.name,
)
)
for module_id, matches in sorted(module_ids.items()):
if len(matches) > 1:
repos = ", ".join(sorted(contract.repo for contract in matches))
for contract in matches:
issues.append(
CompatibilityIssue(
severity="blocker",
code="duplicate_module_id",
message=f"Module id {module_id!r} is declared by multiple repositories: {repos}.",
repo=contract.repo,
module_id=module_id,
)
)
for contract in contracts:
for requirement in contract.requires_interfaces:
available = providers.get(requirement.name, [])
matching = [
(provider_contract, provider)
for provider_contract, provider in available
if version_satisfies_range(
provider.version,
version_min=requirement.version_min,
version_max_exclusive=requirement.version_max_exclusive,
)
]
if matching:
continue
version_range = format_version_range(
version_min=requirement.version_min,
version_max_exclusive=requirement.version_max_exclusive,
)
if not available:
if requirement.optional:
continue
issues.append(
CompatibilityIssue(
severity="blocker",
code="required_interface_missing",
message=f"{contract.module_id} requires unavailable interface {requirement.name!r} ({version_range}).",
repo=contract.repo,
module_id=contract.module_id,
interface=requirement.name,
)
)
continue
available_versions = ", ".join(
f"{provider_contract.module_id}@{provider.version}" for provider_contract, provider in available
)
issues.append(
CompatibilityIssue(
severity="warning" if requirement.optional else "blocker",
code="interface_version_mismatch",
message=(
f"{contract.module_id} requires interface {requirement.name!r} ({version_range}) "
f"but available providers are {available_versions}."
),
repo=contract.repo,
module_id=contract.module_id,
interface=requirement.name,
)
)
return tuple(sorted(issues, key=issue_sort_key))
def interface_impact_map(
contracts: tuple[ModuleContractSnapshot, ...],
) -> dict[str, tuple[tuple[ModuleContractSnapshot, InterfaceRequirementSnapshot], ...]]:
consumers: dict[str, list[tuple[ModuleContractSnapshot, InterfaceRequirementSnapshot]]] = {}
for contract in contracts:
for requirement in contract.requires_interfaces:
consumers.setdefault(requirement.name, []).append((contract, requirement))
return {name: tuple(items) for name, items in sorted(consumers.items())}
def provided_interface_map(
contracts: tuple[ModuleContractSnapshot, ...],
) -> dict[str, tuple[tuple[ModuleContractSnapshot, InterfaceProviderSnapshot], ...]]:
providers: dict[str, list[tuple[ModuleContractSnapshot, InterfaceProviderSnapshot]]] = {}
for contract in contracts:
for provider in contract.provides_interfaces:
providers.setdefault(provider.name, []).append((contract, provider))
return {name: tuple(items) for name, items in sorted(providers.items())}
def parse_manifest_contract(path: Path, *, repo_name: str) -> ModuleContractSnapshot | None:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
constants = imported_string_constants(tree, path)
constants.update(module_constants(tree, initial=constants))
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if call_name(node.func) != "ModuleManifest":
continue
keywords = {keyword.arg: keyword.value for keyword in node.keywords if keyword.arg}
module_id = value_as_string(keywords.get("id"), constants)
if module_id is None:
continue
return ModuleContractSnapshot(
repo=repo_name,
module_id=module_id,
module_version=value_as_string(keywords.get("version"), constants),
manifest_path=str(path),
provides_interfaces=parse_providers(keywords.get("provides_interfaces"), constants),
requires_interfaces=parse_requirements(keywords.get("requires_interfaces"), constants),
)
return None
def parse_providers(node: ast.AST | None, constants: dict[str, str]) -> tuple[InterfaceProviderSnapshot, ...]:
providers: list[InterfaceProviderSnapshot] = []
for call in interface_calls(node, "ModuleInterfaceProvider"):
keywords = {keyword.arg: keyword.value for keyword in call.keywords if keyword.arg}
name = value_as_string(keywords.get("name"), constants)
version = value_as_string(keywords.get("version"), constants)
if name and version:
providers.append(InterfaceProviderSnapshot(name=name, version=version))
return tuple(providers)
def parse_requirements(node: ast.AST | None, constants: dict[str, str]) -> tuple[InterfaceRequirementSnapshot, ...]:
requirements: list[InterfaceRequirementSnapshot] = []
for call in interface_calls(node, "ModuleInterfaceRequirement"):
keywords = {keyword.arg: keyword.value for keyword in call.keywords if keyword.arg}
name = value_as_string(keywords.get("name"), constants)
if not name:
continue
requirements.append(
InterfaceRequirementSnapshot(
name=name,
version_min=value_as_string(keywords.get("version_min"), constants),
version_max_exclusive=value_as_string(keywords.get("version_max_exclusive"), constants),
optional=value_as_bool(keywords.get("optional")),
)
)
return tuple(requirements)
def interface_calls(node: ast.AST | None, expected_name: str) -> tuple[ast.Call, ...]:
if node is None:
return ()
return tuple(
child
for child in ast.walk(node)
if isinstance(child, ast.Call) and call_name(child.func) == expected_name
)
def imported_string_constants(tree: ast.Module, path: Path) -> dict[str, str]:
constants: dict[str, str] = {}
for node in tree.body:
if not isinstance(node, ast.ImportFrom):
continue
source = import_source_path(path, module=node.module, level=node.level)
if source is None or not source.exists():
continue
try:
imported = module_constants(ast.parse(source.read_text(encoding="utf-8"), filename=str(source)))
except SyntaxError:
continue
for alias in node.names:
if alias.name in imported:
constants[alias.asname or alias.name] = imported[alias.name]
return constants
def import_source_path(path: Path, *, module: str | None, level: int) -> Path | None:
if level:
base = path.parent
for _ in range(level - 1):
base = base.parent
if module:
base = base.joinpath(*module.split("."))
candidate = base.with_suffix(".py")
else:
src_root = next((parent for parent in path.parents if parent.name == "src"), None)
if src_root is None or module is None:
return None
candidate = src_root.joinpath(*module.split(".")).with_suffix(".py")
if candidate.exists():
return candidate
init = candidate.with_suffix("") / "__init__.py"
return init if init.exists() else candidate
def module_constants(tree: ast.Module, *, initial: dict[str, str] | None = None) -> dict[str, str]:
constants: dict[str, str] = dict(initial or {})
for node in tree.body:
if isinstance(node, ast.Assign):
value = node.value
for target in node.targets:
if isinstance(target, ast.Name):
resolved = value_as_string(value, constants)
if resolved is not None:
constants[target.id] = resolved
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
resolved = value_as_string(node.value, constants)
if resolved is not None:
constants[node.target.id] = resolved
return constants
def value_as_string(node: ast.AST | None, constants: dict[str, str]) -> str | None:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.Name):
return constants.get(node.id)
return None
def value_as_bool(node: ast.AST | None) -> bool:
return bool(node.value) if isinstance(node, ast.Constant) and isinstance(node.value, bool) else False
def call_name(node: ast.AST) -> str:
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
return node.attr
return ""
def issue_sort_key(issue: CompatibilityIssue) -> tuple[int, str, str, str, str]:
severity_order = {"blocker": 0, "warning": 1, "info": 2}
return (
severity_order.get(issue.severity, 9),
issue.repo or "",
issue.module_id or "",
issue.interface or "",
issue.code,
)
def valid_interface_name(value: str) -> bool:
return bool(re.match(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$", value))
def version_satisfies_range(
version: str,
*,
version_min: str | None = None,
version_max_exclusive: str | None = None,
) -> bool:
if version_min is not None and compare_versions(version, version_min) < 0:
return False
if version_max_exclusive is not None and compare_versions(version, version_max_exclusive) >= 0:
return False
return True
def version_range_is_valid(
*,
version_min: str | None = None,
version_max_exclusive: str | None = None,
) -> bool:
if version_min is None or version_max_exclusive is None:
return True
return compare_versions(version_min, version_max_exclusive) < 0
def format_version_range(
*,
version_min: str | None = None,
version_max_exclusive: str | None = None,
) -> str:
parts: list[str] = []
if version_min is not None:
parts.append(f">= {version_min}")
if version_max_exclusive is not None:
parts.append(f"< {version_max_exclusive}")
return ", ".join(parts) if parts else "any version"
def compare_versions(left: str, right: str) -> int:
left_parts = version_tuple(left)
right_parts = version_tuple(right)
max_length = max(len(left_parts), len(right_parts))
padded_left = left_parts + (0,) * (max_length - len(left_parts))
padded_right = right_parts + (0,) * (max_length - len(right_parts))
if padded_left < padded_right:
return -1
if padded_left > padded_right:
return 1
return 0
def version_tuple(value: str) -> tuple[int, ...]:
match = re.match(r"^\s*v?(\d+(?:\.\d+)*)", value)
if not match:
return (0,)
return tuple(int(part) for part in match.group(1).split("."))