Files
rune/scripts/gen_prd_state.py
T

170 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""gen_prd_state.py — regenerate the living-state sections of the PRDs.
The PRDs' doctrine sections are hand-written; their "current state" sections
are GENERATED from governed sources (config/umbrellas.json, the pipeline/TUI
registries, the version surface) so they cannot drift from reality. Henxels
`changed_with` rules + the CI staleness check make regeneration mandatory in
the same PR as the change (docs/bugfix-contract.md discipline).
Usage:
gen_prd_state.py # rewrite generated sections in both PRDs
gen_prd_state.py --check # exit 1 if any generated section is stale (CI)
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BEGIN = "<!-- BEGIN GENERATED: state — edits here are overwritten by scripts/gen_prd_state.py -->"
END = "<!-- END GENERATED: state -->"
def version() -> str:
text = (ROOT / "src" / "rune" / "__init__.py").read_text()
match = re.search(r'__version__ = "([0-9]+\.[0-9]+\.[0-9]+)"', text)
return match.group(1) if match else "unknown"
def git_head() -> str:
try:
return subprocess.run(
["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, capture_output=True, text=True, check=True
).stdout.strip()
except subprocess.CalledProcessError:
return "unknown"
def pipeline_state() -> str:
umbrellas = json.loads((ROOT / "config" / "umbrellas.json").read_text())["umbrellas"]
lines = [
f"**Version**: v{version()} · **HEAD**: {git_head()} · **Regenerated**: {date.today().isoformat()}",
"",
"### Impact map (config/umbrellas.json — enforced complete by henxels)",
"",
"| Umbrella | Modules | Canaries | Depends on |",
"|---|---|---|---|",
]
for name, d in umbrellas.items():
lines.append(
f"| {name} | {len(d['modules'])} | {', '.join(d['canaries']) or '—'} | {', '.join(d.get('depends_on', [])) or '—'} |"
)
total = sum(len(d["modules"]) for d in umbrellas.values())
lines.append(f"| **total** | **{total} modules, 1:1 mapped** | | |")
lines += ["", "### Gates (merge-blocking, in enforcement order)", ""]
gates = [
("priority/labels", "auto-label.yml", "issues carry exactly one priority; research-pending on ready"),
(
"research evidence",
"pipeline-gate.yml research-clear",
"INWARD citations + OUTWARD live-fetched sources with per-source trail + synthesis qualification (sufficiency, feasibility, kill test) or honest MORE-RESEARCH",
),
(
"umbrella propagation",
"ci.yml unit-tests job → scripts/umbrella_impact.py",
"PR diff resolves against the impact map; unmapped surfaces fail; dependent umbrellas need files-in-PR or a Propagation statement",
),
(
"discharge contract",
"scripts/check_discharge_contract.py",
"bug-fix PRs claiming discharges carry red test hunks (docs/bugfix-contract.md)",
),
(
"dependency lock",
"scripts/check_dep_lock.py",
"requirements-ci.txt regenerates identically; floor-style lines fail",
),
("coverage ratchet", "ci.yml test job", "global floor only rises; changed lines ≥70% via diff-cover"),
("lint + type ratchet", "ci.yml lint/type-check", "ruff E9/F63/F7/F82; pyright counts never increase"),
("canaries", "ci.yml canary job", "every src/rune/**/canary_*.py registered and passing"),
("merge", "merge-robot.yml", "green+mergeable PRs merge deterministically; refusals fail the job loudly"),
(
"release",
"auto-version.yml + maintenance.yml",
"PR-title semver (authoritative) + push-triggered convergence safety net + drift reconciliation",
),
]
lines += [f"| {name} | `{where}` | {what} |" for name, where, what in gates]
lines += ["", "### henxels contract surface", ""]
checks = (ROOT / "henxels_checks.py").read_text()
lines += [f"- `{m}`" for m in re.findall(r'@statement\(\s*"(\w+)"', checks)]
return "\n".join(lines)
def tui_state() -> str:
registry = (ROOT / "docs" / "tui-registry.md").read_text()
rows = [ln for ln in registry.splitlines() if ln.startswith("| `")]
tui_lines = (ROOT / "src" / "rune" / "rune_tui.py").read_text().count("\n")
lines = [
f"**Version**: v{version()} · **HEAD**: {git_head()} · **Regenerated**: {date.today().isoformat()}",
"",
"### Governed TUI surfaces (docs/tui-registry.md)",
"",
]
lines += rows
lines += [
"",
f"`rune_tui.py` is {tui_lines} lines — the known giant, file-size debt tracked in henxels; decomposition target #125.",
"",
"### TUI-adjacent subsystems (impact map)",
"",
]
umbrellas = json.loads((ROOT / "config" / "umbrellas.json").read_text())["umbrellas"]
lines.append(
f"- orchestration umbrella: {len(umbrellas['orchestration']['modules'])} modules (operator, TUI presentation, task lifecycle)"
)
lines.append(
f"- perception umbrella: {len(umbrellas['perception']['modules'])} modules (context assembly, capsule)"
)
lines.append(f"- capability umbrella: {len(umbrellas['capability']['modules'])} modules (tool authority)")
return "\n".join(lines)
def regenerate(path: Path, new_state: str) -> bool:
text = path.read_text()
if BEGIN not in text or END not in text:
# insert the section after the first '---' separator (header block)
idx = text.index("\n---\n", text.index("---")) + len("\n---\n")
block = f"\n{BEGIN}\n{new_state}\n{END}\n"
path.write_text(text[:idx] + block + text[idx:])
return True
pattern = re.compile(re.escape(BEGIN) + r"[\s\S]*?" + re.escape(END))
updated = pattern.sub(BEGIN + "\n" + new_state + "\n" + END, text)
stamp = re.sub(r"(\*\*Last Updated\*\*: )\S+", rf"\g<1>{date.today().isoformat()}", updated)
if stamp != text:
path.write_text(stamp)
return True
return False
def main(argv: list[str]) -> int:
check = "--check" in argv
targets = [(ROOT / "docs" / "pipeline-prd.md", pipeline_state), (ROOT / "PRD.md", tui_state)]
stale = []
for path, gen in targets:
before = path.read_text() if path.exists() else ""
changed = regenerate(path, gen())
if check and changed:
stale.append(str(path.relative_to(ROOT)))
elif changed:
print(f"regenerated: {path.relative_to(ROOT)}")
if check:
if stale:
print("PRD state sections are STALE — run scripts/gen_prd_state.py and commit:")
for s in stale:
print(f" - {s}")
return 1
print("PRD state sections in sync")
return 0
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))