Files
management/scripts/gen_status.py
T
Thore CimbalandClaude Opus 5 0a9543703d feat: harvest states for recurring patterns, end to end
Slice 1, the tracer bullet: schema, generator and one migrated page, so the
whole chain runs before eleven more depend on it.

Two fields were missing entirely. The v0.3.1 merge did not carry over
wiki-page's status and harvested_in, so the mechanism ADR-0009 decides was
not actually available here. A field-by-field comparison against the
vendored baseline found exactly those two and nothing else - the gap the
previous run predicted when it noted that reconciling the extended files is
a manual step with nothing to contradict it.

The generator gains a clustered section and writes the signpost; collect()
and apply_rules() already existed, so the change is a filter and a fifth
rule rather than a second reader or a new checker.

Running it corrected one of my own design errors immediately. The "without
state" group was written as a warning, and it flagged two perfectly correct
pages: status is optional per ADR-0009 and only meaningful on a page that
tracks a pattern. A permanent complaint with no subject trains people to
ignore the section, so the group is now neutral - with the downside written
into the code, since a pattern that lost its state now looks like an
ordinary page.

Eight controls, seven of them deliberate breaks: both generated files go
stale on a hand edit, an invented state value is refused, the new value is
accepted and clusters correctly, and harvested or declined without a named
version now fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2Q4Ri8NGwyTZzScvKnWFM
2026-08-21 12:00:00 +00:00

263 lines
9.8 KiB
Python

