Files
msd-core/tests/gen-context-index.test.cjs
Tom Boucher 05b170e448 chore(#2928): productionize the CONTEXT.md predicate fact-store and gate it in CI (#2938)
* feat(#2928): port CONTEXT.md predicate fact-store into the src seam

Productionizes the ADR-1671 Option-E reference example as a real module:
src/context-predicates.cts (parser + selector + index builder) compiled to
gsd-core/bin/lib/, plus scripts/gen-context-index.cjs following the repo's
--check/--write drift-guard idiom and wired into lint:generated-sync.

Parser behavior is deliberately prototype-equivalent in this commit so the
next commit's regression matrix binds to the real defects rather than to a
missing module.

Two locked design deviations from the prototype:
- duplicates carry a count, not line numbers
- the committed index carries no line field at all, resolving ADR-1671 open
  question 4: an artifact without line numbers cannot drift on a line shift,
  so promoting --check to a CI gate does not make it routinely red

Also reconciles the one remaining duplicate predicate ID
(RULESET.WORKFLOW_MARKDOWN.FENCES was declared twice; the non-MD040 wording
is removed) so the gate can land fail-closed on duplicates.

Refs #1671

* test(#2928): failing-first matrix for the predicate fact-store

Adds the regression matrix from the phase test plan: parser declaration
forms, fence and comment regions, ID/value grammar boundaries at
limit-1/limit/limit+1, CRLF fidelity, duplicate detection, the drift-guard
CLI, the selector query surface, and four document-shaped fast-check
properties.

Seven rows are RED for behavioral reasons against the ported parser:
indented-bare, star-list, plus-list and numbered-list declaration forms are
dropped; a tilde fence and a four-backtick fence containing a shorter fence
are not skipped; and a multi-line HTML comment is parsed as live. Eleven
selector rows are RED because the query surface is not wired yet.

Negative fixtures come from real repo documents that predate the grammar
(CONTEXT.md, CONTRIBUTING.md's fenced env-assignment examples) per the
fixture-provenance rule, and the property generators are document-shaped
rather than seeded from our own serializer.

Refs #1671

* fix(#2928): consume the shared fence scanner, relocate the index, wire the selector

Drives the failing-first matrix green.

Parser: replaces the ported naive triple-backtick toggle with the shared
markdown-sectionizer fence engine. scanFencedBlocks and FencedBlockRecord
gain an export keyword — the only change to that module, which has 71
upstream dependents — because it already returns line-indexed spans, which
is exactly what a line-reporting parser needs. It also already documents
itself as the second copy of the fence state machine pending consolidation;
adding a third copy here would have been the generative-fix divergence this
repo warns about. A parity suite now pins predicate fence-skipping against
that scanner across eight fence shapes. HTML-comment skipping stays local
because the sectionizer has no comment scanner. Declaration forms widen to
indented-bare, star, plus and numbered list items.

Index location: docs/CONTEXT-INDEX.json, not a module under bin/lib. The
remote matrix run caught the original choice — a committed .cjs there ships
~120KB of CONTEXT.md prose into a runtime module, and two content guards
fired truthfully on it (a leaked .claude install path, and four hardcoded
package-name literals). Neither guard was allowlisted; the artifact moved
instead, mirroring docs/INVENTORY-MANIFEST.json. Nothing at runtime needs to
require it — it is a drift-detection artifact, so the selector parses
CONTEXT.md live and is always current.

Generator: adds a frozen REASON enum and --check --json so the gate's
outcome is asserted structurally instead of by matching prose, and
--context-path/--index-path so tests drive the real CLI against a temp tree
with no filesystem monkeypatching.

Selector: gsd_run query context-predicates with --class/--prefix/--contains,
structured output carrying a matched count, own-property guards, and no
project-root resolution. Registering it exposed that the query dispatch
table and the usage string had drifted: a new parity test found 20 routed
commands missing from the usage list, all added here rather than deferred.

Refs #1671

* test(#2928): lock the newly-public scanFencedBlocks contract

Exporting scanFencedBlocks made it public API for the first time, so it
needs its own contract test independent of the consumer that motivated the
export. Memtrace's co-change analysis flagged the gap: this suite changes
together with markdown-sectionizer.cts 8 times in 90 days and was absent
from the diff.

Covers the documented rules: 0-based indices, -1 for an unterminated fence,
the same-char/>=length/no-trailing-text closer rule, a shorter fence inside
a longer one staying content, CommonMark 4.5 backtick-in-info-string, and
<=3-space indent tolerance.

Refs #1671

* fix(#2928): address both isolated review passes

Two independent reviewers (correctness axis and security axis, neither the
author) found seven findings. All are fixed here with regression tests; none
deferred.

BLOCKER — comment-blind fence scanning caused silent, permanent predicate
loss. The HTML-comment scan and the fence scan ran as two independent passes,
and the fence scanner is comment-blind, so a fence delimiter inside an HTML
comment with no later close read as an unterminated fence and skipped every
remaining line to EOF. Worse, the drift-guard could not catch it: it diffs
against a baseline produced by the same corrupted parse. The two constructs
now interleave in a single pass so each suppresses the other's boundary
detection while active, covered in both directions. The parity suite still
binds this scanner to markdown-sectionizer's for comment-free documents, so
the two cannot diverge unnoticed.

BLOCKER — the selector was not consumed anywhere, leaving the phase's
acceptance criterion unmet. Now wired into the pre-work predicate-citation
step in contributor-standards, which is the repo's actual brief-assembly
path; no code-level brief assembler exists to wire into.

MAJOR — ReDoS with an unauthenticated CI-hang exploit. The predicate-id
regex nested a dot-containing character class inside a dot-prefixed repeat,
so N consecutive dots had exponentially many partitions: 40 dots took 565ms
and growth was exponential. CI runs this parser over a pull request's own
CONTEXT.md, so any contributor could have hung a shared runner with one
line. Replaced with linear per-segment validation. Doubled-dot ids are now
rejected; the real document contains none.

MAJOR — the duplicate-id gate had only ever been proven on synthetic
fixtures. A test now re-inserts the exact line this branch removed and
asserts the real generator names it.

MAJOR — --check together with --write silently let write win, turning the
gate into a writer; a missing path value resolved to the cwd and leaked an
EISDIR stack trace. Both are now clean usage errors.

MINOR — the hoisted skip-list was exported as a live mutable Set; replaced
with a read-only predicate. MINOR — flag-shaped selector values were
unmatchable; the inline --flag=value form now provides the escape hatch.

Refs #1671

* chore(#2928): backfill changeset PR number 2938

---------

Co-authored-by: sim <sim@local>
2026-07-31 13:17:01 -04:00

446 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(
/`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');
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',
);
});
});