#!/usr/bin/env bun /** * release-notes.ts — turn a commit range into capability-level release notes * using the TRACES graph instead of raw commit subjects. * * Usage: * bun run scripts/release-notes.ts [] * bun run scripts/release-notes.ts v0.0.15..HEAD * * With no argument it uses ..HEAD (or the whole history if untagged). * * How it works: * 1. `git diff --name-only ` → files the range changed. * 2. Read each changed file's `TRACES:` comments → requirement IDs. * 3. Resolve IDs to descriptions from docs/requirements.md. * 4. Group: UR → Features, DR/IR → Improvements. Deduped, so many commits * touching one requirement collapse to one line. * * This is a drafting aid for docs/release-checklist.md — review the output, * it does not invent descriptions for untraced changes (those are listed * separately so nothing is silently dropped). */ import { execSync } from "node:child_process"; import { readFileSync, existsSync } from "node:fs"; const TRACE_RE = /TRACES:\s*([^\n*]+)/g; const ID_RE = /\b(UR|IR|DR|JA|UT|IT)-\d+\b/g; const REQ_ROW_RE = /^\|\s*((?:UR|IR|DR|JA)-\d+)\s*\|\s*([^|]+?)\s*\|/; function sh(cmd: string): string { return execSync(cmd, { encoding: "utf8" }).trim(); } function defaultRange(): string { try { const tag = sh("git describe --tags --abbrev=0"); return `${tag}..HEAD`; } catch { return ""; // no tags: fall through to whole-history diff } } /** Map requirement ID → human description, parsed from docs/requirements.md. */ function loadRequirementDescriptions(): Map { const map = new Map(); const text = readFileSync("docs/requirements.md", "utf8"); for (const line of text.split("\n")) { const m = line.match(REQ_ROW_RE); // First definition wins: the descriptive tables come before the later // cross-reference tables, whose cells hold linked IDs (or "-"), not prose. if (m && !map.has(m[1])) map.set(m[1], m[2].trim()); } return map; } function changedFiles(range: string): string[] { const cmd = range ? `git diff --name-only ${range}` : "git ls-files"; // untagged repo: describe everything currently traced return sh(cmd) .split("\n") .filter((f) => f && existsSync(f)); } /** Collect requirement IDs referenced by TRACES comments in the given files. */ function idsFromFiles(files: string[]): Set { const ids = new Set(); for (const file of files) { let content: string; try { content = readFileSync(file, "utf8"); } catch { continue; } for (const trace of content.matchAll(TRACE_RE)) { for (const id of trace[1].matchAll(ID_RE)) ids.add(id[0]); } } return ids; } function main() { const range = process.argv[2] ?? defaultRange(); const descriptions = loadRequirementDescriptions(); const files = changedFiles(range); const ids = idsFromFiles(files); const features: string[] = []; // UR const improvements: string[] = []; // DR / IR const unknown: string[] = []; // traced but not in requirements.md for (const id of [...ids].sort()) { const desc = descriptions.get(id); if (id.startsWith("UT") || id.startsWith("IT")) continue; // tests aren't notes if (!desc) { if (!id.startsWith("UT") && !id.startsWith("IT")) unknown.push(id); continue; } const line = `- ${desc} (${id})`; if (id.startsWith("UR")) features.push(line); else improvements.push(line); } const header = range || "(entire history — no tags found)"; const out: string[] = [`## Release notes — ${header}`, ""]; if (features.length) out.push("### ✨ Features", ...features, ""); if (improvements.length) out.push("### 🚀 Improvements", ...improvements, ""); if (unknown.length) out.push( "### ⚠️ Traced IDs missing from requirements.md", ...unknown.map((id) => `- ${id}`), "", ); const untraced = files.filter((f) => { try { return !/TRACES:/.test(readFileSync(f, "utf8")); } catch { return false; } }); if (untraced.length) out.push( `### 📝 Changed files without TRACES (${untraced.length}) — review manually`, ...untraced.map((f) => `- ${f}`), "", ); if (!features.length && !improvements.length) out.push("_No traced requirements in this range._", ""); console.log(out.join("\n")); } main();