#!/usr/bin/env python3
"""gen_status.py — generate STATUS.md deterministically from frontmatter.
PROJEKTERWEITERUNG gegenüber neckbeard v0.1.1 (Original unter
docs/sources/upstream/neckbeard-v0.1.1/scripts/): offene Issues sind
alles außer done/rejected (Status-Enum ist projektweit erweitert);
Tabelle zeigt Meilenstein/Priorität; Verteilungszeile je Meilenstein
ersetzt die früheren Hand-Zählungen der roadmap.md (F-001/F-010).
Writes STATUS.md (no timestamps — output depends only on repo content, so
reruns are diff-clean). With --check, regenerates in memory and fails if
the committed STATUS.md is stale; CI uses this mode.
Usage:
python scripts/gen_status.py [repo-root] # write STATUS.md
python scripts/gen_status.py --check [repo-root] # verify, exit 1 if stale
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
try:
import yaml
except ImportError: # pragma: no cover
sys.exit("gen_status.py needs PyYAML: pip install pyyaml")
H1_RE = re.compile(r"^#\s+(.*)$", re.M)
def parse(path: Path):
lines = path.read_text(encoding="utf-8").splitlines()
if not lines or lines[0].strip() != "---":
return None, ""
for j in range(1, len(lines)):
if lines[j].strip() == "---":
meta = yaml.safe_load("\n".join(lines[1:j])) or {}
body = "\n".join(lines[j + 1:])
return meta, body
return None, ""
def title(body: str, fallback: str) -> str:
match = H1_RE.search(body)
return match.group(1).strip() if match else fallback
def collect(root: Path, subdir: str, wanted_type: str):
items = []
base = root / subdir
if not base.is_dir():
return items
for path in sorted(base.rglob("*.md")):
if path.name == "template.md":
continue
meta, body = parse(path)
if not isinstance(meta, dict) or meta.get("type") != wanted_type:
continue
rel = path.relative_to(root).as_posix()
items.append((rel, meta, title(body, path.stem)))
return items
def render(root: Path) -> str:
issues = collect(root, "docs/issues", "issue")
designs = collect(root, "docs/design", "design")
adrs = collect(root, "docs/adr", "adr")
aars = collect(root, "docs/aar", "aar")
out: list[str] = []
out.append("# STATUS")
out.append("")
out.append("<!-- Generated by scripts/gen_status.py — do not edit. -->")
out.append("")
open_issues = [i for i in issues
if i[1].get("status") not in ("done", "rejected")]
closed = len(issues) - len(open_issues)
out.append(f"## Issues ({len(open_issues)} open, {closed} closed)")
out.append("")
if open_issues:
dist: dict[str, int] = {}
for _rel, meta, _name in open_issues:
m = str(meta.get("milestone", "?"))
dist[m] = dist.get(m, 0) + 1
out.append("Verteilung: " + " · ".join(
f"{m} {n}" for m, n in sorted(dist.items())))
out.append("")
out.append("Bedeutung der Meilensteine: siehe [roadmap.md](roadmap.md).")
out.append("")
# Nach Meilenstein gruppiert, innerhalb dessen nach Priorität: die Sicht, in
# der man einen Meilenstein abarbeitet. Eine flache Liste ueber alle 57 offenen
# Issues beantwortet die Frage "was gehoert zu M1" nur durch Suchen.
# Bewusst ersetzend statt ergaenzend - derselbe Bestand zweimal untereinander
# waere genau die Doppelpflege, die diese Datei vermeiden soll.
rang = {"high": 0, "medium": 1, "low": 2}
for m in sorted({str(i[1].get("milestone", "?")) for i in open_issues}):
gruppe = [i for i in open_issues
if str(i[1].get("milestone", "?")) == m]
gruppe.sort(key=lambda i: (rang.get(str(i[1].get("priority")), 9),
str(i[1].get("id", ""))))
out.append(f"### {m} ({len(gruppe)})")
out.append("")
out.append("| Issue | Priorität | Status | Title |")
out.append("|---|---|---|---|")
for rel, meta, name in gruppe:
out.append(f"| [{meta.get('id', '?')}]({rel}) "
f"| {meta.get('priority')} | {meta.get('status')} "
f"| {name} |")
out.append("")
else:
out.append("_none open_")
out.append("")
active = [d for d in designs if d[1].get("status") != "done"]
out.append(f"## Active design docs ({len(active)})")
out.append("")
if active:
out.append("| Design | Gate | Title |")
out.append("|---|---|---|")
for rel, meta, name in active:
out.append(f"| [{Path(rel).stem}]({rel}) "
f"| {meta.get('status')} | {name} |")
else:
out.append("_none active_")
out.append("")
out.append(f"## ADRs ({len(adrs)})")
out.append("")
if adrs:
out.append("| ADR | Status | Title |")
out.append("|---|---|---|")
for rel, meta, name in adrs:
out.append(f"| [{meta.get('id', '?')}]({rel}) "
f"| {meta.get('status')} | {name} |")
else:
out.append("_none_")
out.append("")
open_aars = [a for a in aars if a[1].get("status") == "open"]
out.append(f"## Open AARs ({len(open_aars)})")
out.append("")
if open_aars:
for rel, _meta, name in open_aars:
out.append(f"- [{name}]({rel})")
else:
out.append("_none — nothing awaiting harvest_")
out.append("")
out += render_fehlerklassen(stolpersteine(root))
return "\n".join(out)
STAENDE = [
("open", "offen"),
("partly", "teilweise gedeckt"),
("harvested", "geerntet"),
("declined", "vom Rahmenwerk nicht abgedeckt — bleibt unseres"),
]
def stolpersteine(root: Path):
"""Wiederkehrende Fehlerklassen: Wiki-Seiten mit area == stolpersteine.
Bewusst ueber collect(), nicht ueber einen eigenen Leser: die Frage
"welche Artefakte gibt es und was steht in ihrem Frontmatter" ist dort
schon beantwortet.
"""
return [i for i in collect(root, "docs/wiki", "wiki-page")
if i[1].get("area") == "stolpersteine"]
def render_fehlerklassen(items) -> list[str]:
"""Nach Stand geclustert. Ohne Stand ist ein Befund, kein stiller Ausfall."""
out = [f"## Fehlerklassen ({len(items)})", ""]
if not items:
out += ["_keine erfasst_", ""]
return out
for wert, bezeichnung in STAENDE:
gruppe = [i for i in items if i[1].get("status") == wert]
if not gruppe:
continue
out.append(f"### {bezeichnung} ({len(gruppe)})")
out.append("")
for rel, meta, name in gruppe:
seit = meta.get("harvested_in")
out.append(f"- [{name}]({rel})" + (f" — {seit}" if seit else ""))
out.append("")
bekannt = {w for w, _ in STAENDE}
ohne = [i for i in items if i[1].get("status") not in bekannt]
if ohne:
# Kein Warnblock: `status` ist laut ADR-0009 optional und nur auf
# Seiten sinnvoll, die ein wiederkehrendes Muster fuehren. Eine
# gewoehnliche Stolperstein-Seite ohne Stand ist korrekt — sie hier
# anzumahnen waere ein Dauerbefund ohne Gegenstand.
# ⚠️ Kehrseite: ein Muster, dem der Stand fehlt, faellt in dieselbe
# Gruppe und ist damit nicht von einer gewoehnlichen Seite zu
# unterscheiden. Als Befund notiert, nicht hier geflickt.
out.append(f"### ohne Ernte-Stand ({len(ohne)})")
out.append("")
for rel, _meta, name in ohne:
out.append(f"- [{name}]({rel})")
out.append("")
return out
def render_wegweiser(items) -> str:
"""FRAMEWORK-BEFUNDE.md als Wegweiser ohne eigenen Bestand.
Die Datei kann nicht entfallen: ADR-0024 ist angenommen und verweist
zweimal auf sie. Sie traegt deshalb nichts, was veralten koennte.
"""
zahl = {w: len([i for i in items if i[1].get("status") == w])
for w, _ in STAENDE}
return "\n".join([
"# Framework-Befunde",
"",
"<!-- Erzeugt von scripts/gen_status.py — nicht von Hand aendern. -->",
"",
"Wiederkehrende Fehlerklassen und Abweichungen vom Verfahren leben",
"als Wiki-Seiten unter [docs/wiki/stolpersteine/](docs/wiki/index.md)",
"— eine Seite je Muster, mit Stand und, wo zutreffend, der Version,",
"die es abdeckt (ADR-0009 des Rahmenwerks).",
"",
f"**Die geclusterte Übersicht steht in [STATUS.md](STATUS.md)**, "
f"Abschnitt *Fehlerklassen*: {zahl['open']} offen, "
f"{zahl['partly']} teilweise, {zahl['harvested']} geerntet, "
f"{zahl['declined']} nicht abgedeckt.",
"",
"Diese Datei hält keinen eigenen Bestand mehr. Sie bleibt als",
"Wegweiser bestehen, weil angenommene Entscheidungen auf sie",
"verweisen und nicht nachträglich editiert werden.",
"",
])
def main() -> int:
args = [a for a in sys.argv[1:] if a != "--check"]
check = "--check" in sys.argv[1:]
root = Path(args[0]) if args else Path.cwd()
erzeugnisse = [
(root / "STATUS.md", render(root)),
(root / "FRAMEWORK-BEFUNDE.md", render_wegweiser(stolpersteine(root))),
]
if check:
veraltet = [p for p, inhalt in erzeugnisse
if (p.read_text(encoding="utf-8") if p.is_file() else "") != inhalt]
if veraltet:
for p in veraltet:
print(f"gen_status --check: {p.name} is stale — "
"run scripts/gen_status.py and commit the result")
return 1
print("gen_status --check: STATUS.md is current")
return 0
for p, inhalt in erzeugnisse:
p.write_text(inhalt, encoding="utf-8", newline="\n")
print(f"wrote {p}")
return 0
if __name__ == "__main__":
sys.exit(main())