157 lines
4.3 KiB
Python
157 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate persistent governance assets for agent workflow safety."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REQUIREMENTS_REGISTRY = "docs/ouroboros/01_requirements_registry.md"
|
|
|
|
|
|
def must_contain(path: Path, required: list[str], errors: list[str]) -> None:
|
|
if not path.exists():
|
|
errors.append(f"missing file: {path}")
|
|
return
|
|
text = path.read_text(encoding="utf-8")
|
|
for token in required:
|
|
if token not in text:
|
|
errors.append(f"{path}: missing required token -> {token}")
|
|
|
|
|
|
def normalize_changed_path(path: str) -> str:
|
|
normalized = path.strip().replace("\\", "/")
|
|
if normalized.startswith("./"):
|
|
normalized = normalized[2:]
|
|
return normalized
|
|
|
|
|
|
def is_policy_file(path: str) -> bool:
|
|
normalized = normalize_changed_path(path)
|
|
if not normalized.endswith(".md"):
|
|
return False
|
|
if not normalized.startswith("docs/ouroboros/"):
|
|
return False
|
|
return normalized != REQUIREMENTS_REGISTRY
|
|
|
|
|
|
def load_changed_files(args: list[str], errors: list[str]) -> list[str]:
|
|
if not args:
|
|
return []
|
|
|
|
# Single range input (e.g. BASE..HEAD or BASE...HEAD)
|
|
if len(args) == 1 and ".." in args[0]:
|
|
range_spec = args[0]
|
|
try:
|
|
completed = subprocess.run(
|
|
["git", "diff", "--name-only", range_spec],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
|
errors.append(f"failed to load changed files from range '{range_spec}': {exc}")
|
|
return []
|
|
return [
|
|
normalize_changed_path(line)
|
|
for line in completed.stdout.splitlines()
|
|
if line.strip()
|
|
]
|
|
|
|
return [normalize_changed_path(path) for path in args if path.strip()]
|
|
|
|
|
|
def validate_registry_sync(changed_files: list[str], errors: list[str]) -> None:
|
|
if not changed_files:
|
|
return
|
|
|
|
changed_set = set(changed_files)
|
|
policy_changed = any(is_policy_file(path) for path in changed_set)
|
|
registry_changed = REQUIREMENTS_REGISTRY in changed_set
|
|
if policy_changed and not registry_changed:
|
|
errors.append(
|
|
"policy file changed without updating docs/ouroboros/01_requirements_registry.md"
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
errors: list[str] = []
|
|
changed_files = load_changed_files(sys.argv[1:], errors)
|
|
|
|
pr_template = Path(".gitea/PULL_REQUEST_TEMPLATE.md")
|
|
issue_template = Path(".gitea/ISSUE_TEMPLATE/runtime_verification.md")
|
|
workflow_doc = Path("docs/workflow.md")
|
|
commands_doc = Path("docs/commands.md")
|
|
handover_script = Path("scripts/session_handover_check.py")
|
|
handover_log = Path("workflow/session-handover.md")
|
|
|
|
must_contain(
|
|
pr_template,
|
|
[
|
|
"Closes #N",
|
|
"Main -> Verifier Directive Contract",
|
|
"Coverage Matrix",
|
|
"NOT_OBSERVED",
|
|
"tea",
|
|
"gh",
|
|
"Session Handover Gate",
|
|
"session_handover_check.py --strict",
|
|
],
|
|
errors,
|
|
)
|
|
must_contain(
|
|
issue_template,
|
|
[
|
|
"[RUNTIME-VERIFY][SCN-XXX]",
|
|
"Requirement Mapping",
|
|
"Close Criteria",
|
|
"NOT_OBSERVED = 0",
|
|
],
|
|
errors,
|
|
)
|
|
must_contain(
|
|
workflow_doc,
|
|
[
|
|
"Session Handover Gate (Mandatory)",
|
|
"session_handover_check.py --strict",
|
|
],
|
|
errors,
|
|
)
|
|
must_contain(
|
|
commands_doc,
|
|
[
|
|
"Session Handover Preflight (Mandatory)",
|
|
"session_handover_check.py --strict",
|
|
],
|
|
errors,
|
|
)
|
|
must_contain(
|
|
handover_log,
|
|
[
|
|
"Session Handover Log",
|
|
"- branch:",
|
|
"- docs_checked:",
|
|
"- open_issues_reviewed:",
|
|
"- next_ticket:",
|
|
],
|
|
errors,
|
|
)
|
|
if not handover_script.exists():
|
|
errors.append(f"missing file: {handover_script}")
|
|
|
|
validate_registry_sync(changed_files, errors)
|
|
|
|
if errors:
|
|
print("[FAIL] governance asset validation failed")
|
|
for err in errors:
|
|
print(f"- {err}")
|
|
return 1
|
|
|
|
print("[OK] governance assets validated")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|