#!/usr/bin/env python3 """ Generate spec/index.md from spec/tasks/*.md Assumptions: - Each task file starts with YAML-like front matter: --- id: 001 title: Add endpoint /reports/addons status: TODO # TODO | DONE created: 2026-02-01 --- Notes: - status lives inside each task file (as requested). - index.md is fully overwritten and marked as generated. - No external deps (no PyYAML). Supports simple "key: value" scalars. Usage: python3 spec/gen_spec_index.py python3 spec/gen_spec_index.py --check # verify index is up-to-date (CI) """ from __future__ import annotations import argparse import datetime as dt import re import sys from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional, Tuple ROOT = Path(__file__).resolve().parents[1] SPEC_DIR = ROOT / "spec" TASKS_DIR = SPEC_DIR / "tasks" INDEX_PATH = SPEC_DIR / "index.md" ALLOWED_STATUS = {"TODO", "DONE"} @dataclass(frozen=True) class TaskMeta: file: Path id: str title: str status: str created: str # YYYY-MM-DD FRONT_MATTER_RE = re.compile(r"^---\s*$") def read_front_matter(md_path: Path) -> Tuple[Dict[str, str], int]: """ Returns (meta_dict, end_line_index_exclusive). If no front matter, returns ({}, 0). """ text = md_path.read_text(encoding="utf-8") lines = text.splitlines() if not lines or not FRONT_MATTER_RE.match(lines[0]): return {}, 0 meta: Dict[str, str] = {} i = 1 while i < len(lines): if FRONT_MATTER_RE.match(lines[i]): return meta, i + 1 line = lines[i].strip() i += 1 if not line or line.startswith("#"): continue # Very small YAML subset: key: value if ":" not in line: continue key, val = line.split(":", 1) key = key.strip() val = val.strip() # Strip optional quotes if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")): val = val[1:-1] meta[key] = val # Started front matter but never closed raise ValueError(f"{md_path}: front matter starts with '---' but missing closing '---'") def normalize_id(md_path: Path, meta: Dict[str, str]) -> str: # Prefer explicit id, else derive from filename prefix like "001-something.md" if "id" in meta and meta["id"].strip(): return meta["id"].strip() m = re.match(r"^(\d+)", md_path.stem) if m: return m.group(1) raise ValueError(f"{md_path}: missing 'id' in front matter and filename doesn't start with digits") def normalize_title(md_path: Path, meta: Dict[str, str], content_start_line: int) -> str: if "title" in meta and meta["title"].strip(): return meta["title"].strip() # Fallback: first markdown heading after front matter: "# Title" text = md_path.read_text(encoding="utf-8") lines = text.splitlines() for line in lines[content_start_line:]: line = line.strip() if line.startswith("# "): return line[2:].strip() raise ValueError(f"{md_path}: missing 'title' in front matter and no '# ' heading found") def normalize_status(md_path: Path, meta: Dict[str, str]) -> str: status = meta.get("status", "").strip().upper() if status not in ALLOWED_STATUS: raise ValueError(f"{md_path}: invalid or missing 'status' (must be one of: {sorted(ALLOWED_STATUS)})") return status def normalize_created(md_path: Path, meta: Dict[str, str]) -> str: created = meta.get("created", "").strip() if created: # Validate YYYY-MM-DD try: dt.date.fromisoformat(created) except ValueError: raise ValueError(f"{md_path}: invalid 'created' date '{created}' (expected YYYY-MM-DD)") return created # Fallback: file mtime (not perfect, but better than failing) mtime = dt.datetime.fromtimestamp(md_path.stat().st_mtime, tz=dt.timezone.utc).date().isoformat() return mtime def load_tasks() -> List[TaskMeta]: if not TASKS_DIR.exists(): raise FileNotFoundError(f"Tasks directory not found: {TASKS_DIR}") md_files = sorted(TASKS_DIR.glob("*.md")) tasks: List[TaskMeta] = [] seen_ids: Dict[str, Path] = {} for f in md_files: meta, content_start = read_front_matter(f) task_id = normalize_id(f, meta) if task_id in seen_ids: raise ValueError(f"Duplicate task id '{task_id}': {seen_ids[task_id]} and {f}") seen_ids[task_id] = f title = normalize_title(f, meta, content_start) status = normalize_status(f, meta) created = normalize_created(f, meta) tasks.append(TaskMeta(file=f, id=task_id, title=title, status=status, created=created)) # Sort by numeric id if possible def sort_key(t: TaskMeta): try: return (0, int(t.id)) except ValueError: return (1, t.id) return sorted(tasks, key=sort_key) def render_index(tasks: List[TaskMeta]) -> str: generated_at = dt.datetime.now(tz=dt.timezone.utc).replace(microsecond=0).isoformat() lines: List[str] = [] lines.append("# Spec Tasks Index") lines.append("") lines.append("> ⚠️ This file is generated. Do not edit manually.") lines.append(f"> Generated at (UTC): `{generated_at}`") lines.append("") lines.append("## Tasks") lines.append("") lines.append("| ID | Status | Created | Title | File |") lines.append("|---:|:------:|:-------:|:------|:-----|") for t in tasks: rel = t.file.relative_to(ROOT).as_posix() title = t.title.replace("|", "\\|") lines.append(f"| {t.id} | {t.status} | {t.created} | {title} | `{rel}` |") lines.append("") lines.append("## Summary") lines.append("") todo = sum(1 for t in tasks if t.status == "TODO") done = sum(1 for t in tasks if t.status == "DONE") lines.append(f"- Total: **{len(tasks)}**") lines.append(f"- TODO: **{todo}**") lines.append(f"- DONE: **{done}**") lines.append("") return "\n".join(lines) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--check", action="store_true", help="fail if spec/index.md is not up-to-date") args = parser.parse_args() tasks = load_tasks() content = render_index(tasks) if args.check: existing = INDEX_PATH.read_text(encoding="utf-8") if INDEX_PATH.exists() else "" if existing != content: print("spec/index.md is not up-to-date. Run: python3 spec/gen_spec_index.py", file=sys.stderr) return 2 return 0 SPEC_DIR.mkdir(parents=True, exist_ok=True) INDEX_PATH.write_text(content, encoding="utf-8") print(f"Wrote {INDEX_PATH.relative_to(ROOT)} ({len(tasks)} tasks)") return 0 if __name__ == "__main__": raise SystemExit(main())