from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PLANS = ROOT / "docs" / "plans"
FM_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
PROGRESS_HEADING_RE = re.compile(r"^##\s+Progress\s*$", re.MULTILINE)
TABLE_ROW_RE = re.compile(r"^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$", re.MULTILINE)
DEFERRED_HEADING_RE = re.compile(
r"^#{1,6}\s+("
r"deferred"
r"|follow[\s-]?up(s)?"
r"|follow[\s-]?on"
r"|future\s+work"
r"|future\s+(extensions|considerations|improvements)"
r"|out\s+of\s+scope"
r"|next\s+steps"
r"|leftover"
r"|not\s+done"
r"|open\s+questions"
r")\s*$",
re.IGNORECASE | re.MULTILINE,
)
AREA_KEYWORDS: list[tuple[str, list[str]]] = [
("hdr", ["rendering", "hdr"]),
("ibl", ["rendering", "lighting"]),
("ldr", ["rendering", "hdr"]),
("lighting", ["rendering", "lighting"]),
("light-prob", ["rendering", "lighting"]),
("scene-lights", ["rendering", "lighting"]),
("shading", ["rendering", "shading"]),
("shadow", ["rendering", "lighting"]),
("reflect", ["rendering", "lighting"]),
("lightmap", ["rendering", "lighting"]),
("material", ["rendering", "materials"]),
("matcap", ["rendering", "materials"]),
("wireframe", ["rendering"]),
("morph", ["geometry", "deformation"]),
("skeletal", ["geometry", "deformation"]),
("skin", ["geometry", "deformation"]),
("particle", ["rendering", "particles"]),
("volum", ["rendering", "volumes"]),
("clip", ["rendering", "clipping"]),
("upload", ["renderer", "uploads"]),
("rendering-perform", ["rendering", "performance"]),
("pick", ["picking"]),
("camera", ["camera"]),
("input", ["input"]),
("selection", ["interaction"]),
("highlight", ["interaction"]),
("overlay", ["overlays"]),
("widget", ["overlays"]),
("gizmo", ["interaction"]),
("terrain", ["plugins", "terrain"]),
("physics", ["plugins", "physics"]),
("compute", ["plugins"]),
("plugin", ["plugins"]),
("plugins", ["plugins"]),
("item-type", ["api", "items"]),
("api-", ["api"]),
("camera-api", ["api", "camera"]),
("input-api", ["api", "input"]),
("gap-", ["consumers"]),
("game-", ["consumers", "games"]),
("downstream", ["consumers"]),
("viewport-quick", ["consumers", "rapid-proto"]),
("viewport-runtime", ["runtime"]),
("viewport-physics", ["plugins", "physics"]),
("viewport-lib-drake", ["consumers", "drake"]),
("residual-y-up", ["coordinates"]),
("point-gaussian", ["rendering", "splats"]),
("volumetric-effects", ["rendering", "volumes"]),
("roadmap", ["meta"]),
("guide", ["meta"]),
]
DEFAULT_AREA = ["uncategorized"]
@dataclass
class ProgressRow:
phase: str
description: str
progress: str
effort: str
priority: str
@dataclass
class PlanState:
path: Path
has_frontmatter: bool
progress_rows: list[ProgressRow] = field(default_factory=list)
has_progress_table: bool = False
deferred_headings: list[str] = field(default_factory=list)
def parse_progress_table(text: str) -> tuple[bool, list[ProgressRow]]:
m = PROGRESS_HEADING_RE.search(text)
if not m:
return False, []
tail = text[m.end():]
next_h2 = re.search(r"^##\s+\S", tail, re.MULTILINE)
if next_h2:
tail = tail[:next_h2.start()]
rows: list[ProgressRow] = []
for rm in TABLE_ROW_RE.finditer(tail):
c1, c2, c3, c4, c5 = (rm.group(i).strip() for i in range(1, 6))
if c1.lower() == "phase" or set(c1.replace(" ", "")) <= {"-", ":"}:
continue
rows.append(ProgressRow(c1, c2, c3, c4, c5))
return True, rows
def find_deferred_headings(text: str) -> list[str]:
return [m.group(0).strip() for m in DEFERRED_HEADING_RE.finditer(text)]
def all_rows_complete(rows: list[ProgressRow]) -> bool:
if not rows:
return False
for r in rows:
p = r.progress.lower().strip()
if p in {"100%", "done", "complete", "completed", "shipped"}:
continue
return False
return True
def infer_area(stem: str) -> list[str]:
lower = stem.lower()
for kw, tags in AREA_KEYWORDS:
if kw in lower:
return tags
return DEFAULT_AREA
ACRONYMS = {
"api", "ibl", "hdr", "ldr", "gpu", "cpu", "ui", "ux", "occt", "ais",
"vtk", "bvh", "aabb", "uat", "lod", "mc", "ik", "ar", "vr",
}
def infer_title(stem: str) -> str:
base = stem
if base.endswith("-plan"):
base = base[:-5]
words = []
for w in base.split("-"):
if w.lower() in ACRONYMS:
words.append(w.upper())
else:
words.append(w.capitalize())
return " ".join(words)
def infer_id(stem: str) -> str:
return stem[:-5] if stem.endswith("-plan") else stem
def analyze(path: Path) -> PlanState:
text = path.read_text(encoding="utf-8")
has_fm = bool(FM_RE.match(text))
has_table, rows = parse_progress_table(text)
deferred = find_deferred_headings(text)
return PlanState(path=path, has_frontmatter=has_fm, progress_rows=rows, has_progress_table=has_table, deferred_headings=deferred)
def propose_frontmatter(state: PlanState) -> str:
stem = state.path.stem
title = infer_title(stem)
plan_id = infer_id(stem)
area = infer_area(stem)
st = state.path.stat()
birth = getattr(st, "st_birthtime", st.st_mtime)
created = datetime.fromtimestamp(birth).date().isoformat()
updated = datetime.fromtimestamp(st.st_mtime).date().isoformat()
if not state.has_progress_table:
status = "draft"
elif all_rows_complete(state.progress_rows):
status = "done"
else:
status = "active"
if state.deferred_headings:
deferred = "unresolved"
else:
deferred = "none"
lines = [
"---",
f"id: {plan_id}",
f"title: {title}",
f"status: {status}",
"priority: ",
f"area: [{', '.join(area)}]",
"source_issue: ",
f"created: {created}",
f"updated: {updated}",
f"deferred: {deferred}",
"supersedes: ",
"superseded_by: ",
'note: ""',
"---",
"",
]
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(description="Bootstrap docs/plans/ frontmatter + audit deferred sections.")
ap.add_argument("--apply", action="store_true", help="prepend proposed frontmatter to plans without one")
ap.add_argument("--audit-only", action="store_true", help="skip proposals; just print the deferred-section audit")
args = ap.parse_args()
if not PLANS.exists():
print("docs/plans/ not found.")
return 0
states = [analyze(p) for p in sorted(PLANS.glob("*.md")) if p.name not in {"INDEX.md", "README.md"}]
if (PLANS / "archive").exists():
states += [analyze(p) for p in sorted((PLANS / "archive").glob("*.md")) if p.name not in {"ARCHIVE.md"}]
missing_table = [s for s in states if not s.has_progress_table]
has_deferred = [s for s in states if s.deferred_headings]
needs_fm = [s for s in states if not s.has_frontmatter]
fully_complete = [s for s in states if s.has_progress_table and all_rows_complete(s.progress_rows)]
if not args.audit_only:
print(f"=== plans_bootstrap.py: {'APPLY' if args.apply else 'DRY-RUN'} ===")
print(f"Scanned {len(states)} plans.")
print(f" needs frontmatter: {len(needs_fm)}")
print(f" missing Progress table: {len(missing_table)}")
print(f" has deferred / follow-up section: {len(has_deferred)}")
print(f" fully complete by Progress table: {len(fully_complete)}")
print()
if needs_fm and not args.apply:
print("--- example proposed frontmatter (first plan needing one) ---")
s = needs_fm[0]
print(f"# {s.path.relative_to(ROOT)}")
print(propose_frontmatter(s))
if args.apply:
written = 0
for s in needs_fm:
text = s.path.read_text(encoding="utf-8")
fm = propose_frontmatter(s)
s.path.write_text(fm + text, encoding="utf-8")
written += 1
print(f"frontmatter added: {s.path.relative_to(ROOT)}")
print(f"\nApplied frontmatter to {written} plans.")
print()
print("=== Deferred-section audit ===")
if not has_deferred:
print("No plans matched the deferred/follow-up heading patterns.")
else:
print("These plans have body sections suggesting un-spun-off follow-up work.")
print("Decide for each: (a) spin off into a new <name>-followups-plan.md and set")
print(" `deferred: docs/plans/<that>.md`, or (b) keep `deferred: unresolved`, or")
print(" (c) mark `deferred: none` if the section is informational only.\n")
for s in has_deferred:
print(f"- {s.path.relative_to(ROOT)}")
for h in s.deferred_headings:
print(f" {h}")
if missing_table:
print()
print("=== Plans missing `## Progress` table ===")
for s in missing_table:
print(f"- {s.path.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
sys.exit(main())