Ports settled-reach's decisions_sync.py + ticket + decision scripts, Scrum-stripped. Writes to .pql/pql.db (gitignored). Verb shape mirrors the eventual `pql` subcommands so migration is a call-site find-replace once pql ships feature parity (D-040, R-011). Ticket IDs are T-NNN (TEXT PKs) — reshape from settled-reach's integer auto-increment so the stopgap's writes are compatible with pql's future reads without a data migration. No `sprints` table; the pyramid is kanban / waterfall (D-035). Verified end-to-end: sync parses 71 records (39 confirmed, 23 open questions, 9 rejected) with 62 cross-refs and zero broken links; `ticket new`, `ticket status`, `ticket board`, and `decisions show --with-refs` all round-trip correctly. Co-Authored-By: Claude <noreply@anthropic.com>
203 lines
6.5 KiB
Python
203 lines
6.5 KiB
Python
"""Markdown parser for decisions/*.md.
|
|
|
|
Yields `Record` dicts with id, type, domain, title, status, date,
|
|
file_path, and a list of (target_id, ref_type, note) cross-refs.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
HEADING_RE = re.compile(r"^###\s+((?:D|Q|R)-\d+):\s+(.+)$")
|
|
DATE_RE = re.compile(r"^\s*-\s+\*\*(?:Date|Rejected):\*\*\s+(\d{4}-\d{2}-\d{2})")
|
|
STATUS_RE = re.compile(r"^\s*-\s+\*\*Status:\*\*\s+(.+)")
|
|
SUPERSEDES_RE = re.compile(r"^\s*-\s+\*\*Supersedes:\*\*", re.IGNORECASE)
|
|
SUPERSEDED_BY_RE = re.compile(r"^\s*-\s+\*\*Superseded\s+by:\*\*", re.IGNORECASE)
|
|
RESOLVES_RE = re.compile(r"^\s*-\s+\*\*Resolves:\*\*", re.IGNORECASE)
|
|
DEPENDS_RE = re.compile(r"^\s*-\s+\*\*Depends\s+on:\*\*", re.IGNORECASE)
|
|
AMENDS_RE = re.compile(r"^\s*\*\*Amendment\s*\(", re.IGNORECASE)
|
|
CROSS_REF_RE = re.compile(r"^\s*-\s+\*\*Cross-reference:\*\*", re.IGNORECASE)
|
|
REF_ID_RE = re.compile(r"(?:D|Q|R|T)-\d+")
|
|
|
|
TYPE_FROM_PREFIX = {"D": "confirmed", "Q": "question", "R": "rejected"}
|
|
|
|
|
|
@dataclass
|
|
class Record:
|
|
id: str
|
|
type: str
|
|
domain: str
|
|
title: str
|
|
status: str
|
|
date: str | None
|
|
file_path: str
|
|
refs: list[tuple[str, str, str]] = field(default_factory=list)
|
|
|
|
|
|
def _infer_status(rec_type: str, title: str, body: list[str]) -> str:
|
|
if rec_type == "rejected":
|
|
return "active"
|
|
for line in body:
|
|
if SUPERSEDED_BY_RE.match(line):
|
|
return "superseded"
|
|
if rec_type == "question":
|
|
for line in body:
|
|
m = STATUS_RE.match(line)
|
|
if not m:
|
|
continue
|
|
s = m.group(1).strip().lower()
|
|
if "partial" in s or "remaining" in s:
|
|
return "open"
|
|
if s.startswith("resolved"):
|
|
return "resolved"
|
|
return "open"
|
|
return "open"
|
|
return "active"
|
|
|
|
|
|
def _extract_date(body: list[str]) -> str | None:
|
|
for line in body:
|
|
m = DATE_RE.match(line)
|
|
if m:
|
|
return m.group(1)
|
|
return None
|
|
|
|
|
|
def _extract_refs(rec_id: str, body: list[str]) -> list[tuple[str, str, str]]:
|
|
refs: list[tuple[str, str, str]] = []
|
|
seen: set[tuple[str, str]] = set()
|
|
for line in body:
|
|
if SUPERSEDES_RE.match(line):
|
|
ref_type = "supersedes"
|
|
elif SUPERSEDED_BY_RE.match(line):
|
|
ref_type = "references"
|
|
elif RESOLVES_RE.match(line):
|
|
ref_type = "resolves"
|
|
elif DEPENDS_RE.match(line):
|
|
ref_type = "depends_on"
|
|
elif AMENDS_RE.match(line):
|
|
ref_type = "amends"
|
|
else:
|
|
ref_type = "references"
|
|
for target in REF_ID_RE.findall(line):
|
|
if target == rec_id or target.startswith("T-"):
|
|
# T-NNN refs are ticket→decision, not decision↔decision
|
|
continue
|
|
key = (target, ref_type)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
note = line.strip().lstrip("- ").rstrip()
|
|
if len(note) > 200:
|
|
note = note[:197] + "..."
|
|
refs.append((target, ref_type, note))
|
|
return refs
|
|
|
|
|
|
def parse_file(path: Path, repo_root: Path) -> list[Record]:
|
|
domain = path.stem
|
|
if domain.startswith("questions-"):
|
|
domain = domain[len("questions-"):]
|
|
rel_path = str(path.relative_to(repo_root))
|
|
text = path.read_text(encoding="utf-8")
|
|
|
|
records: list[Record] = []
|
|
cur_id: str | None = None
|
|
cur_title: str | None = None
|
|
cur_body: list[str] = []
|
|
|
|
def flush() -> None:
|
|
nonlocal cur_id, cur_title, cur_body
|
|
if cur_id is None:
|
|
return
|
|
rec_type = TYPE_FROM_PREFIX[cur_id[0]]
|
|
records.append(
|
|
Record(
|
|
id=cur_id,
|
|
type=rec_type,
|
|
domain=domain,
|
|
title=(cur_title or "").strip(),
|
|
status=_infer_status(rec_type, cur_title or "", cur_body),
|
|
date=_extract_date(cur_body),
|
|
file_path=rel_path,
|
|
refs=_extract_refs(cur_id, cur_body),
|
|
)
|
|
)
|
|
cur_id = None
|
|
cur_title = None
|
|
cur_body = []
|
|
|
|
for line in text.split("\n"):
|
|
m = HEADING_RE.match(line)
|
|
if m:
|
|
flush()
|
|
cur_id = m.group(1)
|
|
cur_title = m.group(2)
|
|
cur_body = []
|
|
elif cur_id is not None:
|
|
if line.strip() == "---":
|
|
flush()
|
|
else:
|
|
cur_body.append(line)
|
|
flush()
|
|
return records
|
|
|
|
|
|
def parse_all(repo_root: Path) -> tuple[list[Record], list[str]]:
|
|
decisions_dir = repo_root / "decisions"
|
|
if not decisions_dir.is_dir():
|
|
return [], [f"decisions/ not found at {decisions_dir}"]
|
|
records: list[Record] = []
|
|
warnings: list[str] = []
|
|
for path in sorted(decisions_dir.glob("*.md")):
|
|
if path.name.lower() == "readme.md":
|
|
continue
|
|
try:
|
|
records.extend(parse_file(path, repo_root))
|
|
except Exception as exc: # noqa: BLE001 - we want a useful warning
|
|
warnings.append(f"error parsing {path.name}: {exc}")
|
|
return records, warnings
|
|
|
|
|
|
def validate(repo_root: Path) -> tuple[bool, list[str]]:
|
|
"""Return (ok, errors). Non-zero errors fail push-check."""
|
|
records, warnings = parse_all(repo_root)
|
|
errors: list[str] = list(warnings)
|
|
|
|
ids_seen: dict[str, str] = {}
|
|
for rec in records:
|
|
if rec.id in ids_seen and ids_seen[rec.id] != rec.file_path:
|
|
errors.append(
|
|
f"duplicate id {rec.id}: {ids_seen[rec.id]} and {rec.file_path}"
|
|
)
|
|
ids_seen[rec.id] = rec.file_path
|
|
if not rec.title:
|
|
errors.append(f"{rec.id}: empty title")
|
|
|
|
known = set(ids_seen)
|
|
for rec in records:
|
|
for target, ref_type, _ in rec.refs:
|
|
if target not in known:
|
|
errors.append(
|
|
f"{rec.id} → {target} ({ref_type}): target not found"
|
|
)
|
|
|
|
return (len(errors) == 0), errors
|
|
|
|
|
|
def next_id(repo_root: Path, prefix: str) -> str:
|
|
prefix = prefix.upper()
|
|
if prefix not in TYPE_FROM_PREFIX:
|
|
raise ValueError(f"invalid prefix {prefix!r} — must be D, Q, or R")
|
|
records, _ = parse_all(repo_root)
|
|
highest = 0
|
|
for rec in records:
|
|
if rec.id.startswith(f"{prefix}-"):
|
|
try:
|
|
highest = max(highest, int(rec.id.split("-", 1)[1]))
|
|
except ValueError:
|
|
continue
|
|
return f"{prefix}-{highest + 1:03d}"
|