* 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>
448 lines
20 KiB
JavaScript
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',
|
|
);
|
|
});
|
|
});
|