#!/usr/bin/env node 'use strict'; /** * Generates the ADR index table in docs/adr/README.md from the ADR files * themselves, and validates the corpus' lifecycle invariants. * * The index is a DERIVED artifact: it is regenerated from every * `docs/adr/-.md` on disk, so it cannot silently drift out of date * the way a hand-maintained table does. CI re-runs this with `--check` and * fails on any diff or invariant violation. * * Invariants enforced (see docs/adr/README.md "Lifecycle rules"): * 1. Every ADR declares `- **Status:** ` with Token in STATUSES. * 2. A Superseded/Retired ADR names its successor as a markdown link to the * target file — never a bare "ADR-N", which is ambiguous (ADR-0010 and * ADR-0011 each resolve to more than one file). * 3. Supersession is symmetric: if A supersedes B, B records superseded-by A. * 4. An ADR whose H1 declares an id must match its filename's id. * 5. The committed index equals the generated index. * * Usage: * node scripts/gen-adr-index.cjs # print the index to stdout * node scripts/gen-adr-index.cjs --write # rewrite the index in README.md * node scripts/gen-adr-index.cjs --check # exit 1 if stale or invalid */ const fs = require('node:fs'); const path = require('node:path'); const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const ROOT = path.resolve(__dirname, '..'); const ADR_DIR = path.join(ROOT, 'docs', 'adr'); const README_PATH = path.join(ADR_DIR, 'README.md'); const START_MARKER = ''; const END_MARKER = ''; /** * The canonical status vocabulary. * * `Legacy` and `Retired` are deliberately distinct from `Superseded`: * - Superseded — a specific newer ADR replaced this decision. Names it. * - Retired — the thing this ADR decided no longer exists at all, and no * single ADR replaced it (e.g. a deleted package boundary). * - Legacy — frozen historical record, kept for provenance, not a * pattern to imitate. * * NOTE: `Legacy` describes a DECISION's standing, not a filename. The * `0001-`..`0012-` sequential *naming* era is legacy, but many of those ADRs * (e.g. 0002, 0004, 0008, 0009) are Accepted and load-bearing today. Do not * conflate the two: grep the naming rule in README.md, not this enum. */ const STATUSES = ['Accepted', 'Proposed', 'Superseded', 'Legacy', 'Retired']; /** * Header fields that assert a lifecycle relation. * * Two DISTINCT relations, deliberately not conflated: * * supersedes — the target decision is REPLACED. The target's status becomes * Superseded and it must name this ADR. (ADR-0174 → ADR-0005.) * * subsumes — the target decision still HOLDS, but a broader ADR now frames * it; the target keeps its Accepted status and becomes a component of the * larger decision. (ADR-1239/EoS subsumes ADR-1016 "as the declarative * adapter" — the descriptor is still real and still correct.) * * Both directions are symmetry-checked, but only `supersedes` implies a status * change on the target. Collapsing subsumption into supersession would mark * four live, load-bearing ADRs as dead — the opposite of the truth. */ const RELATION_FIELDS = new Map([ ['supersedes', { kind: 'supersedes', dir: 'out' }], ['superseded by', { kind: 'supersedes', dir: 'in' }], ['subsumes', { kind: 'subsumes', dir: 'out' }], ['subsumed by', { kind: 'subsumes', dir: 'in' }], ]); /** Relation kinds and the header field a reader should add to fix each gap. */ const RELATION_SPEC = { supersedes: { out: 'Supersedes', in: 'Superseded by' }, subsumes: { out: 'Subsumes', in: 'Subsumed by' }, }; /** * A relation field whose value opens with "nothing"/"none"/"n/a" asserts the * absence of the relation, whatever prose follows it. */ const NEGATED_RELATION_RE = /^\s*(?:nothing|none|n\/a|[—–-])\s*(?:$|[;,.]|\s)/i; /** * Header fields appear in two shapes across the corpus, both legitimate: * bullet — `- **Status:** Accepted` * table — `| **Status** | Accepted |` * Yield [field, value] for either. */ function* headerFields(header) { const bullet = /^\s*[-*]\s*\*\*([^*:]+?)(?::)?\*\*\s*(.*)$/gm; let m; while ((m = bullet.exec(header)) !== null) yield [m[1].trim(), m[2].trim()]; const row = /^\s*\|\s*\*\*([^*|]+?)(?::)?\*\*\s*\|\s*(.*?)\s*\|\s*$/gm; while ((m = row.exec(header)) !== null) yield [m[1].trim(), m[2].trim()]; } /** Numeric identity of an ADR: "0011" and "11" are the same id. */ function canonicalId(raw) { return String(raw).replace(/^0+(?=\d)/, ''); } /** The documented filename shape: `-.md`. */ const ADR_FILENAME_RE = /^[0-9]+-[a-z0-9-]+\.md$/; function adrFiles() { return fs .readdirSync(ADR_DIR) .filter((f) => f.endsWith('.md') && f !== 'README.md') .filter((f) => fs.statSync(path.join(ADR_DIR, f)).isFile()) .sort(); } /** * Split the directory into files this tool can parse and files it cannot. * * A file without a numeric prefix is not merely unparseable — it is invisible * to the index, which is the failure this gate exists to prevent. Report it as * a violation naming the convention, rather than crashing on `match(...)[1]` * or silently skipping it. */ function partitionAdrFiles() { const conforming = []; const nonConforming = []; for (const f of adrFiles()) (ADR_FILENAME_RE.test(f) ? conforming : nonConforming).push(f); return { conforming, nonConforming }; } /** Extract the leading bullet-field header block (everything before the first `##`). */ function headerBlock(text) { const body = text.split(/\r?\n/); const stop = body.findIndex((l) => /^##\s/.test(l)); return (stop === -1 ? body : body.slice(0, stop)).join('\n'); } /** * A relation may also be declared as a whole SECTION rather than a header field. * ADR-0174 is the exemplar: a `## Supersedes` heading over a table whose first * column links each superseded ADR and whose remaining columns explain why. * That is the richest form in the corpus and must count — reading only the * header block would report the repo's best-documented supersession as missing. * * Returns { supersedes: [file…], subsumes: [file…] } from matching sections. */ const RELATION_SECTION_RE = /^##\s+(Supersedes|Subsumes)\b[^\n]*$/i; function relationSections(text) { const lines = text.split(/\r?\n/); const out = { supersedes: [], subsumes: [] }; for (let i = 0; i < lines.length; i++) { const m = lines[i].match(RELATION_SECTION_RE); if (!m) continue; const kind = m[1].toLowerCase() === 'supersedes' ? 'supersedes' : 'subsumes'; // Collect until the next heading of any level. let j = i + 1; const body = []; for (; j < lines.length && !/^#{1,6}\s/.test(lines[j]); j++) body.push(lines[j]); const chunk = body.join('\n'); if (NEGATED_RELATION_RE.test(chunk.trim())) continue; out[kind].push(...linkedAdrFiles(chunk)); i = j - 1; } return out; } /** All markdown links to sibling ADR files inside a chunk of text. */ function linkedAdrFiles(text) { const out = []; const re = /\]\(\s*(?:\.\/)?([0-9]+-[a-z0-9-]+\.md)\s*\)/gi; let m; while ((m = re.exec(text)) !== null) out.push(m[1]); return out; } /** Bare `ADR-123` / `ADR 123` mentions that are NOT part of a markdown link. */ function bareAdrRefs(text) { const withoutLinks = text.replace(/\[[^\]]*\]\([^)]*\)/g, ''); const out = []; const re = /\bADR[-\s]0*(\d+)\b/gi; let m; while ((m = re.exec(withoutLinks)) !== null) out.push(canonicalId(m[1])); return out; } function parseAdr(file) { const full = path.join(ADR_DIR, file); const text = fs.readFileSync(full, 'utf8'); const lines = text.split(/\r?\n/); // `fileId` is the numeric identity used for comparison ("0011" === "11"); // `displayId` preserves the filename's prefix exactly as written, because the // corpus and its cross-references say "ADR-0001" and "ADR-58", not "ADR-1". const rawId = file.match(/^([0-9]+)-/)[1]; const fileId = canonicalId(rawId); const displayId = rawId; const h1 = (lines.find((l) => /^#\s/.test(l)) || '').replace(/^#\s+/, '').trim(); // Title as displayed: drop a leading "ADR-123 — " / "ADR-123: " prefix and a // trailing "[Proposed]"-style status bracket, both of which the index renders // from structured fields instead. const title = h1 .replace(/^ADR[-\s]?0*\d+\s*(?:[—:-]\s*)?/i, '') .replace(/\s*\[(?:Proposed|Accepted|Superseded|Legacy|Retired)\]\s*$/i, '') .trim(); const declaredIdMatch = h1.match(/^ADR[-\s]?0*(\d+)\b/i); const declaredId = declaredIdMatch ? canonicalId(declaredIdMatch[1]) : null; const header = headerBlock(text); let statusRaw = null; // relations[kind][dir] = [{field, value, links, bare}] const relations = { supersedes: { out: [], in: [] }, subsumes: { out: [], in: [] } }; for (const [field, value] of headerFields(header)) { if (field.toLowerCase() === 'status') { if (statusRaw === null) statusRaw = value; continue; } // "Supersedes (generalizes)" / "Subsumes as adapters" → "supersedes" / "subsumes" const key = field.toLowerCase().replace(/\s*\([^)]*\)\s*/g, ' ').replace(/\s+as\s+.*$/, '').trim(); const spec = RELATION_FIELDS.get(key); if (!spec) continue; // "Supersedes: nothing; amends the ADR-1239 harness" asserts NO relation. Such a // field routinely name-drops other ADRs in its prose ("related", "amends", "builds // on"); reading those as supersession claims invents links that were never made. if (NEGATED_RELATION_RE.test(value)) continue; relations[spec.kind][spec.dir].push({ field, value, links: linkedAdrFiles(value), bare: bareAdrRefs(value) }); } const statusToken = statusRaw ? (statusRaw.match(/^([A-Za-z]+)/) || [])[1] : null; // A "Superseded by X" written into the Status line itself is the relation. if (statusRaw && /^Superseded\b/i.test(statusRaw)) { relations.supersedes.in.push({ field: 'Status', value: statusRaw, links: linkedAdrFiles(statusRaw), bare: bareAdrRefs(statusRaw) }); } // `## Supersedes` / `## Subsumes` sections count as OUT claims (ADR-0174's table). const sections = relationSections(text); for (const kind of ['supersedes', 'subsumes']) { if (sections[kind].length === 0) continue; relations[kind].out.push({ field: `## ${kind === 'supersedes' ? 'Supersedes' : 'Subsumes'} section`, value: '', links: sections[kind], bare: [] }); } return { file, fileId, displayId, title, declaredId, statusRaw, statusToken, relations, text }; } function buildCorpus() { const { conforming, nonConforming } = partitionAdrFiles(); const adrs = conforming.map(parseAdr); const byFile = new Map(adrs.map((a) => [a.file, a])); const byId = new Map(); for (const a of adrs) { if (!byId.has(a.fileId)) byId.set(a.fileId, []); byId.get(a.fileId).push(a); } return { adrs, byFile, byId, nonConforming }; } function validate({ adrs, byFile, byId, nonConforming }) { const errors = []; const add = (file, msg) => errors.push(`${file}: ${msg}`); for (const f of nonConforming) { add( f, 'filename does not match the `-.md` convention, so it cannot appear in the index. ' + 'Rename it (see docs/adr/README.md "Naming Convention"), or move it out of docs/adr/ if it is not an ADR.', ); } for (const a of adrs) { if (!a.statusToken) { add(a.file, 'no `- **Status:** ` field found in the header block.'); continue; } if (!STATUSES.includes(a.statusToken)) { add(a.file, `status "${a.statusToken}" is not one of ${STATUSES.join(' | ')} (full line: "${a.statusRaw}").`); } if (a.declaredId && a.declaredId !== a.fileId) { add(a.file, `H1 declares ADR-${a.declaredId} but the filename says ${a.fileId}. The id must match the filename.`); } // A Superseded ADR must point at its successor by FILE LINK. if (a.statusToken === 'Superseded') { const links = a.relations.supersedes.in.flatMap((r) => r.links); if (links.length === 0) { const bare = a.relations.supersedes.in.flatMap((r) => r.bare); add( a.file, bare.length ? `status is Superseded and mentions ADR-${bare.join('/')} but not as a markdown link to the file. ` + 'Bare ids are ambiguous (ADR-0010 and ADR-0011 each resolve to multiple files) — link the target file.' : 'status is Superseded but names no successor. Write `Superseded by [ADR-N](N-slug.md)`.', ); } } // Every relation link must resolve; every bare id must be linked (and exist). for (const kind of Object.keys(RELATION_SPEC)) { for (const dir of ['out', 'in']) { for (const rel of a.relations[kind][dir]) { for (const l of rel.links) { if (!byFile.has(l)) add(a.file, `"${rel.field}" links "${l}", which does not exist in docs/adr/.`); } // The synthetic relation lifted out of the Status line is already covered by // the dedicated Superseded check above; reporting it again just duplicates. if (rel.field === 'Status') continue; // Ids already linked ANYWHERE in this field. A field legitimately // reads "…([ADR-58](58-x.md)) — see 'Relation to ADR-58' below": the // trailing prose repeats an id that is linked earlier, and flagging // that would be noise. But an id that appears ONLY bare is an // unchecked claim — and testing `rel.links.length` instead of the // specific id silently dropped every bare claim in a field that // happened to carry one link. const linkedIds = new Set(rel.links.map((l) => (byFile.get(l) || {}).fileId).filter(Boolean)); for (const b of rel.bare) { if (linkedIds.has(b)) continue; const candidates = byId.get(b) || []; if (candidates.length === 0) { add(a.file, `"${rel.field}" names ADR-${b}, which does not exist in docs/adr/. If it is an ISSUE number, write "#${b}" — not "ADR-${b}".`); } else { add( a.file, `"${rel.field}" names ADR-${b} without a file link` + (candidates.length > 1 ? ` (ambiguous — resolves to ${candidates.length} files: ${candidates.map((c) => c.file).join(', ')})` : '') + '. Link the target file so the relation is checkable.', ); } } } } } } // Symmetry, per relation kind: A -out-> B <=> B -in-> A. // // Only a RATIFIED (Accepted) claimant is owed the back-link. A Proposed ADR's // supersession claim is prospective — it has not taken effect, so stamping its // target as superseded would assert something untrue (ADR-857 is Proposed and // claims to generalize ADR-0011/ADR-58, both of which are Accepted and live). // When such an ADR is ratified to Accepted, this check starts demanding the // back-links at exactly the right moment. const OPPOSITE = { out: 'in', in: 'out' }; for (const a of adrs) { for (const kind of Object.keys(RELATION_SPEC)) { for (const dir of ['out', 'in']) { // The ratification guard applies to the OUT direction only: an unratified // ADR's claim over someone else is prospective and must not obligate the // target. The IN direction is this ADR's statement about ITSELF ("I am // superseded by X") and is always owed a reciprocal — guarding it too // would skip every Superseded ADR (statusToken !== 'Accepted') and leave // dangling one-way claims unchecked, which is the bug this gate exists // to catch. if (dir === 'out' && a.statusToken !== 'Accepted') continue; for (const target of new Set(a.relations[kind][dir].flatMap((r) => r.links))) { const b = byFile.get(target); if (!b) continue; const back = new Set(b.relations[kind][OPPOSITE[dir]].flatMap((r) => r.links)); if (back.has(a.file)) continue; const needed = RELATION_SPEC[kind][OPPOSITE[dir]]; const claim = dir === 'out' ? `it ${kind} this ADR` : `it is ${kind === 'supersedes' ? 'superseded' : 'subsumed'} by this ADR`; add( target, `${a.file} declares ${claim}, but this ADR does not record it. ` + `Add \`- **${needed}:** [ADR-${a.displayId}](${a.file})\` so a reader of THIS file learns the decision moved on.`, ); } } } } return errors; } const GROUPS = [ { heading: 'Active decisions', blurb: 'These govern the system as it stands. Cite these.', match: (a) => a.statusToken === 'Accepted', }, { heading: 'Proposed', blurb: 'Decided in principle, not yet ratified. Do not cite as settled architecture.', match: (a) => a.statusToken === 'Proposed', }, { heading: 'Superseded, Retired, and Legacy', blurb: 'Historical record. **Do not follow these** — each names what replaced it, or why it was retired.', match: (a) => ['Superseded', 'Retired', 'Legacy'].includes(a.statusToken), }, ]; /** * Render ADR-authored text (a title) into a markdown table cell. * * Three hazards, all from text this script does not control: * - `|` would split the cell and corrupt the row. * - An HTML comment would be emitted verbatim into README.md. A title * containing the END marker relocates it, so the NEXT `--write` splices * against the wrong boundary and silently eats the rest of the file. * Escaping `<`/`>` makes a comment sequence unformable, which also blocks * any other HTML injected through a title. * - A backslash is markdown's own escape character, so it MUST be escaped * first. Escaping `|` → `\|` without it turns the input `\|` into `\\|`, * which markdown reads as a literal backslash followed by an UNESCAPED * pipe — re-opening the cell break the pipe escape exists to prevent. * Order is load-bearing: backslash first, then everything that emits one. */ function cellText(text) { return String(text) .replace(/\\/g, '\\\\') .replace(/\|/g, '\\|') .replace(//g, '>') .replace(/\r?\n/g, ' ') .trim(); } function linkCell(files, byFile) { if (files.length === 0) return '—'; return files.map((l) => `[ADR-${(byFile.get(l) || {}).displayId || '?'}](${l})`).join(', '); } function renderIndex(corpus) { const { byFile } = corpus; const out = [START_MARKER, '']; for (const g of GROUPS) { const rows = corpus.adrs.filter(g.match).sort((x, y) => Number(x.fileId) - Number(y.fileId)); if (rows.length === 0) continue; out.push(`### ${g.heading} (${rows.length})`, '', g.blurb, ''); const isHistorical = g.heading.startsWith('Superseded'); // "Read first" points at the broader ADR that now frames this one. It is how a // reader of a still-Accepted component decision (e.g. the runtime descriptor) // discovers the wider decision that reframed it (e.g. EoS) instead of assuming // the component IS the architecture. out.push( isHistorical ? '| ADR | Title | Status | Replaced by |' : '| ADR | Title | Status | Read first |', isHistorical ? '|-----|-------|--------|-------------|' : '|-----|-------|--------|------------|', ); for (const a of rows) { const cells = [`[ADR-${a.displayId}](${a.file})`, cellText(a.title), a.statusToken]; cells.push( isHistorical ? linkCell([...new Set(a.relations.supersedes.in.flatMap((r) => r.links))], byFile) : linkCell([...new Set(a.relations.subsumes.in.flatMap((r) => r.links))], byFile), ); out.push(`| ${cells.join(' | ')} |`); } out.push(''); } out.push( `_${corpus.adrs.length} ADRs. Generated by \`scripts/gen-adr-index.cjs\` — run \`--write\` after adding or restatusing an ADR._`, '', END_MARKER, ); return out.join('\n'); } function spliceIntoReadme(readme, index) { const start = readme.indexOf(START_MARKER); const end = readme.indexOf(END_MARKER); if (start === -1 || end === -1) { throw new ExitError( 1, `docs/adr/README.md is missing the index markers.\nExpected:\n ${START_MARKER}\n ${END_MARKER}\n`, ); } return readme.slice(0, start) + index + readme.slice(end + END_MARKER.length); } function main() { const [, , flag] = process.argv; const corpus = buildCorpus(); const errors = validate(corpus); if (errors.length > 0 && flag !== '--write') { process.stderr.write( `docs/adr/ has ${errors.length} lifecycle violation(s).\n` + 'See docs/adr/README.md "Lifecycle rules" for the contract.\n\n', ); for (const e of errors) process.stderr.write(` ✗ ${e}\n`); process.stderr.write('\n'); throw new ExitError(1); } const index = renderIndex(corpus); if (flag === '--check') { const readme = fs.readFileSync(README_PATH, 'utf8'); const expected = spliceIntoReadme(readme, index); if (expected !== readme) { process.stderr.write( 'docs/adr/README.md index is stale. Run:\n node scripts/gen-adr-index.cjs --write\n\n', ); throw new ExitError(1); } process.stdout.write(`docs/adr/README.md index is up to date (${corpus.adrs.length} ADRs).\n`); } else if (flag === '--write') { const readme = fs.readFileSync(README_PATH, 'utf8'); fs.writeFileSync(README_PATH, spliceIntoReadme(readme, index)); process.stdout.write(`Wrote ADR index into ${README_PATH} (${corpus.adrs.length} ADRs).\n`); if (errors.length > 0) { process.stderr.write(`\n${errors.length} lifecycle violation(s) remain — --check will fail:\n\n`); for (const e of errors) process.stderr.write(` ✗ ${e}\n`); } } else { process.stdout.write(index + '\n'); } } runMain(main);