* test(#2705): failing-first regression for single-sourced ADR legacy range * docs(#2705): single-source the legacy ADR range; classify 0174/0656 as mis-padded modern * fix(#2705): align README prose with test vocabulary (mis-padded modern; backtick-tolerant range regex) (review) * docs(changeset): #2705 single-source legacy ADR range * docs(changeset): backfill #2705 PR number to 2836 * chore: trigger clean CI run (prior run cancelled by rapid backfill push)
616 lines
29 KiB
JavaScript
616 lines
29 KiB
JavaScript
'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, describe } = 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 = '<!-- ADR-INDEX:START — generated by scripts/gen-adr-index.cjs; do not edit by hand -->';
|
||
const END = '<!-- ADR-INDEX: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:\*\* <Token>` 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 <script>x</script> 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('<script>'), 'angle brackets must be escaped, not emitted raw');
|
||
assert.match(readme, /<script>/);
|
||
});
|
||
|
||
test('a backslash-pipe in a title cannot break out of its table cell', (t) => {
|
||
// CodeQL js/incomplete-sanitization: escaping `|` -> `\|` without escaping the
|
||
// backslash FIRST turns the input `\|` into `\\|`, which markdown reads as a
|
||
// literal backslash plus an UNESCAPED pipe — re-opening the very cell break the
|
||
// pipe escape exists to prevent.
|
||
const root = makeRepo(t, {
|
||
'0001-alpha.md': adr('Alpha \\| Accepted \\| forged', ['**Status:** Proposed']),
|
||
});
|
||
assert.equal(run(root, ['--write']).status, 0);
|
||
const readme = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8');
|
||
|
||
const row = readme.split(/\r?\n/).find((l) => l.includes('0001-alpha.md'));
|
||
assert.ok(row, 'the ADR must still have a row');
|
||
// 4 pipes = the row's own delimiters (| id | title | status | read-first |) = 5.
|
||
// Any unescaped pipe from the title would add a 6th boundary and shift the cells.
|
||
const unescaped = [...row.matchAll(/(?<!\\)\|/g)].length;
|
||
assert.equal(unescaped, 5, `title pipes must stay escaped; row was: ${row}`);
|
||
assert.match(readme, /Proposed \(1\)/, 'the forged cell must not land the ADR in Active');
|
||
});
|
||
|
||
test('a pipe in a title cannot break out of its table cell', (t) => {
|
||
const root = makeRepo(t, {
|
||
'0001-alpha.md': adr('Alpha | Accepted | fake', ['**Status:** Proposed']),
|
||
});
|
||
assert.equal(run(root, ['--write']).status, 0);
|
||
const readme = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8');
|
||
assert.match(readme, /Alpha \\\| Accepted \\\| fake/, 'pipes must be escaped');
|
||
assert.match(readme, /Proposed \(1\)/, 'the forged cell must not land the ADR in Active');
|
||
});
|
||
|
||
test('a file that does not match the naming convention is reported, not crashed on', (t) => {
|
||
// Review finding: `notes.md` hit `file.match(/^([0-9]+)-/)[1]` → TypeError on
|
||
// null. An unparseable name is also invisible to the index — the exact failure
|
||
// this gate exists to prevent — so it must surface as a violation.
|
||
const root = makeRepo(t, {
|
||
'0001-alpha.md': adr('Alpha', ['**Status:** Accepted']),
|
||
'notes.md': '# Scratch notes\n\nNot an ADR.\n',
|
||
});
|
||
const res = run(root, ['--check']);
|
||
assert.equal(res.status, 1);
|
||
assert.match(res.stderr, /notes\.md/);
|
||
assert.match(res.stderr, /does not match the .*convention/);
|
||
assert.doesNotMatch(res.stderr, /TypeError|Cannot read propert/, 'must be a gate violation, not a crash');
|
||
});
|
||
|
||
test('the real repo corpus is clean and its index is current', () => {
|
||
// The gate must hold against docs/adr/ as committed, not only fixtures.
|
||
const res = run(REPO_ROOT, ['--check']);
|
||
assert.equal(res.status, 0, `docs/adr/ must satisfy its own gate:\n${res.stderr}`);
|
||
});
|
||
|
||
// --- regressions -----------------------------------------------------------
|
||
//
|
||
// The --check gate above passes on a corpus that still carries dangling
|
||
// references: it validates naming, relation symmetry, and index freshness, but
|
||
// it never resolves a link target and it STRIPS the H1 status bracket
|
||
// (gen-adr-index.cjs) rather than comparing it. Both defect classes below were
|
||
// green under `--check` while broken. These assert on the real corpus, so
|
||
// reverting the repair re-reds them.
|
||
|
||
const ADR_DIR = path.join(REPO_ROOT, 'docs', 'adr');
|
||
const STATUS_TOKENS = ['Accepted', 'Proposed', 'Superseded', 'Legacy', 'Retired'];
|
||
|
||
function adrMarkdownFiles() {
|
||
return fs.readdirSync(ADR_DIR).filter((f) => f.endsWith('.md'));
|
||
}
|
||
|
||
test('every relative markdown link in docs/adr/ resolves to a file that exists', () => {
|
||
const dangling = [];
|
||
for (const file of adrMarkdownFiles()) {
|
||
const body = fs.readFileSync(path.join(ADR_DIR, file), 'utf8');
|
||
for (const match of body.matchAll(/\]\(([^)#:\s]+\.md)(?:#[^)]*)?\)/g)) {
|
||
const target = match[1];
|
||
if (!fs.existsSync(path.resolve(ADR_DIR, target))) {
|
||
dangling.push(`${file} -> ${target}`);
|
||
}
|
||
}
|
||
}
|
||
assert.deepEqual(
|
||
dangling,
|
||
[],
|
||
`dangling relative links in docs/adr/ (a link written as reference/x.md from inside docs/adr/ resolves to the nonexistent docs/adr/reference/):\n${dangling.join('\n')}`,
|
||
);
|
||
});
|
||
|
||
test('no ADR H1 status bracket contradicts its Status field', () => {
|
||
// The index generator strips a trailing "[Proposed]"-style bracket for
|
||
// display instead of comparing it, so a stale bracket is invisible to the
|
||
// gate while still being the first thing a reader sees.
|
||
const mismatches = [];
|
||
for (const file of adrMarkdownFiles()) {
|
||
if (file === 'README.md') continue;
|
||
const lines = fs.readFileSync(path.join(ADR_DIR, file), 'utf8').split(/\r?\n/);
|
||
const heading = lines.find((l) => /^#\s/.test(l)) || '';
|
||
const bracket = heading.match(/\[(Proposed|Accepted|Superseded|Legacy|Retired)\]\s*$/i);
|
||
if (!bracket) continue;
|
||
const statusLine = lines.find((l) => /^\s*[-*]?\s*\*\*Status/.test(l)) || '';
|
||
// Resolve by earliest position in the line, not by STATUS_TOKENS order: a
|
||
// Status field like "Superseded by ADR-X (was Accepted ...)" mentions two
|
||
// tokens, and array order would pick 'Accepted' and report a false mismatch
|
||
// against a correct [Superseded] bracket.
|
||
let token;
|
||
let tokenAt = Infinity;
|
||
for (const s of STATUS_TOKENS) {
|
||
const at = statusLine.search(new RegExp(`\\b${s}\\b`, 'i'));
|
||
if (at !== -1 && at < tokenAt) {
|
||
tokenAt = at;
|
||
token = s;
|
||
}
|
||
}
|
||
if (token && token.toLowerCase() !== bracket[1].toLowerCase()) {
|
||
mismatches.push(`${file}: H1 says [${bracket[1]}], Status field says ${token}`);
|
||
}
|
||
}
|
||
assert.deepEqual(mismatches, [], `H1 bracket contradicts Status:\n${mismatches.join('\n')}`);
|
||
});
|
||
|
||
test('the ADR path cited by src/plan-drift-guard.cts exists', () => {
|
||
// This module is compiled into the published payload, so a wrong citation
|
||
// here ships to users.
|
||
const src = fs.readFileSync(path.join(REPO_ROOT, 'src', 'plan-drift-guard.cts'), 'utf8');
|
||
const cited = [...src.matchAll(/docs\/adr\/([A-Za-z0-9._-]+\.md)/g)].map((m) => m[1]);
|
||
assert.notEqual(cited.length, 0, 'expected plan-drift-guard.cts to cite its governing ADR');
|
||
for (const name of cited) {
|
||
assert.ok(
|
||
fs.existsSync(path.join(ADR_DIR, name)),
|
||
`src/plan-drift-guard.cts cites docs/adr/${name}, which does not exist`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('the ADR naming worked example names an ADR file that exists', () => {
|
||
for (const rel of ['CONTRIBUTING.md', path.join('docs', 'contributor-standards.md')]) {
|
||
const body = fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8');
|
||
const examples = [...body.matchAll(/docs\/adr\/(\d+-[a-z0-9-]+\.md)/g)].map((m) => m[1]);
|
||
assert.notEqual(examples.length, 0, `expected ${rel} to show a worked ADR-naming example`);
|
||
for (const name of examples) {
|
||
assert.ok(
|
||
fs.existsSync(path.join(ADR_DIR, name)),
|
||
`${rel} illustrates the naming convention with docs/adr/${name}, which does not exist`,
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
// ─── #2705: legacy ADR range is single-sourced and matches disk ─────────────
|
||
//
|
||
// The legacy zero-padded ADR range used to be stated two different ways
|
||
// (contributor-standards.md vs adr/README.md), both wrong. Now adr/README.md is
|
||
// the single authoritative statement and contributor-standards.md references it.
|
||
// The legacy set is the zero-padded ADRs whose numbers do NOT correspond to a
|
||
// same-numbered repository issue; 0174 and 0656 are modern mis-padded files.
|
||
|
||
describe('#2705: legacy ADR range single-sourced and accurate', () => {
|
||
const STANDARDS = path.join(REPO_ROOT, 'docs', 'contributor-standards.md');
|
||
const README = path.join(REPO_ROOT, 'docs', 'adr', 'README.md');
|
||
|
||
// Zero-padded ADR files on disk (4-digit prefix).
|
||
function zeroPaddedAdrFiles() {
|
||
return fs.readdirSync(ADR_DIR)
|
||
.filter((f) => /^\d{4}-.*\.md$/.test(f))
|
||
.map((f) => ({ file: f, num: Number(f.slice(0, 4)) }));
|
||
}
|
||
|
||
// A zero-padded number is "legacy sequential" iff no same-numbered issue-shaped
|
||
// ADR exists (i.e. the 4-digit number is NOT an issue number reused). 0001–0012
|
||
// are sequential (collisions on disk corroborate); 0174/0656 match issues.
|
||
function legacyNumbers() {
|
||
const all = zeroPaddedAdrFiles();
|
||
const nums = new Set(all.map((a) => a.num));
|
||
const legacy = new Set();
|
||
for (const n of nums) {
|
||
// Sequential legacy range: numbers 1..12 have intra-range duplicates on disk
|
||
// (0010 x2, 0011 x3) and do not correspond to same-numbered modern files.
|
||
if (n >= 1 && n <= 12) legacy.add(n);
|
||
}
|
||
return legacy;
|
||
}
|
||
|
||
test('exactly one doc restates the legacy range; the other references it', () => {
|
||
const readme = fs.readFileSync(README, 'utf8');
|
||
const standards = fs.readFileSync(STANDARDS, 'utf8');
|
||
// The authoritative "0001-* through 0012-*" statement lives in README. The
|
||
// range tokens are backtick-delimited in the prose (`0001-*` through `0012-*`),
|
||
// so match the digits+through+digits ignoring the backtick/asterisk escapes.
|
||
assert.match(readme, /0001-\*[^A-Za-z0-9]*through[^A-Za-z0-9]*0012-\*/, 'README must carry the authoritative legacy-range statement');
|
||
// contributor-standards must NOT restate the range — it must cross-reference README.
|
||
assert.doesNotMatch(
|
||
standards,
|
||
/0001-\*[^A-Za-z0-9]*through[^A-Za-z0-9]*001[12]-\*/,
|
||
'contributor-standards.md must not restate the legacy range (single-sourced in README); it should reference it',
|
||
);
|
||
assert.match(
|
||
standards,
|
||
/adr\/README\.md/,
|
||
'contributor-standards.md must reference docs/adr/README.md for the legacy range',
|
||
);
|
||
});
|
||
|
||
test('README legacy range matches the on-disk legacy set and excludes mis-padded modern 0174/0656', () => {
|
||
const readme = fs.readFileSync(README, 'utf8');
|
||
// The legacy clause must NOT include 0174 (the prior wrong "(and 0174-*)" clause).
|
||
assert.doesNotMatch(
|
||
readme,
|
||
/through 0012-\* \(and 0174-\*\)/,
|
||
'README must not classify 0174 as legacy residue (it is a modern mis-padded ADR)',
|
||
);
|
||
// The legacy set on disk (0001–0012) all exist as zero-padded files.
|
||
const legacy = legacyNumbers();
|
||
for (const n of legacy) {
|
||
const pad = String(n).padStart(4, '0');
|
||
const matches = fs.readdirSync(ADR_DIR).filter((f) => f.startsWith(`${pad}-`));
|
||
assert.ok(matches.length > 0, `legacy ADR ${pad}-* must exist on disk`);
|
||
}
|
||
});
|
||
|
||
test('README identifies 0174 and 0656 as mis-padded modern ADRs', () => {
|
||
const readme = fs.readFileSync(README, 'utf8');
|
||
assert.match(readme, /0174/, 'README must mention 0174');
|
||
assert.match(readme, /0656/, 'README must mention 0656');
|
||
assert.match(
|
||
readme,
|
||
/modern.*mis-padded|mis-padded.*modern/i,
|
||
'README must identify the zero-padded modern ADRs as mis-padded modern files, not legacy residue',
|
||
);
|
||
});
|
||
});
|