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
263 lines
9.8 KiB
Python
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())
|