#!/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; } /** * Commit subjects whose changes carry no requirement meaning. * * `chore(format)` / `style` rewrite files without changing behaviour; * `chore(deps)` moves lockfiles. Anything else — including a bare `chore:` and * `chore(release):` — is assumed to mean something and is kept. * * Anchored at the start of the subject on purpose: "fix(duration): format times * over 24 hours" is a real fix to formatting *code*, not a formatting commit. */ const COSMETIC_SUBJECT = /^(chore\(format\)|chore\(deps\)|style)(\([^)]*\))?\s*:/i; /** * Does this commit subject describe a change with no requirement meaning? * * Exported for scripts/release-notes.test.ts. * * TRACES: | DR-219 */ export function isCosmeticCommit(subject: string): boolean { return COSMETIC_SUBJECT.test(subject.trim()); } /** * Files the range changed, excluding those touched only by cosmetic commits. * * Why not a plain `git diff --name-only `: that is what this did, and a * single repo-wide `prettier --write` inside the range made it report 199 files * whose TRACES comments resolved to nearly the entire requirement matrix. The * generated notes for v0.9.2 claimed the release had added the whole * application — and build-release.yml publishes this output, so the noise would * have shipped. * * Walking commit by commit and skipping the cosmetic ones keeps a file that a * sweep *and* a real change both touched: it is still listed by the real * commit. Only files touched exclusively by cosmetic commits drop out, which is * exactly the intent. * * Merge commits produce no output from `git diff-tree` without `-m`, and are * skipped deliberately: everything they merge is already in the range as its * own commit, so including them would double-count. */ function changedFiles(range: string): string[] { // Untagged repo: describe everything currently traced. if (!range) { return sh("git ls-files") .split("\n") .filter((f) => f && existsSync(f)); } // NUL between hash and subject so a subject containing anything at all is safe. const log = sh(`git log --no-merges --format=%H%x00%s ${range}`); if (!log) return []; const files = new Set(); let skipped = 0; for (const line of log.split("\n")) { const [sha, ...subjectParts] = line.split("\u0000"); const subject = subjectParts.join("\u0000"); if (!sha) continue; if (isCosmeticCommit(subject)) { skipped++; continue; } for (const f of sh(`git diff-tree --no-commit-id --name-only -r ${sha}`).split("\n")) { if (f && existsSync(f)) files.add(f); } } if (skipped > 0) { // Say what was dropped rather than silently reporting a smaller set. console.error( `ℹ️ Skipped ${skipped} cosmetic commit(s) (formatting/deps) when deriving notes.`, ); } return [...files]; } /** 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")); } // Guarded so this module stays importable from release-notes.test.ts. if (import.meta.main) { main(); }