'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 { copyScriptWithDeps } = require('./helpers/copy-script-fixture.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 together with its transitive * relative-require graph. 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 }); copyScriptWithDeps(REPO_ROOT, root, SCRIPT_REL); 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 || '' }; } /** * Run the generator with `--json` and parse its stdout into the structured * report (see gen-adr-index.cjs's `--json` doc comment for the shape). Per * CONTRIBUTING.md's "Prohibited: Raw Text Matching on Test Outputs" (and the * `bin/verify-reapply-patches.cjs` worked example this PR follows), gate * assertions bind to this typed report instead of regexing stderr prose. * Asserts the parse succeeded with a useful message on failure — a crash * that corrupts stdout (or leaves it empty) fails loudly here instead of * throwing an opaque `JSON.parse` SyntaxError deep inside a test body. */ function runJson(root, args = []) { const r = run(root, ['--json', ...args]); let report; try { report = JSON.parse(r.stdout); } catch (err) { assert.fail(`--json did not emit parseable JSON on stdout (status ${r.status}): ${err.message}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`); } return { status: r.status, report }; } 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\b/); assert.match(readme, /### Proposed\b/); }); 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\b/); }); 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\b/, '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\b/); }); 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(' \\', ['**Status:** Accepted']), ); assertInsertOnly(before, after, 'adding an ADR whose title carries |, <, >, and \\'); const row = after.find((l) => l.includes('200-beta.md')); assert.ok(row, 'the hostile-title ADR must still have a row'); const cells = [...row.matchAll(/(?'), 'angle brackets must be escaped, not emitted raw'); }); // --- 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'); // The two corpus-level checks that used to live here — "every relative // markdown link in docs/adr/ resolves" and "no ADR H1 status bracket // contradicts its Status field" — were themselves second implementations of // the rule scripts/gen-adr-index.cjs now enforces for real: exactly the // `DEFECT.GENERATIVE-FIX` shape (a check and its parallel copy, nothing // asserting agreement) this PR (#2704) exists to remove. The corpus // assertion is now made by `test('the real corpus passes the new // assertions')` below, which is strictly stronger — it runs the shipping // `--check` code path instead of a parallel regex copy that could silently // drift from it. One deliberate behavioral difference: the old link-check // test flagged a dangling `.md` link even inside a fenced code block, where // the real gate treats fenced (and inline) code as code — markdown does not // render a link there, so masking it out is correct, not a regression. 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'); // allow-test-rule: source-text-is-the-product — the ADR citation is a comment in src/plan-drift-guard.cts, erased at compile time, so no runtime observation can reach it (#3502) // This site surfaced only after no-source-grep was widened to recognize .cts // reads and .matchAll() (#3502); it is irreducible, not unconverted — there is // no exported value to require() in its place, since a comment leaves no // runtime trace to assert against. 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', ); }); }); // ─── #2704: link resolution + H1 status bracket vs Status: field ─────────── // // Failing-first: the production surface below does not exist yet. // scripts/gen-adr-index.cjs will gain: // module.exports = { STATUSES, REASON, extractLinks, maskCode } // extractLinks(text) -> [{ line, target }] (1-indexed line, RAW dest text) // maskCode(text) -> same-length string; code masked to ' ', newlines kept // `if (require.main === module) runMain(main);` (today it runs unconditionally) // `--json` -> { ok, adrCount, indexStale, violations: [{file, reason, ...}] } // on stdout, gate-verdict tests below bind to this typed report via // `runJson`, not stderr prose (REASON is the frozen enum of violation kinds). // // Two altitudes, per .gsd/phase/feat-2704-adr-gate-link-resolution/50-test-matrix.md: // IR altitude — require the real script from REPO_ROOT and assert on // extractLinks/maskCode's typed return values. // Gate-verdict altitude — run(root, ['--check']) and assert on exit status + // stderr, following this file's established idiom. // // MESSAGE-FORMAT CONTRACT these tests bind the CLI to (there was no contract // before this PR, so this file establishes one): a dangling/escaping link // violation is reported as `:: ...` — filename, a literal colon, // the 1-indexed line number, then prose naming the raw target text. A // repository-escaping target gets a message containing "escap...", distinct // from the generic "does not resolve" wording used for an ordinary dangling // target (row 27) — proof the escape guard runs before any generic resolution // attempt. An H1-bracket-vs-Status disagreement names the file and BOTH // tokens and uses the word "bracket", so rows 46/47 can assert its ABSENCE // precisely when a different, pre-existing error already owns the report. // // fast-check is confirmed present in package.json devDependencies (^4.8.0); // property tests below pin { seed: 2704, numRuns: 200 } per-call so a failure // replays deterministically regardless of this suite's global fc default. const { STATUSES, REASON, extractLinks, maskCode, } = require(path.join(REPO_ROOT, SCRIPT_REL)); const fc = require('./helpers/fast-check-setup.cjs'); /** * Build an ADR with header fields plus explicit prose body lines after * '## Context'. Array `.join('\n')` only — fence/inline-span detection is * column-sensitive, so an indented template literal would silently corrupt * the boundary rows (15-20). */ function adrBody(title, fields, bodyLines) { return [ `# ${title}`, '', ...fields.map((f) => `- ${f}`), '', '## Context', '', ...bodyLines, '', ].join('\n'); } // ── Link resolution — gate-verdict altitude ───────────────────────────────── test('a resolving relative link passes', (t) => { const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['See [Beta](900-beta.md) for context.']), '900-beta.md': adr('Beta', ['**Status:** Accepted']), }); assert.equal(run(root, ['--write']).status, 0); const res = run(root, ['--check']); assert.equal(res.status, 0, `a link to an existing sibling must resolve: ${res.stderr}`); }); test('a dangling relative link fails and names file, line and target', (t) => { const root = makeRepo(t, { // Lines: 1 '# Alpha', 2 '', 3 '- **Status:** Accepted', 4 '', 5 '## Context', // 6 '', 7 'Body text.', 8 the link line, 9 trailing ''. '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['Body text.', 'See [Ghost](ghost.md) for context.']), }); const { status, report } = runJson(root, ['--check']); assert.equal(status, 1, 'a dangling relative link must fail --check'); assert.ok( report.violations.some( (v) => v.reason === REASON.LINK_UNRESOLVED && v.file === '0001-alpha.md' && v.line === 8 && v.target === 'ghost.md', ), `expected a link_unresolved violation for 0001-alpha.md:8 target ghost.md; got ${JSON.stringify(report.violations)}`, ); }); test('a directory target counts as resolved', (t) => { const okRoot = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['See the [PRD folder](../prd/) for background.']), }); fs.mkdirSync(path.join(okRoot, 'docs', 'prd'), { recursive: true }); assert.equal(run(okRoot, ['--write']).status, 0); assert.equal(run(okRoot, ['--check']).status, 0, 'an existing directory target must resolve'); const missingRoot = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['See the [PRD folder](../prd/) for background.']), }); // No docs/prd/ created here — the directory target does not exist. const { status, report } = runJson(missingRoot, ['--check']); assert.equal(status, 1, 'a nonexistent directory target must fail'); assert.ok( report.violations.some((v) => v.reason === REASON.LINK_UNRESOLVED && v.target === '../prd/'), `expected a link_unresolved violation for target ../prd/; got ${JSON.stringify(report.violations)}`, ); }); test('absolute destinations are out of scope', (t) => { const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], [ 'See [docs](https://example.com/nonexistent) and [http](http://example.com/x)', 'and [mail](mailto:nobody@example.com) and [proto-rel](//example.com/x).', ]), }); assert.equal(run(root, ['--write']).status, 0); const res = run(root, ['--check']); assert.equal(res.status, 0, `URI-scheme/protocol-relative destinations must be skipped: ${res.stderr}`); }); test('a same-document anchor is not a file reference', (t) => { const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['See [context](#context) above.']), }); assert.equal(run(root, ['--write']).status, 0); assert.equal(run(root, ['--check']).status, 0); }); test('a fragment is stripped before resolution', (t) => { const okRoot = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['See [Beta](900-beta.md#context) for detail.']), '900-beta.md': adr('Beta', ['**Status:** Accepted']), }); assert.equal(run(okRoot, ['--write']).status, 0); assert.equal(run(okRoot, ['--check']).status, 0, 'the file exists — only the fragment is unresolved (and ignored)'); const missingRoot = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['See [Ghost](ghost.md#context) for detail.']), }); const { status, report } = runJson(missingRoot, ['--check']); assert.equal(status, 1, 'the file does not exist, fragment or not'); assert.ok( report.violations.some((v) => v.reason === REASON.LINK_UNRESOLVED && v.target === 'ghost.md#context'), `expected a link_unresolved violation naming ghost.md; got ${JSON.stringify(report.violations)}`, ); }); test('an empty destination is skipped', (t) => { const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], [ 'Empty: [t]().', 'Whitespace-only: [t]( ).', ]), }); assert.equal(run(root, ['--write']).status, 0); const res = run(root, ['--check']); assert.equal(res.status, 0, `empty/whitespace-only destinations must never resolve to the ADR dir: ${res.stderr}`); }); test('a link inside a fenced code block is code, not a link', (t) => { const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], [ '```', 'See [Ghost](ghost.md) inside a backtick fence.', '```', '', '~~~', 'See [Ghost2](ghost2.md) inside a tilde fence.', '~~~', ]), }); assert.equal(run(root, ['--write']).status, 0); const res = run(root, ['--check']); assert.equal(res.status, 0, `links inside fenced code must not be checked: ${res.stderr}`); }); test('a link inside an inline code span is code, not a link', (t) => { const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['Inline: `[Ghost](ghost.md)` is code, not a link.']), }); assert.equal(run(root, ['--write']).status, 0); const res = run(root, ['--check']); assert.equal(res.status, 0, `a link inside an inline code span must not be checked: ${res.stderr}`); }); test('the two code shapes present in the real corpus produce no findings', (t) => { const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], [ '```text', 'mod[entry.router]({ args, cwd, raw, error })', '```', '', 'Inline: `require(module)[router]()` explained here.', ]), }); assert.equal(run(root, ['--write']).status, 0); const res = run(root, ['--check']); assert.equal(res.status, 0, `the real corpus's bracket-after-identifier shapes must not be misread as links: ${res.stderr}`); }); test('a dangling image target fails', (t) => { const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['![diagram](missing.png)']), }); const { status, report } = runJson(root, ['--check']); assert.equal(status, 1, 'an image with a dangling target must fail like any link'); assert.ok( report.violations.some((v) => v.reason === REASON.LINK_UNRESOLVED && v.target === 'missing.png'), `expected a link_unresolved violation for missing.png; got ${JSON.stringify(report.violations)}`, ); }); test('angle-bracket and titled destinations', (t) => { // 'ref/a b.md' lives under a subdirectory: readdirSync(ADR_DIR) is // non-recursive, so this never trips the `-.md` naming check. const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], [ 'See [Beta]() for detail.', 'See [Gamma](900-gamma.md "The Gamma decision") too.', ]), '900-gamma.md': adr('Gamma', ['**Status:** Accepted']), }); fs.mkdirSync(path.join(root, 'docs', 'adr', 'ref'), { recursive: true }); fs.writeFileSync(path.join(root, 'docs', 'adr', 'ref', 'a b.md'), '# scratch\n'); assert.equal(run(root, ['--write']).status, 0); const res = run(root, ['--check']); assert.equal(res.status, 0, `angle-bracket and titled destinations must resolve: ${res.stderr}`); }); test('percent-encoded destinations decode', (t) => { const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], [ 'See [Beta](ref/a%20b.md) for detail.', 'See [Ghost](ref/a%zz.md) too.', ]), }); fs.mkdirSync(path.join(root, 'docs', 'adr', 'ref'), { recursive: true }); fs.writeFileSync(path.join(root, 'docs', 'adr', 'ref', 'a b.md'), '# scratch\n'); // A malformed percent-escape must not crash the process: `runJson` asserts // the parse succeeded, which itself proves stdout carried a real JSON // document rather than a stack trace from an uncaught TypeError/URIError. const { status, report } = runJson(root, ['--check']); assert.equal(status, 1, `a%20b.md must decode and resolve; a%zz.md is malformed and must not resolve: ${JSON.stringify(report)}`); assert.ok( !report.violations.some((v) => v.target === 'ref/a%20b.md'), 'the percent-encoded-but-valid target must not itself be reported', ); assert.ok( report.violations.some((v) => v.reason === REASON.LINK_UNRESOLVED && v.target === 'ref/a%zz.md'), `the malformed escape must be reported using its raw text; got ${JSON.stringify(report.violations)}`, ); }); test('a root-relative destination resolves against the repo root', (t) => { // '/docs/prd/plan.md' exists ONLY relative to the repo root — resolving it // relative to docs/adr/ instead (docs/adr/docs/prd/plan.md) would not exist, // so this discriminates the two interpretations rather than only proving // "some" interpretation works. const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['See [Repo file](/docs/prd/plan.md).']), }); fs.mkdirSync(path.join(root, 'docs', 'prd'), { recursive: true }); fs.writeFileSync(path.join(root, 'docs', 'prd', 'plan.md'), '# plan\n'); assert.equal(run(root, ['--write']).status, 0); const res = run(root, ['--check']); assert.equal(res.status, 0, `a root-relative destination must resolve against the repo root: ${res.stderr}`); }); test('a destination escaping the repository is rejected without touching the filesystem', (t) => { // IR altitude first: extractLinks must capture the raw traversal target // verbatim — no early resolution/mangling before the CLI-level escape // guard gets a chance to reject it. const source = 'See [Ghost](../../../../../etc/passwd) for context.\n'; assert.deepEqual(extractLinks(source), [{ line: 1, target: '../../../../../etc/passwd' }]); // Gate-verdict altitude: the CLI must reject the escape outright. const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['See [Ghost](../../../../../etc/passwd) for context.']), }); const { status, report } = runJson(root, ['--check']); assert.equal(status, 1, 'a link escaping the repository root must fail'); assert.ok( report.violations.some((v) => v.reason === REASON.LINK_ESCAPES_REPO && v.target === '../../../../../etc/passwd'), `expected a link_escapes_repo violation for the traversal target; got ${JSON.stringify(report.violations)}`, ); // The escape-vs-unresolved discrimination is now two distinct reason // codes, not two prose patterns — proof the escape guard runs BEFORE any // generic resolution attempt. assert.ok( !report.violations.some((v) => v.reason === REASON.LINK_UNRESOLVED), 'a repo-escaping target must get link_escapes_repo, never the generic link_unresolved reason', ); }); test('a repo-root path whose first segment starts with two dots is not an escape', (t) => { // `path.relative(ROOT, abs).startsWith('..')` alone would ALSO match an // in-repo path whose first segment merely begins with two dots — a // legitimate root-level file named `..hidden.md`. docs/adr/ is two // segments below ROOT, so '../..' walks docs/adr -> docs -> ROOT, landing // squarely inside the repo: path.relative(ROOT, ROOT/'..hidden.md') is // exactly '..hidden.md', which starts with '..' but does not escape. const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['See [Hidden](../../..hidden.md) for context.']), }); fs.writeFileSync(path.join(root, '..hidden.md'), '# hidden\n'); assert.equal( path.relative(root, path.join(root, '..hidden.md')), '..hidden.md', 'fixture sanity check: the resolved relative path must literally start with two dots', ); assert.equal(run(root, ['--write']).status, 0); const { status, report } = runJson(root, ['--check']); assert.equal(status, 0, `a same-segment-prefix path must not be misclassified as escaping: ${JSON.stringify(report)}`); assert.ok(report.ok, 'a same-segment-prefix path must produce a clean report, not an escape violation'); }); test('resolution is case-exact', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']), '900-beta.md': adrBody('Beta', ['**Status:** Accepted'], ['See [Alpha](0001-ALPHA.md) for detail.']), }); // Deliberately no platform guard: case-exactness must hold identically on // every OS, including case-insensitive filesystems (macOS default, Windows), // where a naive fs.existsSync(...) would silently resolve and hide this. const { status, report } = runJson(root, ['--check']); assert.equal(status, 1, 'a case-mismatched target must fail on every platform, not just case-sensitive ones'); assert.ok( report.violations.some((v) => v.reason === REASON.LINK_UNRESOLVED && v.target === '0001-ALPHA.md'), `expected a link_unresolved violation for the case-mismatched target; got ${JSON.stringify(report.violations)}`, ); }); test('every occurrence is reported, not just the first', (t) => { const root = makeRepo(t, { // Lines: 1 '# Alpha' .. 6 '', 7 First, 8 Between, 9 Second, 10 trailing ''. '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], [ 'First: [Ghost](ghost.md).', 'Between.', 'Second: [Ghost again](ghost.md).', ]), }); const { status, report } = runJson(root, ['--check']); assert.equal(status, 1); const ghostFindings = report.violations.filter((v) => v.reason === REASON.LINK_UNRESOLVED && v.target === 'ghost.md'); assert.ok(ghostFindings.length >= 2, `both dangling occurrences must be reported; violations:\n${JSON.stringify(report.violations)}`); assert.ok(ghostFindings.some((v) => v.line === 7), 'first occurrence must report its own line'); assert.ok(ghostFindings.some((v) => v.line === 9), 'second occurrence must report its own line'); }); test('only the unresolvable link on a mixed line is reported', (t) => { const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['See [Beta](900-beta.md) and [Ghost](ghost.md) together.']), '900-beta.md': adr('Beta', ['**Status:** Accepted']), }); const { status, report } = runJson(root, ['--check']); assert.equal(status, 1); assert.ok( !report.violations.some((v) => v.target === '900-beta.md'), 'the resolving link must not be reported', ); assert.ok( report.violations.some((v) => v.reason === REASON.LINK_UNRESOLVED && v.target === 'ghost.md' && v.line === 7), `the unresolvable link must be reported on its own line; got ${JSON.stringify(report.violations)}`, ); }); test('CRLF input yields the same findings as LF', () => { const lfLines = ['# Alpha', '', 'See [Ghost](ghost.md) here.', '', 'More [Also](also.md) text.']; const lfLinks = extractLinks(lfLines.join('\n')); const crlfLinks = extractLinks(lfLines.join('\r\n')); assert.deepEqual(crlfLinks, lfLinks, 'CRLF and LF twins must yield identical {line, target} findings'); }); test('a dangling link in README.md is caught', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']) }); // makeRepo writes its own bare README.md — this test supplies real prose so // it can carry a dangling link. fs.writeFileSync( path.join(root, 'docs', 'adr', 'README.md'), ['# ADRs', '', 'See [the process doc](process.md) for how ADRs are written.', '', '## Index', '', START, END, ''].join('\n'), ); const { status, report } = runJson(root, ['--check']); assert.equal(status, 1, 'a dangling link in README.md must be caught'); assert.ok( report.violations.some((v) => v.reason === REASON.LINK_UNRESOLVED && v.file === 'README.md' && v.target === 'process.md'), `expected a link_unresolved violation naming README.md's dangling link; got ${JSON.stringify(report.violations)}`, ); }); test('a non-conforming filename is still link-checked', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']), 'notes.md': ['# Scratch notes', '', 'Not an ADR, but see [Ghost](ghost.md) anyway.', ''].join('\n'), }); const { status, report } = runJson(root, ['--check']); assert.equal(status, 1); assert.ok( report.violations.some((v) => v.reason === REASON.FILENAME_INVALID && v.file === 'notes.md'), `the existing naming violation must still be reported; got ${JSON.stringify(report.violations)}`, ); assert.ok( report.violations.some((v) => v.reason === REASON.LINK_UNRESOLVED && v.file === 'notes.md' && v.target === 'ghost.md'), `the dangling link must ALSO be reported; got ${JSON.stringify(report.violations)}`, ); }); test('the real corpus passes the new assertions', () => { const res = run(REPO_ROOT, ['--check']); assert.equal(res.status, 0, `docs/adr/ must satisfy the link-resolution and bracket-parity gates:\n${res.stderr}`); }); // ── Link resolution — IR altitude (fence/inline-span boundaries, hostile input) ── test('fence marker length 2/3/4', () => { // 2-backtick run: NOT a fence — a dangling link after it must still be found. const two = ['``', 'text', '``', '[Ghost](ghost.md)'].join('\n'); assert.deepEqual(extractLinks(two), [{ line: 4, target: 'ghost.md' }], '2 backticks do not open a fence'); // 3-backtick run: IS a fence — its contents (including a link) are masked. const three = ['```', '[Ghost](ghost.md)', '```'].join('\n'); assert.deepEqual(extractLinks(three), [], '3 backticks open a real fence'); // 4-backtick run closed by only 3: still open — a shorter run cannot close it. const four = ['````', '[Ghost](ghost.md)', '```', 'still inside the fence', '[Ghost2](ghost2.md)', '````'].join('\n'); assert.deepEqual(extractLinks(four), [], 'a 4-run fence is not closed by a 3-run'); }); test('a fence closes only on its own marker kind', () => { const mixed = [ '```', '[Ghost](ghost.md)', '~~~', 'still fenced — ~~~ does not close a ``` fence', '[Ghost2](ghost2.md)', '```', '[After](after.md)', ].join('\n'); assert.deepEqual(extractLinks(mixed), [{ line: 7, target: 'after.md' }], 'only the real close (```) ends the fence'); }); test('an unterminated fence swallows the remainder without crashing', () => { const text = ['```', '[Ghost](ghost.md)', 'never closed', '[Ghost2](ghost2.md)'].join('\n'); let links; assert.doesNotThrow(() => { links = extractLinks(text); }); assert.deepEqual(links, [], 'an unterminated fence masks the rest of the file — no findings, no crash'); }); test('inline code spans close on an equal backtick run', () => { const oneRun = 'a `[Ghost](ghost.md)` b [Real](real.md)'; assert.deepEqual(extractLinks(oneRun), [{ line: 1, target: 'real.md' }], 'a 1-backtick span closes on the next 1-run'); const twoRun = 'a ``[Ghost](ghost.md)`` b [Real](real.md)'; assert.deepEqual(extractLinks(twoRun), [{ line: 1, target: 'real.md' }], 'a 2-backtick span closes on the next 2-run'); }); test('link text with nested brackets is skipped, not misreported', () => { let links; assert.doesNotThrow(() => { links = extractLinks('[see [1]](x.md)'); }); assert.deepEqual(links, [], 'nested brackets in link text are out of the inline-links-only scope'); }); test('regex character classes in prose are not links', () => { const text = 'Use `[A-Z][A-Z0-9_]` for constants and [a-z0-9][a-z0-9-] for slugs.'; assert.deepEqual(extractLinks(text), [], 'bracket-adjacent-bracket regex-class prose must not be read as markdown links'); }); test('an empty ADR file produces no link findings', (t) => { // IR altitude: no text at all yields no links (not even a crash). assert.deepEqual(extractLinks(''), []); // Gate-verdict altitude: the existing "no Status field" rule still fires for // a 0-byte file, but no spurious link-resolution finding piggybacks on it. const root = makeRepo(t, { '0001-alpha.md': '' }); const res = run(root, ['--check']); assert.equal(res.status, 1); assert.match(res.stderr, /no `- \*\*Status:\*\* ` field/); assert.doesNotMatch(res.stderr, /does not resolve|escapes the repository/, 'a 0-byte file must not also report a link finding'); }); test('property: extractLinks is total and reports in-range lines', () => { fc.assert( fc.property( fc.oneof( fc.string({ maxLength: 300 }), fc.string({ unit: 'grapheme-composite', maxLength: 300 }), fc.string({ unit: 'binary', maxLength: 300 }), ), (text) => { let links; assert.doesNotThrow(() => { links = extractLinks(text); }, `extractLinks threw on: ${JSON.stringify(text).slice(0, 120)}`); const lineCount = text.split(/\r?\n/).length; for (const { line } of links) { assert.ok(line >= 1 && line <= lineCount, `line ${line} out of range [1, ${lineCount}]`); } }, ), { seed: 2704, numRuns: 200 }, ); }); test('property: masking preserves length and line structure', () => { fc.assert( fc.property( fc.oneof( fc.string({ maxLength: 300 }), fc.string({ unit: 'grapheme-composite', maxLength: 300 }), fc.string({ unit: 'binary', maxLength: 300 }), ), (text) => { let masked; assert.doesNotThrow(() => { masked = maskCode(text); }, `maskCode threw on: ${JSON.stringify(text).slice(0, 120)}`); assert.equal(masked.length, text.length, 'masked output must be the same length as input'); for (let i = 0; i < text.length; i++) { if (text[i] === '\n') assert.equal(masked[i], '\n', `newline at index ${i} must survive masking`); } }, ), { seed: 2704, numRuns: 200 }, ); }); // ── H1 status bracket vs Status: field — gate-verdict altitude ────────────── test('an agreeing H1 bracket passes and is still stripped from the title', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Title one [Accepted]', ['**Status:** Accepted']) }); assert.equal(run(root, ['--write']).status, 0); const check = run(root, ['--check']); assert.equal(check.status, 0, `an agreeing bracket must pass: ${check.stderr}`); const readme = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8'); assert.doesNotMatch(readme, /\[Accepted\]/, 'the bracket must not survive into the rendered title'); assert.match(readme, /Title one/); }); test('an H1 bracket contradicting Status fails and names both', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Title one [Proposed]', ['**Status:** Accepted']) }); const { status, report } = runJson(root, ['--check']); assert.equal(status, 1); assert.ok( report.violations.some( (v) => v.reason === REASON.STATUS_BRACKET_MISMATCH && v.file === '0001-alpha.md' && v.actual === 'Proposed' && v.expected === 'Accepted', ), `expected a status_bracket_mismatch violation naming both tokens; got ${JSON.stringify(report.violations)}`, ); }); test('bracket comparison is case-insensitive', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Title one [proposed]', ['**Status:** Proposed']) }); assert.equal(run(root, ['--write']).status, 0); assert.equal(run(root, ['--check']).status, 0, 'a differently-cased but agreeing bracket must pass'); }); test('a bracket agreeing with a prose-carrying Status passes', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Title one [Superseded]', ['**Status:** Superseded by [ADR-900](900-beta.md)']), '900-beta.md': adr('Title two', ['**Status:** Accepted', '**Supersedes:** [ADR-0001](0001-alpha.md)']), }); assert.equal(run(root, ['--write']).status, 0); const check = run(root, ['--check']); assert.equal(check.status, 0, `a bracket agreeing with the parsed status TOKEN must pass: ${check.stderr}`); }); test('a non-status trailing bracket is title text, not a claim', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Title one [Draft]', ['**Status:** Accepted']), '900-beta.md': adr('Title two [ADR-0001](0001-alpha.md)', ['**Status:** Accepted']), }); assert.equal(run(root, ['--write']).status, 0); const check = run(root, ['--check']); assert.equal(check.status, 0, `a non-vocabulary bracket and a link-shaped H1 suffix are both title text: ${check.stderr}`); const readme = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8'); assert.match(readme, /\[Draft\]/, '[Draft] is title text and must survive into the rendered title'); }); test('a missing Status field does not also report bracket disagreement', (t) => { const root = makeRepo(t, { '0001-alpha.md': '# Title one [Accepted]\n\nNo header fields.\n\n## Context\n\nBody.\n' }); const { status, report } = runJson(root, ['--check']); assert.equal(status, 1); assert.ok( report.violations.some((v) => v.reason === REASON.STATUS_MISSING && v.file === '0001-alpha.md'), `expected a status_missing violation; got ${JSON.stringify(report.violations)}`, ); assert.ok( !report.violations.some((v) => v.reason === REASON.STATUS_BRACKET_MISMATCH), 'a missing Status field must not ALSO get a status_bracket_mismatch violation', ); }); test('an invalid Status token is not also reported as a bracket disagreement', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Title one [Accepted]', ['**Status:** Draft']) }); const { status, report } = runJson(root, ['--check']); assert.equal(status, 1); assert.ok( report.violations.some((v) => v.reason === REASON.STATUS_INVALID && v.status === 'Draft'), `expected a status_invalid violation naming "Draft"; got ${JSON.stringify(report.violations)}`, ); assert.ok( !report.violations.some((v) => v.reason === REASON.STATUS_BRACKET_MISMATCH), 'an invalid status token must not ALSO get a status_bracket_mismatch violation', ); }); test('an ADR with no H1 is skipped', (t) => { const root = makeRepo(t, { '0001-alpha.md': '- **Status:** Accepted\n\n## Context\n\nBody with no heading line at all.\n' }); assert.equal(run(root, ['--write']).status, 0); const res = run(root, ['--check']); assert.equal(res.status, 0, `no H1 means no bracket to compare — must not error: ${res.stderr}`); }); test('both real H1 spellings are compared', (t) => { const root = makeRepo(t, { '1143-one.md': '# ADR-1143: Title one [Proposed]\n\n- **Status:** Accepted\n\n## Context\n\nBody.\n', '1606-two.md': '# ADR 1606: title two [Proposed]\n\n- **Status:** Accepted\n\n## Context\n\nBody.\n', }); const { status, report } = runJson(root, ['--check']); assert.equal(status, 1); assert.ok( report.violations.some((v) => v.reason === REASON.STATUS_BRACKET_MISMATCH && v.file === '1143-one.md'), `expected a status_bracket_mismatch violation for 1143-one.md; got ${JSON.stringify(report.violations)}`, ); assert.ok( report.violations.some((v) => v.reason === REASON.STATUS_BRACKET_MISMATCH && v.file === '1606-two.md'), `expected a status_bracket_mismatch violation for 1606-two.md; got ${JSON.stringify(report.violations)}`, ); }); /** * Some status tokens carry obligations beyond "the H1 bracket must agree * with the Status field" — e.g. `Superseded` also requires a successor * named as a markdown link, symmetrically recorded on both sides (see * gen-adr-index.cjs's dedicated Superseded check). The bracket-parity test * below wants to exercise ONLY bracket-vs-Status agreement, so whatever * status a fixture DECLARES must independently satisfy that status's own * obligations — otherwise the corpus fails for an unrelated, pre-existing * reason and the test reports the wrong defect (exactly what happened here: * a bare "Superseded" Status field with no successor tripped the * "names no successor" check before bracket comparison ever mattered). * * Keyed by status token, not hardcoded into the test body, so a FUTURE * token with its own obligation is forced through this same seam instead of * silently reusing the bare-token fixture and reporting a misleading * failure. */ function statusObligations(token) { if (token === 'Superseded') { return { statusField: 'Superseded by [ADR-900](900-beta.md)', companions: { '900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** [ADR-0001](0001-alpha.md)']), }, }; } return { statusField: token, companions: {} }; } test('parity: every status in the vocabulary is recognized as a bracket', (t) => { // DEFECT.GENERATIVE-FIX guard: this iterates the REAL exported STATUSES // array instead of a hand-copied literal, so a 6th status added to the // vocabulary is covered by this test the day it lands, not the next time // someone remembers to update a parallel hardcoded list here. assert.ok(Array.isArray(STATUSES) && STATUSES.length > 0, 'STATUSES must be a real exported, non-empty array'); for (const token of STATUSES) { const own = statusObligations(token); const agreeing = makeRepo(t, { '0001-alpha.md': adr(`Title one [${token}]`, [`**Status:** ${own.statusField}`]), ...own.companions, }); assert.equal(run(agreeing, ['--write']).status, 0, `token "${token}": --write must succeed`); const agreeingCheck = run(agreeing, ['--check']); assert.equal(agreeingCheck.status, 0, `token "${token}": an agreeing bracket must pass: ${agreeingCheck.stderr}`); const other = STATUSES.find((s) => s !== token); // The contradicting fixture DECLARES `other` in the Status field, so it // is `other`'s obligations (not `token`'s) that the corpus must satisfy. const otherObligations = statusObligations(other); const contradicting = makeRepo(t, { '0001-alpha.md': adr(`Title one [${token}]`, [`**Status:** ${otherObligations.statusField}`]), ...otherObligations.companions, }); assert.equal(run(contradicting, ['--write']).status, 0, `token "${token}" vs "${other}": --write must succeed even with violations`); const { status: contradictingStatus, report: contradictingReport } = runJson(contradicting, ['--check']); assert.equal(contradictingStatus, 1, `token "${token}" vs "${other}": a contradicting bracket must fail`); assert.ok( contradictingReport.violations.some( (v) => v.reason === REASON.STATUS_BRACKET_MISMATCH && v.actual === token && v.expected === other, ), `token "${token}" vs "${other}": expected a status_bracket_mismatch violation with actual="${token}" expected="${other}"; got ${JSON.stringify(contradictingReport.violations)}`, ); } }); test('the generated index is byte-identical to the pre-change output', (t) => { // Build the SAME corpus twice — once with an agreeing bracket in the H1, // once without — and assert the rendered README region is identical either // way. The bracket is compared, not rendered: adding the comparison must // not move a single byte of the generated index. const withBracket = makeRepo(t, { '0001-alpha.md': adr('Title one [Accepted]', ['**Status:** Accepted']) }); assert.equal(run(withBracket, ['--write']).status, 0); const readmeWith = fs.readFileSync(path.join(withBracket, 'docs', 'adr', 'README.md'), 'utf8'); const withoutBracket = makeRepo(t, { '0001-alpha.md': adr('Title one', ['**Status:** Accepted']) }); assert.equal(run(withoutBracket, ['--write']).status, 0); const readmeWithout = fs.readFileSync(path.join(withoutBracket, 'docs', 'adr', 'README.md'), 'utf8'); assert.equal(readmeWith, readmeWithout, 'an agreeing H1 bracket must not change a single byte of the generated index'); }); // ─── Isolated adversarial security review, #2704 follow-up ───────────────── // // F1/F2 (BLOCKER, live PoC): `validateLinks` checks containment LEXICALLY // (`path.relative(ROOT, abs)`), which is correct as far as it goes. But // `existsCaseExact` then walks path segments via `readdirSync`, which // FOLLOWS symlinks at the OS level. A contributor can commit // `docs/adr/x -> /etc/somewhere-outside` plus an ADR linking // `[t](x/passwd)`: the lexical check sees `docs/adr/x/passwd` (looks // repo-internal), and the walk then lists the real external directory — and // a wrong-case probe echoes a real filename from OUTSIDE the repo into // PUBLIC CI LOGS on a fork PR via the "Did you mean X?" hint. Proven live by // the reviewer. // // F4 (MINOR): an unreadable `*.md` dirent (broken symlink) previously threw // `ENOENT` out of `markdownFilesInAdrDir`'s `statSync`, and `runMain` wrote // the raw `err.stack` — absolute host filesystem paths — to public fork-PR // CI logs. // // F3 (MAJOR): `maskInlineCodeSpans`'s correctness under adversarial input is // covered separately below; see that test's comment. describe('symlink escape guard (F1/F2)', () => { /** * fs.symlinkSync can fail with EPERM on a platform/host that forbids * unprivileged symlink creation (notably Windows without Developer Mode or * admin rights). `t.skip()` degrades cleanly there; a bare `return` would * silently report a PASS in node:test and hide the gap this guard exists * to close. */ function trySymlink(t, target, linkPath, type) { try { fs.symlinkSync(target, linkPath, type); return true; } catch (err) { if (err && err.code === 'EPERM') { t.skip('cannot create symlinks on this platform (EPERM)'); return false; } throw err; } } test('a link traversing a symlink out of the repository is rejected', (t) => { const outside = createTempDir('gsd-adr-index-outside-'); t.after(() => cleanup(outside)); fs.writeFileSync(path.join(outside, 'secret.txt'), 'do not leak me\n'); const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['See [t](x/secret.txt) for context.']), }); if (!trySymlink(t, outside, path.join(root, 'docs', 'adr', 'x'), 'dir')) return; const { status, report } = runJson(root, ['--check']); assert.equal(status, 1, 'a link traversing an out-of-repo symlink must fail --check'); assert.ok( report.violations.some((v) => v.reason === REASON.LINK_ESCAPES_REPO_SYMLINK && v.target === 'x/secret.txt'), `expected a link_escapes_repo_symlink violation, not an ordinary dangling link; got ${JSON.stringify(report.violations)}`, ); assert.ok( !report.violations.some((v) => v.reason === REASON.LINK_UNRESOLVED), 'a symlink escape must get its own reason code, never the generic link_unresolved one', ); }); test('a symlink out of the repository never leaks a filename hint', (t) => { const outside = createTempDir('gsd-adr-index-outside-'); t.after(() => cleanup(outside)); fs.writeFileSync(path.join(outside, 'secret.txt'), 'do not leak me\n'); const root = makeRepo(t, { // Wrong case: on a naive implementation this would trigger a // "Did you mean secret.txt?" hint built from the OUTSIDE directory's // real listing — the disclosure this test guards against. '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['See [t](x/SECRET.TXT) for context.']), }); if (!trySymlink(t, outside, path.join(root, 'docs', 'adr', 'x'), 'dir')) return; const { status, report } = runJson(root, ['--check']); assert.equal(status, 1); // A stronger guarantee than the old stderr-prose check: no field of any // violation record — not just a hand-picked message string — may carry // the sentinel filename from OUTSIDE the repository. The ADR's own link // target is deliberately case-DIFFERENT ('x/SECRET.TXT') from the real // outside file ('secret.txt'), so this exact-case search cannot // false-positive on the requested-target text, only on a genuine leak // (e.g. a "Did you mean secret.txt?" hint built from the real listing). assert.equal( JSON.stringify(report).includes('secret.txt'), false, `the real external filename must never be echoed into the report: ${JSON.stringify(report)}`, ); }); test('a symlink that stays inside the repository still resolves', (t) => { const root = makeRepo(t, { '0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], ['See [t](inside/target.md) for context.']), }); const insideTarget = path.join(root, 'internal-target'); fs.mkdirSync(insideTarget, { recursive: true }); fs.writeFileSync(path.join(insideTarget, 'target.md'), '# scratch\n'); if (!trySymlink(t, insideTarget, path.join(root, 'docs', 'adr', 'inside'), 'dir')) return; assert.equal(run(root, ['--write']).status, 0); const res = run(root, ['--check']); assert.equal(res.status, 0, `an in-repo symlink must not be misclassified as an escape: ${res.stderr}`); }); // Same containment rule this describe block enforces for link TARGETS // (`x/secret.txt` above) applies to the ADR FILES themselves: // `markdownFilesInAdrDir` used `fs.statSync`, which follows symlinks, so a // `docs/adr/*.md` symlinked out of the repo was accepted as an ADR and had // its full body read by `parseAdr` and scanned for links by // `validateLinks` — echoing fragments of an arbitrary outside file into // public stderr on a fork PR. test('an ADR file that is a symlink out of the repository is not read', (t) => { const outside = createTempDir('gsd-adr-index-outside-'); t.after(() => cleanup(outside)); fs.writeFileSync( path.join(outside, 'evil-source.md'), adrBody('Evil', ['**Status:** Accepted'], ['SENTINEL-OUTSIDE-CONTENT', 'See [x](sentinel-target.md) for context.']), ); const root = makeRepo(t, {}); if ( !trySymlink(t, path.join(outside, 'evil-source.md'), path.join(root, 'docs', 'adr', '0002-evil.md'), 'file') ) { return; } const { status, report } = runJson(root, ['--check']); assert.equal(status, 1, 'an ADR file symlinked out of the repository must fail the gate'); assert.ok( report.violations.some((v) => v.reason === REASON.DIRENT_ESCAPES_REPO_SYMLINK && v.file === '0002-evil.md'), `expected a dirent_escapes_repo_symlink violation naming 0002-evil.md, distinct from the broken/unreadable reason; got ${JSON.stringify(report.violations)}`, ); assert.equal( JSON.stringify(report).includes('SENTINEL-OUTSIDE-CONTENT'), false, 'must never read or echo content from the file outside the repository', ); assert.equal( JSON.stringify(report).includes('sentinel-target.md'), false, 'must never echo a link target found only inside the unread outside file', ); }); test('an ADR file that is a symlink inside the repository is still read', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Alpha module', ['**Status:** Accepted']), }); if ( !trySymlink( t, path.join(root, 'docs', 'adr', '0001-alpha.md'), path.join(root, 'docs', 'adr', '0003-alias.md'), 'file', ) ) { return; } const write = run(root, ['--write']); assert.equal(write.status, 0, `a legitimate in-repo symlinked ADR file must still be read: ${write.stderr}`); }); }); test('a broken symlink under docs/adr is reported, not a crash (F4)', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']) }); try { fs.symlinkSync(path.join(root, 'does-not-exist.md'), path.join(root, 'docs', 'adr', 'ghost.md'), 'file'); } catch (err) { if (err && err.code === 'EPERM') { t.skip('cannot create symlinks on this platform (EPERM)'); return; } throw err; } // runJson itself asserts stdout parses as JSON: a raw stack trace or a // surfaced fs error (TypeError/ENOENT) would either corrupt stdout or // leave it empty, so the parse succeeding is already proof this is a // typed gate violation, not a crash — no separate stderr pattern needed. const { status, report } = runJson(root, ['--check']); assert.equal(status, 1, 'a broken symlink under docs/adr must fail the gate, not crash it'); assert.ok( report.violations.some((v) => v.reason === REASON.DIRENT_UNREADABLE && v.file === 'ghost.md'), `expected a dirent_unreadable violation naming ghost.md; got ${JSON.stringify(report.violations)}`, ); }); test('masking an adversarial backtick line stays fast (F3)', () => { // ~2000 backtick runs of strictly ascending length, none of which ever // closes (every length is unique) — the exact shape that forced the old // per-opener rescan implementation into near-quadratic time (measured // 34ms/50KB -> 220ms/200KB -> 1.76s/800KB, unbounded). This test asserts // correctness, not timing (this repo forbids wall-clock assertions in // tests) — the linear rewrite's speed is verified separately, out of band. const N = 2000; let hostile = ''; for (let n = 1; n <= N; n += 1) hostile += '`'.repeat(n) + 'x'; const text = `Intro line.\n${hostile}\nSee [Real](real.md) after the pathological section.\n`; let masked; assert.doesNotThrow(() => { masked = maskCode(text); }, 'masking the adversarial line must not throw'); assert.equal(masked.length, text.length, 'masked output must be the same length as input'); for (let i = 0; i < text.length; i += 1) { if (text[i] === '\n') assert.equal(masked[i], '\n', `newline at index ${i} must survive masking`); } const links = extractLinks(text); assert.deepEqual( links.map((l) => l.target), ['real.md'], 'the link after the pathological backtick section must still be found', ); }); // ── --json structured output surface ──────────────────────────────────── test('the REASON enum is locked', () => { // Adding a violation class is a deliberate three-part change: a new entry // here, its emitting `add(...)` call site, and this list growing to match // — never a silent addition that a `--json` consumer discovers by surprise. assert.deepEqual(Object.keys(REASON).sort(), [ 'DIRENT_ESCAPES_REPO_SYMLINK', 'DIRENT_UNREADABLE', 'FILENAME_INVALID', 'ID_MISMATCH', 'LINK_ESCAPES_REPO', 'LINK_ESCAPES_REPO_SYMLINK', 'LINK_UNRESOLVED', 'RELATION_ASYMMETRIC', 'RELATION_BARE_ID_MISSING', 'RELATION_BARE_ID_UNLINKED', 'RELATION_LINK_MISSING', 'STATUS_BRACKET_MISMATCH', 'STATUS_INVALID', 'STATUS_MISSING', 'SUPERSEDED_BARE_ID', 'SUPERSEDED_NO_SUCCESSOR', ]); }); test('an unknown flag is rejected', (t) => { const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']) }); const res = run(root, ['--bogus']); assert.equal(res.status, 1, 'an unrecognized flag must fail closed, not silently fall through'); assert.match(res.stderr, /unknown flag: --bogus/); assert.equal(res.stdout, '', 'the index must NOT be printed when an unrecognized flag is supplied'); }); test('--json emits nothing on stderr and a parseable report on stdout', (t) => { const clean = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']) }); assert.equal(run(clean, ['--write']).status, 0); const cleanRun = run(clean, ['--json']); assert.equal(cleanRun.status, 0, `a clean corpus must exit 0 under --json: ${cleanRun.stderr}`); assert.equal(cleanRun.stderr, '', '--json must write nothing to stderr on a clean corpus'); const cleanReport = JSON.parse(cleanRun.stdout); assert.deepEqual(cleanReport, { ok: true, adrCount: 1, indexStale: false, violations: [] }); const violating = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Draft']) }); const violatingRun = run(violating, ['--json']); assert.equal(violatingRun.status, 1, `a violating corpus must exit 1 under --json: ${violatingRun.stdout}`); assert.equal(violatingRun.stderr, '', '--json must write nothing to stderr on a violating corpus'); const violatingReport = JSON.parse(violatingRun.stdout); assert.equal(violatingReport.ok, false); assert.ok(violatingReport.violations.some((v) => v.reason === REASON.STATUS_INVALID && v.status === 'Draft')); // `--check --json` is identical to `--json` alone. const combined = run(violating, ['--check', '--json']); assert.equal(combined.status, 1); assert.equal(combined.stderr, ''); assert.deepEqual(JSON.parse(combined.stdout), violatingReport); });