Init
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user