|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""changelog-manual.py — ZERO-TOKEN: feed the CHANGELOG's real descriptions into the manual. |
| 3 | +
|
| 4 | +The auto-generated Experimental.md lists features but doesn't describe them, so the FAQ bot used to |
| 5 | +invent behaviour. The CHANGELOG *does* describe them (Esa writes what each feature does when he ships |
| 6 | +it) — but spread across Feature:/Improvement: entries under varying names. This aggregates every |
| 7 | +changelog entry that mentions a feature and emits, per feature, its dated real descriptions. |
| 8 | +
|
| 9 | +Outputs: |
| 10 | + docs/FEATURE-DESCRIPTIONS.md — the full feature → changelog-descriptions map |
| 11 | + fills <!-- AUTO:descriptions:TOPIC --> blocks in manual/Experimental.md (e.g. TOPIC=playerpro) |
| 12 | +
|
| 13 | + python3 .spine/changelog-manual.py [repo_root] |
| 14 | +""" |
| 15 | +import json |
| 16 | +import re |
| 17 | +import sys |
| 18 | +from pathlib import Path |
| 19 | + |
| 20 | +ROOT = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/Users/esaruoho/work/paketti") |
| 21 | +CHANGELOG = ROOT / "manual" / "CHANGESLOG.md" |
| 22 | +INDEX = ROOT / "docs" / "paketti-functions.json" |
| 23 | +MANUAL = ROOT / "manual" / "Experimental.md" |
| 24 | +OUT = ROOT / "docs" / "FEATURE-DESCRIPTIONS.md" |
| 25 | + |
| 26 | +GENERIC = {"dialog", "dialogs", "tool", "tools", "mode", "modes", "paketti", "renoise", "feature", |
| 27 | + "features", "window", "panel", "button", "buttons", "menu", "the", "and", "for", "with", |
| 28 | + "now", "this", "will", "that", "your", "from", "playerpro"} # playerpro: too broad alone |
| 29 | + |
| 30 | + |
| 31 | +def changelog_entries(): |
| 32 | + entries, cur = [], None |
| 33 | + for ln in CHANGELOG.read_text(encoding="utf-8").splitlines(): |
| 34 | + m = re.match(r"^### (\d{4}-\d{2}-\d{2}) - (Feature|Improvement|Fix|Change)?:?\s*(.*)", ln) |
| 35 | + if m: |
| 36 | + if cur: |
| 37 | + entries.append(cur) |
| 38 | + cur = {"date": m.group(1), "kind": (m.group(2) or "Note"), "head": m.group(3).strip(), |
| 39 | + "body": []} |
| 40 | + elif cur and ln.strip(): |
| 41 | + cur["body"].append(ln.strip()) |
| 42 | + if cur: |
| 43 | + entries.append(cur) |
| 44 | + return entries |
| 45 | + |
| 46 | + |
| 47 | +def base_features(): |
| 48 | + """Distinct base feature names from the index (sub-action suffixes collapsed).""" |
| 49 | + idx = json.loads(INDEX.read_text(encoding="utf-8")) |
| 50 | + names = {f["function"] for fns in idx.values() for f in fns} |
| 51 | + return sorted(names) |
| 52 | + |
| 53 | + |
| 54 | +def words_of(s): |
| 55 | + return {w for w in re.findall(r"[a-z0-9]{3,}", s.lower()) if w not in GENERIC} |
| 56 | + |
| 57 | + |
| 58 | +def describe(feature, entries): |
| 59 | + """PRECISE: the feature must be the SUBJECT of a Feature/Improvement entry — its name appears as a |
| 60 | + near-exact phrase at the START of the heading. Fuzzy word-overlap produced wrong descriptions |
| 61 | + (Auto-Open → 'Toggle Mute/Unmute Tracks'), which is the bullshitting we're killing. Precision over |
| 62 | + recall: a feature with no confident match is honestly undocumented, for the teach loop to fill.""" |
| 63 | + core = re.sub(r"[.…]+$", "", feature).strip().lower() |
| 64 | + relaxed = re.sub(r"^(paketti |playerpro )+", "", core) |
| 65 | + cores = [core] + ([relaxed] if relaxed != core else []) |
| 66 | + hits = [] |
| 67 | + for e in entries: |
| 68 | + if e["kind"] not in ("Feature", "Improvement"): |
| 69 | + continue |
| 70 | + head = e["head"].lower() |
| 71 | + pos = min((head.find(c) for c in cores if c in head), default=-1) |
| 72 | + if 0 <= pos <= 30: # the feature LEADS the heading = it's the subject |
| 73 | + rank = 0 if e["kind"] == "Feature" else 1 |
| 74 | + hits.append((rank, pos, e)) |
| 75 | + hits.sort(key=lambda x: (x[0], x[1], x[2]["date"])) |
| 76 | + return [e for _, _, e in hits] |
| 77 | + |
| 78 | + |
| 79 | +def entry_text(e): |
| 80 | + body = " ".join(l for l in e["body"] if not l.lstrip().startswith("![") and l.strip() not in ("--", "---")) |
| 81 | + desc = (e["head"] + ((" — " + body) if body else "")).strip() |
| 82 | + return f"- **{e['date']} ({e['kind']})** {desc[:400]}" |
| 83 | + |
| 84 | + |
| 85 | +def main(): |
| 86 | + entries = changelog_entries() |
| 87 | + feats = base_features() |
| 88 | + described, undoc = {}, [] |
| 89 | + for f in feats: |
| 90 | + d = describe(f, entries) |
| 91 | + if d: |
| 92 | + described[f] = d |
| 93 | + else: |
| 94 | + undoc.append(f) |
| 95 | + |
| 96 | + lines = ["# Paketti — Feature Descriptions (sourced from the CHANGELOG)", "", |
| 97 | + f"*Zero-token, regenerated by `.spine/changelog-manual.py`. " |
| 98 | + f"{len(described)} features have changelog descriptions; {len(undoc)} are undocumented " |
| 99 | + f"(need a write-up). Never hand-edit — describe features in the CHANGELOG instead.*", ""] |
| 100 | + for f in sorted(described): |
| 101 | + lines.append(f"### {f}") |
| 102 | + for e in described[f][:4]: |
| 103 | + lines.append(entry_text(e)) |
| 104 | + lines.append("") |
| 105 | + lines.append("## Undocumented (no changelog description yet)") |
| 106 | + lines.append("") |
| 107 | + for f in undoc: |
| 108 | + lines.append(f"- {f}") |
| 109 | + OUT.write_text("\n".join(lines) + "\n", encoding="utf-8") |
| 110 | + print(f"wrote {OUT} — {len(described)} described, {len(undoc)} undocumented") |
| 111 | + return 0 |
| 112 | + |
| 113 | + |
| 114 | +if __name__ == "__main__": |
| 115 | + sys.exit(main()) |
0 commit comments