Files
msd-core/tests/gen-context-index.test.cjs
Tom Boucher 69e7afd0c7 chore(#3212): bounded quantifiers over document content — prohibition with teeth — Phase 4 (#3441)
* feat(#3415): ship local/no-unbounded-quantifier, burn down ReDoS class

Phase 4 of epic #3212 (ADR-3212 §5/§7, the final phase). New rule flags
an unbounded */+/{n,} quantifier over a broad character class
([\s\S], dotAll ., or a 1-2-unit negated class like [^\n]/[^)\n] — the
exact #2128-fixed shape) applied to a regex whose match target is
data-flow-traced to readFileSync content.

eslint-rules/lib/readfilesync-trace.cjs extracts the data-flow tracer
shared with no-crlf-fragile-split (Phase 2) rather than a second copy
— no-crlf-fragile-split refactored onto it with zero behavior change,
parity-tested.

Real triage, not 798 mechanical edits: the ADR's census (2026-08-08)
screened every unbounded quantifier in the tree unscoped. Correctly
scoped to readFileSync-derived content (matching Phase 2's own G2/G3
scoping), the rule found 162 real hits across two detection waves — the
second wave (93) surfaced only after a genuine off-by-one bug in this
rule's own first draft was caught while writing its RuleTester tests
and fixed (the bug silently missed every directly-quantified [\s\S]*
with no gap before the quantifier — exactly the class this rule exists
to catch). 3 hits landed in production src/ (commands.cts, milestone.cts,
roadmap.cts) and were each empirically timed against adversarial input
(matching #2128's own measured-not-assumed precedent) — all confirmed
linear-time/benign, left unbounded with a measured-evidence comment
rather than mechanically bounded. The remaining 159 are test-file
fixture parsing (test-author-controlled, fixed-size content, not
adversarial input) — each suppressed with a specific, non-generic
reason. Zero functional behavior changed anywhere in this diff.

tests/no-pending-3212-markers.test.cjs locks the epic's own closing
invariant (ADR §7: "assert zero pending #3212 markers remain") — ground
truth confirmed trivially true today (no phase left any such marker
behind), now regression-locked going forward.

Design: .gsd/phase/chore-3415-prohibition-with-teeth/40-design.md
Test matrix: .gsd/phase/chore-3415-prohibition-with-teeth/50-test-matrix.md

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3415): correct rule category mislabel, add CI test-scope entry

An orthogonal Standards-axis review found eslint-rules/no-unbounded-quantifier.cjs
mistakenly carried meta.docs.category: 'Portability', copied from a sibling
rule without realizing what that implied: docs/contributing/cross-platform-
portability-rules.md governs an ADR-1703 rule family under a hard "zero
escape hatches" contract (tests/portability-rule-disable-ban.test.cjs's
PROTECTED_RULES bans eslint-disable for those rules entirely). This rule is
not part of that family — it's ADR-3212 (ReDoS/CWE-1333), a different epic —
and its eslint-disable-next-line suppressions (159 of them, added earlier
this same phase after empirical benign-verification) are an intentional,
correct design, not a bypass. Corrected to category: 'Best Practices',
matching the actual precedent (no-adhoc-regex-escape.cjs, Phase 1 of the
same epic, which is also correctly outside PROTECTED_RULES), and the rule's
own docstring now states this explicitly so a future reader doesn't have to
re-derive it.

Also registers a new scripts/ci-test-scope.cjs bucket so editing this rule
or the shared eslint-rules/lib/readfilesync-trace.cjs helper re-runs their
own test suites under targeted CI selection — was previously unregistered
and invisible to that fast-path (this PR's own gsd-test checkpoint runs the
full suite regardless, so this only affects future narrowly-scoped PRs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3415): bound no-unbounded-quantifier's own scanner (CWE-1333, ironic)

Security review found the rule meant to catch algorithmic-complexity bugs
had one of its own: hasUnboundedBroadQuantifier's negated-class inner
scan walked from each `[^` occurrence to the next `]` (or EOF) with no
bound, while the outer loop only ever advanced by one character — O(n²)
total work on a pattern with many unclosed `[^` runs. Runs unconditionally
inside checkPattern on any `new RegExp('literal string')` argument in any
linted file, before the (cheap) readFileSync data-flow gate — so a single
crafted string literal, no valid regex syntax required, could make
`npm run lint` / CI hang.

Empirically confirmed both the bug and the fix: pre-fix, n=4000/8000/
16000/32000 chars took 30.8/115.6/463.8/1874.3ms (~4x work per 2x n,
quadratic); extrapolated, the 300000-char repro from the finding would
run ~165s. Post-fix (bail the inner scan once units exceeds the rule's
own 1-2-unit scope, rather than continuing to hunt for a closing `]`),
the same 300000-char input runs in 8.7ms via the real rule module,
independently reconfirmed at 18ms via a fresh Linter.verify() call.

New regression row in tests/no-unbounded-quantifier.rule.test.cjs
asserts the RuleTester run on a 50000-char adversarial pattern
completes and returns a defined result — no wall-clock assertion
(CLAUDE.md Clock Seams / local/no-elapsed-assertion).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3415): triage 3 new sites, re-raise ceiling after upstream batch

