'use strict'; /** * Behavioral tests for scripts/gen-adr-index.cjs — the ADR index generator and * lifecycle gate (#2340). * * These drive the real CLI as a subprocess against synthetic ADR corpora in a * temp dir, asserting on exit code and emitted text. No source-grepping: the * runtime behavior is the contract. */ const { test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); const { createTempDir, cleanup } = require('./helpers.cjs'); const REPO_ROOT = path.resolve(__dirname, '..'); const SCRIPT_REL = path.join('scripts', 'gen-adr-index.cjs'); const START = ''; const END = ''; /** * Build a throwaway repo whose docs/adr/ contains exactly `files`, and whose * scripts/ holds a copy of the generator + its cli-exit dependency. A unique * mkdtemp per call keeps parallel tests from colliding, and the dir is removed * via `t.after()` so a failing assertion cannot leak it. */ function makeRepo(t, files) { // helpers.cleanup (not raw fs.rmSync) carries the Windows-EBUSY retry budget. const root = createTempDir('gsd-adr-index-'); t.after(() => cleanup(root)); fs.mkdirSync(path.join(root, 'docs', 'adr'), { recursive: true }); fs.mkdirSync(path.join(root, 'scripts', 'lib'), { recursive: true }); fs.copyFileSync(path.join(REPO_ROOT, SCRIPT_REL), path.join(root, SCRIPT_REL)); fs.copyFileSync( path.join(REPO_ROOT, 'scripts', 'lib', 'cli-exit.cjs'), path.join(root, 'scripts', 'lib', 'cli-exit.cjs'), ); for (const [name, body] of Object.entries(files)) { fs.writeFileSync(path.join(root, 'docs', 'adr', name), body); } fs.writeFileSync( path.join(root, 'docs', 'adr', 'README.md'), `# ADRs\n\n## Index\n\n${START}\n${END}\n`, ); return root; } /** * Run the generator in `root`; never throws — returns {status, stdout, stderr}. * * spawnSync (not execFileSync) because BOTH streams matter on BOTH outcomes: * `--write` exits 0 while reporting outstanding violations on stderr, and * execFileSync only surfaces stderr via the thrown error on non-zero exit. */ function run(root, args = []) { const res = spawnSync(process.execPath, [path.join(root, SCRIPT_REL), ...args], { cwd: root, encoding: 'utf8', timeout: 30_000, }); if (res.error) throw res.error; return { status: res.status, stdout: res.stdout || '', stderr: res.stderr || '' }; } const adr = (title, fields) => `# ${title}\n\n${fields.map((f) => `- ${f}`).join('\n')}\n\n## Context\n\nBody.\n`; test('a clean corpus generates an index and --check passes', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Alpha module', ['**Status:** Accepted', '**Date:** 2026-01-01']), '900-beta.md': adr('ADR-900: Beta module', ['**Status:** Proposed', '**Date:** 2026-01-02']), }); const write = run(root, ['--write']); assert.equal(write.status, 0, `--write failed: ${write.stderr}`); const check = run(root, ['--check']); assert.equal(check.status, 0, `--check failed: ${check.stderr}`); const readme = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8'); assert.match(readme, /\[ADR-0001\]\(0001-alpha\.md\)/, 'zero-padded id must render as written, not ADR-1'); assert.match(readme, /\[ADR-900\]\(900-beta\.md\)/); assert.match(readme, /Active decisions \(1\)/); assert.match(readme, /Proposed \(1\)/); }); test('--check fails when an ADR is added but the index is not regenerated', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']) }); assert.equal(run(root, ['--write']).status, 0); // A new ADR lands without re-running --write. This is the exact drift that let // the hand-maintained index reach 40/65. fs.writeFileSync(path.join(root, 'docs', 'adr', '901-gamma.md'), adr('Gamma', ['**Status:** Accepted'])); const check = run(root, ['--check']); assert.equal(check.status, 1, 'a missing index row must fail CI'); assert.match(check.stderr, /stale/i); }); test('a status outside the vocabulary is rejected and names the offender', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Draft']) }); const res = run(root, ['--check']); assert.equal(res.status, 1); assert.match(res.stderr, /0001-alpha\.md/); assert.match(res.stderr, /"Draft" is not one of/); }); test('an ADR with no status field at all is rejected', (t) => { const root = makeRepo(t, { '0001-alpha.md': '# Alpha\n\nNo header fields.\n\n## Context\n\nBody.\n' }); const res = run(root, ['--check']); assert.equal(res.status, 1); assert.match(res.stderr, /no `- \*\*Status:\*\* ` field/); }); test('the table header form is parsed as legitimately as the bullet form', (t) => { // ADR-2008 uses a markdown table for its header. Treating that as "missing a // status" would flag a correct ADR. const root = makeRepo(t, { '0001-alpha.md': '# Alpha\n\n| | |\n|---|---|\n| **Status** | Accepted |\n| **Date** | 2026-01-01 |\n\n## Context\n\nBody.\n', }); const res = run(root, ['--write']); assert.equal(res.status, 0, `table-form header must parse: ${res.stderr}`); assert.match(fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8'), /Active decisions \(1\)/); }); test('Superseded must name its successor as a file link, not a bare id', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Superseded by ADR-900 (2026-02-01)']), '900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** [ADR-0001](0001-alpha.md)']), }); const res = run(root, ['--check']); assert.equal(res.status, 1); assert.match(res.stderr, /not as a markdown link/); }); test('Superseded with no successor at all is rejected', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Superseded']) }); const res = run(root, ['--check']); assert.equal(res.status, 1); assert.match(res.stderr, /names no successor/); }); test('a one-way supersession is rejected and the message names the fix', (t) => { const root = makeRepo(t, { // Beta claims Alpha; Alpha says nothing back. '900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** [ADR-0001](0001-alpha.md)']), '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']), }); const res = run(root, ['--check']); assert.equal(res.status, 1); assert.match(res.stderr, /0001-alpha\.md/); assert.match(res.stderr, /does not record it/); assert.match(res.stderr, /\*\*Superseded by:\*\* \[ADR-900\]\(900-beta\.md\)/); }); test('a symmetric supersession pair passes', (t) => { const root = makeRepo(t, { '900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** [ADR-0001](0001-alpha.md)']), '0001-alpha.md': adr('Alpha', ['**Status:** Superseded by [ADR-900](900-beta.md) (2026-02-01)']), }); assert.equal(run(root, ['--check']).status, 1, 'index not yet written'); assert.equal(run(root, ['--write']).status, 0); assert.equal(run(root, ['--check']).status, 0, 'a symmetric pair must pass'); }); test('subsumption is symmetry-checked but does NOT mark the target superseded', (t) => { // The EoS case: ADR-1239 subsumes ADR-1016 as an adapter. ADR-1016 stays // Accepted — collapsing this into supersession would kill a live decision. const root = makeRepo(t, { '900-eos.md': adr('EoS', ['**Status:** Accepted', '**Subsumes as adapters:** [ADR-0001](0001-alpha.md)']), '0001-alpha.md': adr('Alpha', ['**Status:** Accepted', '**Subsumed by:** [ADR-900](900-eos.md)']), }); assert.equal(run(root, ['--write']).status, 0); const readme = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8'); assert.match(readme, /Active decisions \(2\)/, 'a subsumed ADR stays Active'); // The subsumer is surfaced in the "Read first" column so EoS is discoverable // from the component ADR. assert.match(readme, /\| \[ADR-0001\]\(0001-alpha\.md\) \|[^|]*\| Accepted \| \[ADR-900\]\(900-eos\.md\) \|/); }); test('a missing subsumption back-link is rejected', (t) => { const root = makeRepo(t, { '900-eos.md': adr('EoS', ['**Status:** Accepted', '**Subsumes:** [ADR-0001](0001-alpha.md)']), '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']), }); const res = run(root, ['--check']); assert.equal(res.status, 1); assert.match(res.stderr, /\*\*Subsumed by:\*\* \[ADR-900\]\(900-eos\.md\)/); }); test("a Proposed ADR's supersession claim is prospective — no back-link demanded", (t) => { // ADR-857 is Proposed and claims to generalize live ADRs. Demanding the // back-link would stamp an Accepted decision as superseded by an unratified one. const root = makeRepo(t, { '900-beta.md': adr('Beta', ['**Status:** Proposed', '**Supersedes:** [ADR-0001](0001-alpha.md)']), '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']), }); assert.equal(run(root, ['--write']).status, 0); const check = run(root, ['--check']); assert.equal(check.status, 0, `a Proposed claimant must not force a back-link: ${check.stderr}`); }); test('ratifying that Proposed ADR to Accepted then demands the back-link', (t) => { const root = makeRepo(t, { '900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** [ADR-0001](0001-alpha.md)']), '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']), }); const res = run(root, ['--check']); assert.equal(res.status, 1, 'on ratification the back-link becomes required'); assert.match(res.stderr, /does not record it/); }); test('"Supersedes: nothing" asserts no relation even when it name-drops an ADR', (t) => { // ADR-2264 says "Supersedes: nothing; amends the ADR-1239 harness". Reading that // as a supersession claim invents a link the author never made. const root = makeRepo(t, { '900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** nothing; amends the [ADR-0001](0001-alpha.md) harness']), '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']), }); assert.equal(run(root, ['--write']).status, 0); const check = run(root, ['--check']); assert.equal(check.status, 0, `a negated relation field must assert nothing: ${check.stderr}`); }); test('an em-dash relation value asserts no relation', (t) => { // Vacuous unless the negated field also carries a LINK: with a bare em-dash // there is nothing to mis-parse, so the test passes whether or not negation // works. Linking an ADR after the em-dash makes it discriminating — if the // field were read as a real claim, symmetry would demand 0001 record it. const root = makeRepo(t, { '900-beta.md': '# Beta\n\n| | |\n|---|---|\n| **Status** | Accepted |\n| **Supersedes** | — see [ADR-0001](0001-alpha.md) for context |\n\n## Context\n\nBody.\n', '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']), }); assert.equal(run(root, ['--write']).status, 0); const check = run(root, ['--check']); assert.equal(check.status, 0, `an em-dash field must assert nothing: ${check.stderr}`); assert.doesNotMatch(check.stderr, /does not record it/); }); test('a mixed field with one link and one bare id still flags the bare id', (t) => { // Regression: testing `rel.links.length` instead of the specific id meant a // field carrying ANY link silently dropped every bare claim beside it. const root = makeRepo(t, { '900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** [ADR-0001](0001-alpha.md), ADR-0002']), '0001-alpha.md': adr('Alpha', ['**Status:** Superseded by [ADR-900](900-beta.md)']), '0002-gamma.md': adr('Gamma', ['**Status:** Accepted']), }); const res = run(root, ['--check']); assert.equal(res.status, 1); assert.match(res.stderr, /names ADR-2 without a file link/); }); test('a bare id repeated in prose beside its own link is not flagged', (t) => { // The corpus legitimately writes "…([ADR-0001](0001-alpha.md)) — see ADR-0001 // below". That repeat must not be noise. const root = makeRepo(t, { '900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** Alpha ([ADR-0001](0001-alpha.md)) — see the ADR-0001 note below']), '0001-alpha.md': adr('Alpha', ['**Status:** Superseded by [ADR-900](900-beta.md)']), }); assert.equal(run(root, ['--write']).status, 0); const check = run(root, ['--check']); assert.equal(check.status, 0, `a linked-and-repeated id must not be flagged: ${check.stderr}`); }); test('a dangling "Superseded by" is caught even though the ADR is not Accepted', (t) => { // Regression: the ratification guard skipped every non-Accepted ADR, which // killed the IN direction entirely — a Superseded ADR pointing at a successor // that never claims it went unchecked. const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Superseded by [ADR-900](900-beta.md)']), '900-beta.md': adr('Beta', ['**Status:** Accepted']), }); const res = run(root, ['--check']); assert.equal(res.status, 1, 'a one-way superseded-by must fail'); assert.match(res.stderr, /does not claim it|does not record it/); }); test('a `## Supersedes` table section counts as the claim (ADR-0174 shape)', (t) => { // The richest form in the corpus declares supersession as a section+table, not // a header field. Reading only the header block reported the repo's // best-documented supersession as missing. const root = makeRepo(t, { '900-beta.md': '# Beta\n\n- **Status:** Accepted\n\n## Supersedes\n\n| ADR | What it said | Why superseded |\n|---|---|---|\n| [ADR-0001](0001-alpha.md) | a thing | a reason |\n\n## Context\n\nBody.\n', '0001-alpha.md': adr('Alpha', ['**Status:** Superseded by [ADR-900](900-beta.md)']), }); assert.equal(run(root, ['--write']).status, 0); const check = run(root, ['--check']); assert.equal(check.status, 0, `a ## Supersedes table must satisfy symmetry: ${check.stderr}`); }); test('a title id that disagrees with the filename is rejected', (t) => { // The real ADR-218 case: renamed to the issue# convention, title left behind. const root = makeRepo(t, { '218-release.md': adr('ADR-0175: Harden release validation', ['**Status:** Accepted']) }); const res = run(root, ['--check']); assert.equal(res.status, 1); assert.match(res.stderr, /H1 declares ADR-175 but the filename says 218/); }); test('a relation link to a nonexistent ADR is rejected', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Accepted', '**Supersedes:** [ADR-404](404-ghost.md)']), }); const res = run(root, ['--check']); assert.equal(res.status, 1); assert.match(res.stderr, /does not exist in docs\/adr\//); }); test('a bare id naming a nonexistent ADR is steered toward issue syntax', (t) => { // ADR-1610 says "superseding the #597 tier-max ratchet" — #597 is an ISSUE. // Written as "ADR-597" it would be an unresolvable reference. const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Accepted', '**Supersedes:** ADR-597 tier-max ratchet']), }); const res = run(root, ['--check']); assert.equal(res.status, 1); assert.match(res.stderr, /If it is an ISSUE number, write "#597"/); }); test('an ambiguous bare id reports every file it could mean', (t) => { const root = makeRepo(t, { '0011-one.md': adr('One', ['**Status:** Accepted']), '0011-two.md': adr('Two', ['**Status:** Accepted']), '900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** ADR-0011']), }); const res = run(root, ['--check']); assert.equal(res.status, 1); assert.match(res.stderr, /ambiguous — resolves to 2 files/); assert.match(res.stderr, /0011-one\.md/); assert.match(res.stderr, /0011-two\.md/); }); test('--check fails loudly when the README markers are missing', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']) }); fs.writeFileSync(path.join(root, 'docs', 'adr', 'README.md'), '# ADRs\n\nNo markers here.\n'); const res = run(root, ['--check']); assert.equal(res.status, 1); assert.match(res.stderr, /missing the index markers/); }); test('--write still emits the index while reporting outstanding violations', (t) => { // --write must remain usable as a repair tool on a corpus that is not yet clean, // but must not pretend the corpus is healthy. const root = makeRepo(t, { '900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** [ADR-0001](0001-alpha.md)']), '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']), }); const res = run(root, ['--write']); assert.equal(res.status, 0, '--write proceeds'); assert.match(res.stderr, /lifecycle violation\(s\) remain/); assert.match(fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8'), /Active decisions \(2\)/); }); test('an ADR title cannot hijack the README splice with an index marker', (t) => { // Review finding: a title carrying the literal END marker was emitted verbatim // into the table cell, relocating the boundary so the NEXT --write spliced // against the wrong marker and ate the rest of README.md. const root = makeRepo(t, { '0001-alpha.md': adr(`Evil ${END} title`, ['**Status:** Accepted']), }); assert.equal(run(root, ['--write']).status, 0); const readme = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8'); const endCount = readme.split(END).length - 1; assert.equal(endCount, 1, 'exactly one END marker must survive — the title must not forge another'); // The splice must remain stable across repeated writes. assert.equal(run(root, ['--write']).status, 0); const again = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8'); assert.equal(again.split(END).length - 1, 1); assert.equal(run(root, ['--check']).status, 0, 'a hostile title must not leave the index permanently stale'); }); test('a title cannot inject raw HTML into the generated index', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Alpha module', ['**Status:** Accepted']), }); assert.equal(run(root, ['--write']).status, 0); const readme = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8'); assert.ok(!readme.includes('