233 lines
6.7 KiB
Python
233 lines
6.7 KiB
Python
#!/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())
|