#!/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("") 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", "", "", "", "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())