next merged 12 more PRs during this PR's review. Two consequences:

- tests/edit-phase.test.cjs (fix #3262, unrelated) added 3 new
  content.match(/<tag>([\s\S]*?)<\/tag>/) reads of this repo's own
  workflow .md content — the same Class A pattern as the ~159 sites
  already triaged elsewhere in this PR. Suppressed with the same
  established reason.
- lint-allow-test-rule-refs' ratchet ceiling needed re-raising again
  (301 -> 303) for the same reason as the two prior bumps: organic
  growth from unrelated, already-reviewed PRs landing concurrently,
  not a defect in this branch's own diff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 10:02:28 -04:00

448 lines
20 KiB
JavaScript

'use strict';
/**
* Integration tests for scripts/gen-context-index.cjs — the CI gate that
* keeps docs/CONTEXT-INDEX.json in sync with the predicates declared in the
* repo-root CONTEXT.md (ADR-1671, #2928 Phase 1, rows F1-F17).
*
* The committed artifact is plain JSON (not a `.cjs` CommonJS module): a
* shipped runtime module is the wrong place for ~120 KB of arbitrary
* CONTEXT.md prose, and embedding it there tripped both
* tests/cline-install.test.cjs (leaked `.claude/hooks/...` path literals) and
* tests/package-name-single-source.test.cjs (hardcoded package-name
* literals) — both true positives against runtime-code content scanning.
* docs/CONTEXT-INDEX.json mirrors docs/INVENTORY-MANIFEST.json's precedent:
* a committed, generated, `--check`-guarded JSON manifest that is not
* runtime code.
*
* Fixture isolation (ADR-1671 Phase 1 commit 3): gen-context-index.cjs now
* accepts `--context-path <p>` / `--index-path <p>` CLI overrides (and the
* same-named parameters on the exported `checkReport`/`buildFreshIndex`
* pure functions), so every test here spawns the real CLI (spawnSync, not an
* engine-direct call — an engine-direct call is false-green for CLI behavior
* per the design's own risk analysis) pointed directly at temp fixture
* files, with NO fs monkeypatching. The prior `--require` preload
* (tests/helpers/gen-context-index-fs-fixture.cjs) redirected two hardcoded
* absolute paths by patching fs.readFileSync/existsSync/writeFileSync — that
* indirection is no longer needed now that the paths are directly
* injectable, and the preload has been deleted.
*
* Prohibited: Raw Text Matching on Test Outputs (CONTRIBUTING.md). This
* generator's `--check --json` mode emits a typed `{ ok, reason, duplicates,
* count, classes }` report — `reason` is always one of the frozen `REASON`
* enum values. Rows F7-F10 assert on `report.reason === REASON.FAIL_X`
* (and, for F7, that `report.duplicates` names the duplicate id) instead of
* exit-code-only / stderr-substring assertions.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { serializeIndex, buildFreshIndex, checkReport, REASON } = require('../scripts/gen-context-index.cjs');
const ROOT = path.resolve(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'gen-context-index.cjs');
const REAL_CONTEXT_PATH = path.join(ROOT, 'CONTEXT.md');
const STACK_FRAME_RE = /\n\s+at\s+\S+\s+\(.*:\d+:\d+\)/;
/**
* Spawn the real gen-context-index.cjs CLI with explicit `--context-path` /
* `--index-path` overrides — no fs monkeypatching, no `--require` preload.
*
* @param {string[]} args - CLI args (e.g. ['--check', '--json']).
* @param {{contextPath?: string, indexPath?: string}} [paths] - absolute
* fixture paths to pass via `--context-path`/`--index-path`. Omit a key to
* leave that seam at its real-repo default (read-only, untouched).
* @returns {{code: number, stdout: string, stderr: string}}
*/
function runGenContextIndex(args, paths = {}) {
const fullArgs = [...args];
if (paths.contextPath !== undefined) fullArgs.push('--context-path', paths.contextPath);
if (paths.indexPath !== undefined) fullArgs.push('--index-path', paths.indexPath);
try {
const stdout = execFileSync(process.execPath, [SCRIPT, ...fullArgs], {
cwd: ROOT,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 30000,
});
return { code: 0, stdout, stderr: '' };
} catch (err) {
return {
code: err.status ?? 1,
stdout: err.stdout ? err.stdout.toString() : '',
stderr: err.stderr ? err.stderr.toString() : '',
};
}
}
/**
* Parse the single JSON line `--check --json` writes to stdout.
*
* @param {string} stdout
* @returns {object}
*/
function parseJsonReport(stdout) {
return JSON.parse(stdout.trim());
}
describe('gen-context-index.cjs REASON enum (three-coordinated-changes lock)', () => {
test('REASON key set is exactly the documented set', () => {
// Locks the documented enum shape (CONTRIBUTING.md three-coordinated-
// changes pattern): adding a reason requires updating this assertion
// too, so the typed surface cannot silently drift from what tests expect.
assert.deepEqual(Object.keys(REASON).sort(), [
'FAIL_CONTEXT_MISSING',
'FAIL_CONTEXT_UNREADABLE',
'FAIL_DUPLICATE_IDS',
'FAIL_INDEX_MISSING',
'FAIL_INDEX_UNPARSEABLE',
'FAIL_LIB_NOT_BUILT',
'FAIL_STALE',
'OK_UP_TO_DATE',
]);
});
test('REASON is frozen', () => {
assert.ok(Object.isFrozen(REASON));
});
});
describe('gen-context-index.cjs --check (F)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gen-context-index-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('checkExitsZeroWhenIndexIsFresh', () => {
// Read-only against the real, already-fresh repo state — no override
// needed, and nothing is mutated.
const r = runGenContextIndex(['--check']);
assert.equal(r.code, 0);
});
test('checkExitsZeroAfterPureLineShift', () => {
// S5: the committed artifact carries no `line` field, so a pure line
// shift in CONTEXT.md must not perturb the byte-identical serialization.
const real = fs.readFileSync(REAL_CONTEXT_PATH, 'utf8');
const lines = real.split(/\r?\n/);
const shifted = [lines[0], '', '', ...lines.slice(1)].join('\n');
const shiftedPath = path.join(tmpDir, 'CONTEXT-shifted.md');
fs.writeFileSync(shiftedPath, shifted, 'utf8');
const r = runGenContextIndex(['--check'], { contextPath: shiftedPath });
assert.equal(r.code, 0, 'a pure line shift must not fail the gate (Q4 resolution, S5)');
});
test('checkExitsOneWhenPredicateValueChanged', () => {
const real = fs.readFileSync(REAL_CONTEXT_PATH, 'utf8');
const modified = real.replace(
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own maintainer-authored CONTEXT.md, bounded, not adversarial input
/`RULESET\.PR-SCOPE\.one-concern-per-pr=[^`]*`/,
'`RULESET.PR-SCOPE.one-concern-per-pr=CHANGED VALUE FOR TEST`',
);
assert.notEqual(modified, real, 'fixture setup sanity: the substitution must actually apply');
const modifiedPath = path.join(tmpDir, 'CONTEXT-value-changed.md');
fs.writeFileSync(modifiedPath, modified, 'utf8');
const r = runGenContextIndex(['--check'], { contextPath: modifiedPath });
assert.equal(r.code, 1);
});
test('checkExitsOneWhenPredicateAdded', () => {
const real = fs.readFileSync(REAL_CONTEXT_PATH, 'utf8');
const added = real + '\n`ZZZTEST.added-by-test=value`\n';
const addedPath = path.join(tmpDir, 'CONTEXT-added.md');
fs.writeFileSync(addedPath, added, 'utf8');
const r = runGenContextIndex(['--check'], { contextPath: addedPath });
assert.equal(r.code, 1);
});
test('checkExitsOneWhenPredicateRemoved', () => {
const real = fs.readFileSync(REAL_CONTEXT_PATH, 'utf8');
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own maintainer-authored CONTEXT.md, bounded, not adversarial input
const removed = real.replace(/`RULESET\.PR-SCOPE\.one-concern-per-pr=[^`]*`\r?\n/, '');
assert.notEqual(removed, real, 'fixture setup sanity: the removal must actually apply');
const removedPath = path.join(tmpDir, 'CONTEXT-removed.md');
fs.writeFileSync(removedPath, removed, 'utf8');
const r = runGenContextIndex(['--check'], { contextPath: removedPath });
assert.equal(r.code, 1);
});
test('checkExitsOneWhenClassSetChanged', () => {
const real = fs.readFileSync(REAL_CONTEXT_PATH, 'utf8');
const classGained = real + '\n`BRANDNEWCLASSFORTEST.x=y`\n';
const classGainedPath = path.join(tmpDir, 'CONTEXT-class-gained.md');
fs.writeFileSync(classGainedPath, classGained, 'utf8');
const r = runGenContextIndex(['--check'], { contextPath: classGainedPath });
assert.equal(r.code, 1);
});
test('checkExitsOneAndNamesDuplicateIdentifier (F7)', () => {
const real = fs.readFileSync(REAL_CONTEXT_PATH, 'utf8');
const dupPath = path.join(tmpDir, 'CONTEXT-dup.md');
const dupIntroduced = real + '\n`RULESET.PR-SCOPE.one-concern-per-pr=duplicate copy for test`\n';
fs.writeFileSync(dupPath, dupIntroduced, 'utf8');
const r = runGenContextIndex(['--check', '--json'], { contextPath: dupPath });
assert.equal(r.code, 1);
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'no bare stack trace in non-debug failure output');
const report = parseJsonReport(r.stdout);
assert.equal(report.ok, false);
assert.equal(report.reason, REASON.FAIL_DUPLICATE_IDS);
assert.ok(
report.duplicates.some((d) => d.id === 'RULESET.PR-SCOPE.one-concern-per-pr'),
'report.duplicates must name the duplicate id',
);
});
test('checkExitsOneWithRemedyWhenIndexMissing (F8)', () => {
const missingIndexPath = path.join(tmpDir, 'does-not-exist.json');
const r = runGenContextIndex(['--check', '--json'], { indexPath: missingIndexPath });
assert.equal(r.code, 1);
assert.doesNotMatch(r.stderr, STACK_FRAME_RE);
const report = parseJsonReport(r.stdout);
assert.equal(report.ok, false);
assert.equal(report.reason, REASON.FAIL_INDEX_MISSING);
});
test('checkExitsOneWithNamedReasonWhenIndexCorrupt (F9)', () => {
const corruptIndexPath = path.join(tmpDir, 'corrupt-index.json');
fs.writeFileSync(corruptIndexPath, 'this is not { valid javascript', 'utf8');
const r = runGenContextIndex(['--check', '--json'], { indexPath: corruptIndexPath });
assert.equal(r.code, 1);
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'no bare stack trace for a corrupt committed index');
const report = parseJsonReport(r.stdout);
assert.equal(report.ok, false);
assert.equal(report.reason, REASON.FAIL_INDEX_UNPARSEABLE);
});
test('checkExitsOneWhenContextMdMissing (F10)', () => {
const missingContextPath = path.join(tmpDir, 'does-not-exist.md');
const r = runGenContextIndex(['--check', '--json'], { contextPath: missingContextPath });
assert.equal(r.code, 1);
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'no bare stack trace when CONTEXT.md is missing');
const report = parseJsonReport(r.stdout);
assert.equal(report.ok, false);
assert.equal(report.reason, REASON.FAIL_CONTEXT_MISSING);
});
test('checkExitsOneWhenContextMdUnreadable', () => {
// Fault injection via the mandated technique (CONTRIBUTING.md /
// CLAUDE.md cross-platform IO-failure rule): monkeypatch fs.readFileSync
// to throw an injected EACCES for one specific fixture path, restore in
// `finally`. Never chmod 0o000 (root bypasses mode bits). This is an
// in-process call to the exported `checkReport` pure function rather
// than a subprocess spawn — a subprocess's fs cannot be monkeypatched
// from the parent test process without a `--require` preload, and
// `checkReport` IS the typed surface under test here, so calling it
// directly is not an engine-direct false-green for CLI *argv* behavior
// (that risk is covered by the spawned-CLI tests above); it is the
// correct level to exercise a fault the CLI itself cannot inject.
const fixtureContextPath = path.join(tmpDir, 'unreadable-context.md');
fs.writeFileSync(fixtureContextPath, '`FOO=bar`\n', 'utf8');
const origReadFileSync = fs.readFileSync;
fs.readFileSync = function patchedReadFileSync(p, ...rest) {
if (p === fixtureContextPath) {
const err = new Error(`EACCES: permission denied, open '${p}' (injected by test, never a real fs fault)`);
err.code = 'EACCES';
throw err;
}
return origReadFileSync.call(fs, p, ...rest);
};
try {
const report = checkReport(fixtureContextPath, path.join(tmpDir, 'unused-index.json'));
assert.equal(report.ok, false);
assert.equal(report.reason, REASON.FAIL_CONTEXT_UNREADABLE);
} finally {
fs.readFileSync = origReadFileSync;
}
});
test('checkIsCrlfAgnostic (F17)', () => {
// F17: a CRLF-committed index compared against the (LF) fresh real
// CONTEXT.md must still exit 0 — comparison is CRLF-normalized.
const freshSerialized = serializeIndex(buildFreshIndex());
const crlfSerialized = freshSerialized.replace(/\n/g, '\r\n');
const crlfIndexPath = path.join(tmpDir, 'context-index-crlf.json');
fs.writeFileSync(crlfIndexPath, crlfSerialized, 'utf8');
const r = runGenContextIndex(['--check'], { indexPath: crlfIndexPath });
assert.equal(r.code, 0, 'CRLF-vs-LF committed/fresh comparison must be normalized, not a false failure');
});
});
describe('gen-context-index.cjs --write / default / usage (F)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gen-context-index-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('writeThenCheckIsClean', () => {
const writeTarget = path.join(tmpDir, 'write-target.json');
const w = runGenContextIndex(['--write'], { indexPath: writeTarget });
assert.equal(w.code, 0);
assert.ok(fs.existsSync(writeTarget), '--write must create the fixture-redirected index file');
const c = runGenContextIndex(['--check'], { indexPath: writeTarget });
assert.equal(c.code, 0, '--check must be clean immediately after --write');
});
test('writeIsByteIdenticalAcrossRuns', () => {
const target1 = path.join(tmpDir, 'w1.json');
const target2 = path.join(tmpDir, 'w2.json');
assert.equal(runGenContextIndex(['--write'], { indexPath: target1 }).code, 0);
assert.equal(runGenContextIndex(['--write'], { indexPath: target2 }).code, 0);
const content1 = fs.readFileSync(target1, 'utf8');
const content2 = fs.readFileSync(target2, 'utf8');
assert.equal(content1, content2, '--write must be deterministic across independent runs');
});
test('writtenIndexContainsNoLineField', () => {
const target = path.join(tmpDir, 'no-line-field.json');
assert.equal(runGenContextIndex(['--write'], { indexPath: target }).code, 0);
const content = fs.readFileSync(target, 'utf8');
assert.equal(content.includes('"line"'), false, 'the committed artifact must carry no `line` field anywhere (S5)');
});
test('defaultInvocationPrintsIndexToStdout', () => {
// Fully safe against the real repo: default mode only reads CONTEXT.md
// and the compiled predicates lib (read-only) and writes nothing.
const r = runGenContextIndex([]);
assert.equal(r.code, 0);
assert.ok(r.stdout.length > 0);
// Compare against the exact expected serialization (computed the same
// way the CLI does, via the exported pure functions) rather than
// hand-parsing the rendered text — avoids brittle delimiter-scanning
// over a JSON payload that legitimately contains ';' inside string values.
const expected = serializeIndex(buildFreshIndex()) + '\n';
assert.equal(r.stdout, expected);
});
test('unknownFlagExitsWithUsage', () => {
// Safe against the real repo: the unknown-flag branch never reads
// CONTEXT.md or the committed index at all.
const r = runGenContextIndex(['--totally-bogus-flag']);
assert.notEqual(r.code, 0);
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'usage output must never be a bare stack trace');
});
// ─── DEFECT.GEN-CONTEXT-INDEX-PARSEARGS-GATE-BYPASS (MAJOR review finding):
// conflicting `--check --write` must be a hard usage error, not a silent
// `--write` win, and a missing/flag-shaped path value must never resolve
// to the cwd (which previously leaked a raw EISDIR stack trace). ────────
test('checkAndWriteTogetherIsUsageErrorNotASilentWrite (a)', () => {
const target = path.join(tmpDir, 'should-not-be-written.json');
const r = runGenContextIndex(['--check', '--write'], { indexPath: target });
assert.notEqual(r.code, 0, '--check --write together must not silently exit 0 as a write');
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'usage output must never be a bare stack trace');
assert.equal(fs.existsSync(target), false, '--write must never win over --check and rewrite the index');
});
test('writeAndCheckReversedOrderIsAlsoAUsageError (a)', () => {
const target = path.join(tmpDir, 'should-also-not-be-written.json');
const r = runGenContextIndex(['--write', '--check'], { indexPath: target });
assert.notEqual(r.code, 0, 'conflicting mode flags must be a usage error regardless of order');
assert.doesNotMatch(r.stderr, STACK_FRAME_RE);
assert.equal(fs.existsSync(target), false);
});
test('missingTrailingValueForContextPathIsUsageErrorNotEisdirStackTrace (b)', () => {
const target = path.join(tmpDir, 'should-not-be-written-2.json');
// `--context-path` is the LAST arg: argv[i+1] is undefined, which used
// to resolve to the cwd via `path.resolve(undefined ?? '')`.
const r = runGenContextIndex(['--write', '--index-path', target, '--context-path']);
assert.notEqual(r.code, 0, 'a missing --context-path value must be a usage error');
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'must never leak a raw EISDIR (or any) stack trace');
assert.equal(fs.existsSync(target), false, 'no write must happen when the path argument is rejected');
});
test('flagShapedValueForIndexPathIsUsageErrorNotSwallowedAsALiteralPath (b)', () => {
// `--index-path` is immediately followed by another flag rather than a
// path — must be rejected, not silently swallowed as the literal path
// "--json".
const r = runGenContextIndex(['--write', '--index-path', '--json']);
assert.notEqual(r.code, 0, 'a flag-shaped --index-path value must be a usage error');
assert.doesNotMatch(r.stderr, STACK_FRAME_RE);
});
});
// ─── DEFECT.GEN-CONTEXT-INDEX-DUPLICATE-GATE-UNPROVEN (MAJOR review finding):
// `FAIL_DUPLICATE_IDS` was only ever proven against synthetic fixtures — this
// branch hand-deleted the ONE live duplicate
// (`RULESET.WORKFLOW_MARKDOWN.FENCES`) from the real CONTEXT.md, so the
// committed docs/CONTEXT-INDEX.json ships `duplicates: []` and the gate has
// never been shown to catch a REAL duplicate in the real document. This
// suite re-inserts the exact deleted line (recovered from
// `git show origin/next:CONTEXT.md`) into a copy of the REAL CONTEXT.md and
// runs the real generator CLI against it. ───────────────────────────────────
describe('gen-context-index.cjs --check against a real-CONTEXT.md duplicate (real-data proof)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gen-context-index-real-dup-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('reinsertingTheDeletedRulesetWorkflowMarkdownFencesLineFailsWithNamedDuplicate', () => {
// The exact line this branch deleted from the real CONTEXT.md (does NOT
// mention MD040 — the live line that replaced it does).
const deletedLine =
'`RULESET.WORKFLOW_MARKDOWN.FENCES=when editing shell snippets inside workflow markdown, preserve the opening language fence; malformed fence can create fresh CodeRabbit threads`';
const real = fs.readFileSync(REAL_CONTEXT_PATH, 'utf8');
assert.ok(
real.includes('RULESET.WORKFLOW_MARKDOWN.FENCES'),
'sanity: the real CONTEXT.md must still carry the live (MD040) FENCES line',
);
assert.ok(!real.includes(deletedLine), 'sanity: the deleted line must not already be present verbatim');
const reinserted = real + '\n' + deletedLine + '\n';
const fixturePath = path.join(tmpDir, 'CONTEXT-real-with-reinserted-duplicate.md');
fs.writeFileSync(fixturePath, reinserted, 'utf8');
const r = runGenContextIndex(['--check', '--json'], { contextPath: fixturePath });
assert.equal(r.code, 1, 'a real duplicate reintroduced into the real CONTEXT.md must fail the gate');
assert.doesNotMatch(r.stderr, STACK_FRAME_RE);
const report = parseJsonReport(r.stdout);
assert.equal(report.ok, false);
assert.equal(report.reason, REASON.FAIL_DUPLICATE_IDS);
assert.ok(
report.duplicates.some((d) => d.id === 'RULESET.WORKFLOW_MARKDOWN.FENCES'),
'report.duplicates must name RULESET.WORKFLOW_MARKDOWN.FENCES as the real duplicate',
);
});
});