magicciv/tools/objectives-report.py
Natalie e6a2a067aa feat(@projects/@magic-civilization): add objectives dashboard tracking
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-17 00:19:20 -07:00

192 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Regenerate .project/objectives/README.md from per-objective frontmatter.
SSoT: each .project/objectives/p[012]-NN-*.md file carries its own status in
YAML frontmatter. This script reads every such file, validates the fields, and
emits a grouped dashboard. The dashboard is regenerated, not hand-edited.
Usage:
python3 tools/objectives-report.py # write README.md
python3 tools/objectives-report.py --check # exit 1 if README.md is stale
"""
from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
OBJ_DIR = REPO / ".project" / "objectives"
OUT = OBJ_DIR / "README.md"
VALID_STATUS = {"done", "partial", "stub", "missing", "oos"}
VALID_PRIORITY = {"p0", "p1", "p2"}
VALID_SCOPE = {"game1", "game2"}
STATUS_ICON = {
"done": "",
"partial": "🟡",
"stub": "🔴",
"missing": "",
"oos": "",
}
@dataclass(frozen=True)
class Objective:
id: str
title: str
priority: str
status: str
scope: str
updated_at: str
path: Path
def parse_frontmatter(text: str, path: Path) -> dict[str, str]:
"""Parse a minimal YAML frontmatter block (flat key: value pairs).
We deliberately avoid a PyYAML dependency — frontmatter here is flat
`key: value` plus occasional list fields we don't need to index.
"""
if not text.startswith("---\n"):
raise ValueError(f"{path}: missing frontmatter opener '---'")
end = text.find("\n---\n", 4)
if end == -1:
raise ValueError(f"{path}: missing frontmatter closer '---'")
body = text[4:end]
out: dict[str, str] = {}
current_key: str | None = None
for raw in body.splitlines():
line = raw.rstrip()
if not line or line.lstrip().startswith("#"):
continue
# List-item continuation (we ignore list values for now)
if line.startswith((" ", "\t", "-")):
current_key = None
continue
if ":" not in line:
raise ValueError(f"{path}: malformed frontmatter line: {raw!r}")
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
if value:
out[key] = value
current_key = key
return out
REQUIRED_FIELDS = ("id", "title", "priority", "status", "scope", "updated_at")
def load_objectives() -> list[Objective]:
out: list[Objective] = []
for path in sorted(OBJ_DIR.glob("p[012]-*.md")):
if path.name == "README.md":
continue
text = path.read_text(encoding="utf-8")
fm = parse_frontmatter(text, path)
missing = [k for k in REQUIRED_FIELDS if k not in fm]
if missing:
raise ValueError(f"{path}: missing frontmatter keys: {missing}")
if fm["priority"] not in VALID_PRIORITY:
raise ValueError(f"{path}: invalid priority {fm['priority']!r}")
if fm["status"] not in VALID_STATUS:
raise ValueError(f"{path}: invalid status {fm['status']!r}")
if fm["scope"] not in VALID_SCOPE:
raise ValueError(f"{path}: invalid scope {fm['scope']!r}")
out.append(Objective(
id=fm["id"],
title=fm["title"],
priority=fm["priority"],
status=fm["status"],
scope=fm["scope"],
updated_at=fm["updated_at"],
path=path,
))
out.sort(key=lambda o: (o.priority, o.id))
return out
def render(objectives: list[Objective]) -> str:
by_priority: dict[str, list[Objective]] = {"p0": [], "p1": [], "p2": []}
for o in objectives:
by_priority[o.priority].append(o)
counts = {s: sum(1 for o in objectives if o.status == s) for s in VALID_STATUS}
total = len(objectives)
lines: list[str] = []
lines.append("# Objectives — Dashboard")
lines.append("")
lines.append(
"> **Generated by `tools/objectives-report.py` — do not hand-edit.** "
"Source of truth is per-file YAML frontmatter in this directory."
)
lines.append("")
lines.append("## Legend")
lines.append("")
lines.append("✅ done · 🟡 partial · 🔴 stub · ❌ missing · ⚫ out-of-scope (Game 2)")
lines.append("")
lines.append("## Totals")
lines.append("")
lines.append("| Status | Count |")
lines.append("|---|---|")
for status in ("done", "partial", "stub", "missing", "oos"):
lines.append(f"| {STATUS_ICON[status]} {status} | {counts[status]} |")
lines.append(f"| **total** | **{total}** |")
lines.append("")
priority_heading = {
"p0": "P0 — Blockers for \"completely playable\"",
"p1": "P1 — Ship-readiness",
"p2": "P2 — Polish",
}
for prio in ("p0", "p1", "p2"):
group = by_priority[prio]
if not group:
continue
lines.append(f"## {priority_heading[prio]}")
lines.append("")
lines.append("| ID | Status | Title | Updated |")
lines.append("|---|---|---|---|")
for o in group:
link = f"[{o.id}]({o.path.name})"
icon = STATUS_ICON[o.status]
lines.append(f"| {link} | {icon} {o.status} | {o.title} | {o.updated_at} |")
lines.append("")
return "\n".join(lines) + "\n"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"--check",
action="store_true",
help="Exit 1 if README.md is stale instead of writing",
)
args = ap.parse_args()
try:
objectives = load_objectives()
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 2
rendered = render(objectives)
if args.check:
existing = OUT.read_text(encoding="utf-8") if OUT.exists() else ""
if existing != rendered:
print(f"stale: {OUT.relative_to(REPO)} — run tools/objectives-report.py", file=sys.stderr)
return 1
return 0
OUT.write_text(rendered, encoding="utf-8")
print(f"wrote {OUT.relative_to(REPO)} ({len(objectives)} objectives)")
return 0
if __name__ == "__main__":
raise SystemExit(main())