Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
1792 lines
85 KiB
JavaScript
1792 lines
85 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 { copyScriptWithDeps } = require('./helpers/copy-script-fixture.cjs');
|
||
const { findTableWithColumns } = require('../msd-core/bin/lib/markdown-table.cjs');
|
||
const { GENERATOR_SCRIPT_TIMEOUT_MS } = require('./helpers/timeouts.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 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('msd-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: GENERATOR_SCRIPT_TIMEOUT_MS,
|
||
});
|
||
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:\*\* <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\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.
|
||
const table = findTableWithColumns(readme, ['ADR', 'Title', 'Status', 'Read first']);
|
||
assert.ok(table, 'the generated README has an ADR/Title/Status/Read first table');
|
||
const row = table.rows.find((r) => r.ADR === '[ADR-0001](0001-alpha.md)');
|
||
assert.ok(row, 'a row for ADR-0001 exists');
|
||
assert.equal(row.Status, 'Accepted');
|
||
assert.equal(row['Read first'], '[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 <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\b/, '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\b/, '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}`);
|
||
});
|
||
|
||
// --- insert-only invariant (#3251) ------------------------------------------
|
||
//
|
||
// The generated region is a shared, committed artifact rewritten by every
|
||
// ADR-adding PR. Two PRs that add different ADRs touch different table rows
|
||
// and merge cleanly — but if adding an ADR ever MODIFIES an existing line
|
||
// (not just appends new ones), two such PRs collide on that line and one
|
||
// lands with a locally-green, CI-red `--check` (exactly what happened when
|
||
// PR #3251's ADR-2313 and #3249's concurrently-landed ADR-3247 both rewrote
|
||
// the same count line). The property that makes concurrent PRs merge is
|
||
// stronger than "no count string is present": it is that render(N) is a
|
||
// strict line-subsequence of render(N+1) for every N. These tests lock that
|
||
// property directly, rather than the one symptom (a specific count format)
|
||
// that happened to trigger #3251.
|
||
|
||
/** Slice the generated region (inclusive of both markers) out of a README, as lines. */
|
||
function indexRegionLines(readme) {
|
||
const start = readme.indexOf(START);
|
||
const end = readme.indexOf(END);
|
||
assert.ok(start !== -1 && end !== -1, 'README must carry both index markers');
|
||
return readme.slice(start, end + END.length).split(/\r?\n/);
|
||
}
|
||
|
||
/**
|
||
* Assert `before` is a strict line-subsequence of `after`: every line of
|
||
* `before`, in order, is also found in `after` in order. That is precisely
|
||
* "adding an ADR only INSERTS lines; it never MODIFIES an existing one" —
|
||
* the property that lets two ADR-adding PRs merge without colliding.
|
||
*
|
||
* On failure, names the first `before` line that could not be matched (and
|
||
* its index) rather than a bare boolean — `assert.ok(isSubsequence)` gives a
|
||
* debugging dead end when a corpus of dozens of lines fails.
|
||
*/
|
||
function assertInsertOnly(before, after, message) {
|
||
let j = 0;
|
||
for (let i = 0; i < before.length; i++) {
|
||
while (j < after.length && after[j] !== before[i]) j++;
|
||
if (j >= after.length) {
|
||
assert.fail(
|
||
`${message}: "before" line ${i} was not found, in order, in "after" — ` +
|
||
`it was modified rather than merely followed by an insertion.\n` +
|
||
` missing line (before[${i}]): ${JSON.stringify(before[i])}`,
|
||
);
|
||
}
|
||
j++; // consume the match so later lines cannot re-match the same slot
|
||
}
|
||
}
|
||
|
||
/** Render the region for a corpus, then write one more file and re-render. */
|
||
function renderBeforeAfter(t, baseFiles, addName, addBody) {
|
||
const root = makeRepo(t, baseFiles);
|
||
assert.equal(run(root, ['--write']).status, 0);
|
||
const before = indexRegionLines(fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8'));
|
||
|
||
fs.writeFileSync(path.join(root, 'docs', 'adr', addName), addBody);
|
||
assert.equal(run(root, ['--write']).status, 0);
|
||
const after = indexRegionLines(fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8'));
|
||
|
||
return { before, after };
|
||
}
|
||
|
||
test('adding an ADR only inserts lines (append position)', (t) => {
|
||
// Highest id: the new row lands at the bottom of an already-populated
|
||
// table. Pre-fix, `### Active decisions (2)` -> `(3)` and the footer
|
||
// `_2 ADRs...` -> `_3 ADRs...` both MODIFY an existing line, so this case
|
||
// fails the subsequence check against the pre-fix generator.
|
||
const { before, after } = renderBeforeAfter(
|
||
t,
|
||
{
|
||
'100-alpha.md': adr('Alpha', ['**Status:** Accepted']),
|
||
'200-beta.md': adr('Beta', ['**Status:** Accepted']),
|
||
},
|
||
'300-gamma.md',
|
||
adr('Gamma', ['**Status:** Accepted']),
|
||
);
|
||
assertInsertOnly(before, after, 'appending the highest-id ADR');
|
||
});
|
||
|
||
test('adding a lowest-id or middle-id ADR is still insert-only', (t) => {
|
||
// Table-driven per the matrix's boundary cases #2 (lowest — the riskiest
|
||
// insertion point, at the very top of the table) and #3 (middle). Same
|
||
// pre-fix failure mode as the append case: the group-heading count and the
|
||
// footer count both change on every insertion, regardless of where the row
|
||
// lands.
|
||
const cases = [
|
||
{ label: 'lowest id (top of the table)', id: '050' },
|
||
{ label: 'middle id (between existing rows)', id: '150' },
|
||
];
|
||
for (const { label, id } of cases) {
|
||
const { before, after } = renderBeforeAfter(
|
||
t,
|
||
{
|
||
'100-alpha.md': adr('Alpha', ['**Status:** Accepted']),
|
||
'300-gamma.md': adr('Gamma', ['**Status:** Accepted']),
|
||
},
|
||
`${id}-beta.md`,
|
||
adr('Beta', ['**Status:** Accepted']),
|
||
);
|
||
assertInsertOnly(before, after, `adding ADR-${id} (${label})`);
|
||
}
|
||
});
|
||
|
||
test('the first ADR in an empty corpus inserts a whole group block', (t) => {
|
||
// limit-1 -> limit: before has NO group blocks at all (every group's row
|
||
// count is 0, so `renderIndex` emits only the markers and the footer).
|
||
// Pre-fix, the footer itself carries the only count (`_0 ADRs...` ->
|
||
// `_1 ADRs...`), which is a MODIFIED line, not an insertion — so this case
|
||
// fails against the pre-fix generator even though no group heading exists
|
||
// yet to change.
|
||
const { before, after } = renderBeforeAfter(t, {}, '100-alpha.md', adr('Alpha', ['**Status:** Accepted']));
|
||
assertInsertOnly(before, after, 'first ADR in an empty corpus');
|
||
});
|
||
|
||
test('adding the first ADR of a new status group leaves other groups untouched', (t) => {
|
||
const { before, after } = renderBeforeAfter(
|
||
t,
|
||
{ '100-alpha.md': adr('Alpha', ['**Status:** Accepted']) },
|
||
'200-beta.md',
|
||
adr('Beta', ['**Status:** Proposed']),
|
||
);
|
||
assertInsertOnly(before, after, 'adding the first Proposed ADR alongside an Accepted-only corpus');
|
||
|
||
// Stronger than insert-only: the whole Active decisions block (heading
|
||
// through its own trailing blank line) must be byte-identical, since the
|
||
// new Proposed section is inserted strictly after it, never inside it.
|
||
// Pre-fix, `### Active decisions (1)` would itself be a line INSIDE this
|
||
// block that survives unchanged here (the Proposed group is what's new,
|
||
// not Active's row count) — the real pre-fix failure for this case is the
|
||
// footer's total count, which sits after both blocks.
|
||
const footerIdx = before.findIndex((l) => l.startsWith('_Generated by'));
|
||
assert.notEqual(footerIdx, -1, 'before render must carry the footer line');
|
||
assert.deepEqual(
|
||
after.slice(0, footerIdx),
|
||
before.slice(0, footerIdx),
|
||
'the Active decisions block must be byte-identical after adding a Proposed ADR',
|
||
);
|
||
});
|
||
|
||
test('superseding an ADR may edit its row, but never a count line', (t) => {
|
||
// The one case in the matrix flagged as a possible exception to strict
|
||
// insert-only: a new Superseded ADR naming an existing Accepted ADR as its
|
||
// successor. Empirically (tracing parseAdr/renderIndex) it is NOT actually
|
||
// an exception here: every row's cells are derived solely from that ADR's
|
||
// OWN header text, so adding a file that talks ABOUT Alpha cannot alter
|
||
// Alpha's already-computed row — only Alpha's own header, which this test
|
||
// never edits, could do that. Assert what the matrix requires at minimum
|
||
// (no count-bearing line, in either render) and, since it costs nothing
|
||
// and happens to hold, the full insert-only property too — this is a
|
||
// strictly stronger, still-true claim, not a weakened one.
|
||
//
|
||
// Pre-fix failing line: `_1 ADRs. Generated by ...` -> `_2 ADRs. ...`. The
|
||
// new ADR joins the Superseded group, so Active's own heading count stays
|
||
// `(1)`; it is the footer total that is MODIFIED and breaks the
|
||
// subsequence walk. Non-vacuous.
|
||
const { before, after } = renderBeforeAfter(
|
||
t,
|
||
{ '100-alpha.md': adr('Alpha', ['**Status:** Accepted']) },
|
||
'200-beta.md',
|
||
adr('Beta', ['**Status:** Superseded by [ADR-100](100-alpha.md)']),
|
||
);
|
||
const countBearing = /^### .+\(\d+\)\s*$/;
|
||
const footerCount = /^_\d+ ADRs\./;
|
||
for (const region of [before, after]) {
|
||
for (const line of region) {
|
||
assert.ok(!countBearing.test(line), `no group heading may carry a count: ${JSON.stringify(line)}`);
|
||
assert.ok(!footerCount.test(line), `no footer line may carry a count: ${JSON.stringify(line)}`);
|
||
}
|
||
}
|
||
assertInsertOnly(before, after, 'adding a Superseded ADR that names the Accepted ADR as successor');
|
||
});
|
||
|
||
test('insert-only holds for titles carrying markdown/HTML hazards', (t) => {
|
||
// Pipe, angle brackets, and backslash are all reachable through an H1
|
||
// title and are exactly what `cellText` exists to neutralize (#: pipe
|
||
// would split the table cell, angle brackets could forge an HTML/marker
|
||
// sequence, backslash is markdown's escape char and must be escaped
|
||
// first). A literal newline is deliberately NOT included here: the title
|
||
// is always extracted from a single physical H1 line
|
||
// (`lines.find(l => /^#\s/.test(l))`), so a raw `\n` cannot survive into
|
||
// the title text at all — asserting `cellText`'s newline handling would be
|
||
// vacuous at this call site (there is no path from a corpus file to a
|
||
// multi-line title).
|
||
//
|
||
// Pre-fix failing lines: BOTH `### Active decisions (1)` -> `(2)` and
|
||
// `_1 ADRs. ...` -> `_2 ADRs. ...`. The hazardous title affects only the
|
||
// NEW row's cell text, so what actually breaks the subsequence walk
|
||
// pre-fix is the same pair of count lines as the plain cases — the title
|
||
// hazard rides along to prove escaping does not itself introduce a
|
||
// modified line. Non-vacuous.
|
||
const { before, after } = renderBeforeAfter(
|
||
t,
|
||
{ '100-alpha.md': adr('Alpha', ['**Status:** Accepted']) },
|
||
'200-beta.md',
|
||
adr('Beta \\| <script>x</script> \\', ['**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(/(?<!\\)\|/g)].length;
|
||
assert.equal(cells, 5, `hazardous title must not add or remove a cell boundary; row was: ${row}`);
|
||
assert.ok(!row.includes('<script>'), '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 .msd/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 `<file>:<line>: ...` — 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'], ['']),
|
||
});
|
||
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 `<issue#>-<slug>.md` naming check.
|
||
const root = makeRepo(t, {
|
||
'0001-alpha.md': adrBody('Alpha', ['**Status:** Accepted'], [
|
||
'See [Beta](<ref/a b.md>) 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:\*\* <Token>` 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('msd-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('msd-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('msd-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);
|
||
});
|