Add a docs-site (mdBook) with a Gitea publish-docs workflow, a release-notes generator script (release:notes) that turns a commit range's TRACES into grouped notes, the background-audio feature spec, and CLAUDE.md. Ignore docs-site build artifacts.
138 lines
4.3 KiB
TypeScript
138 lines
4.3 KiB
TypeScript
#!/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 [<range>]
|
|
* bun run scripts/release-notes.ts v0.0.15..HEAD
|
|
*
|
|
* With no argument it uses <latest tag>..HEAD (or the whole history if untagged).
|
|
*
|
|
* How it works:
|
|
* 1. `git diff --name-only <range>` → 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<string, string> {
|
|
const map = new Map<string, string>();
|
|
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<string> {
|
|
const ids = new Set<string>();
|
|
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();
|