Phase 4 of #3464, following #3465, #3466 and #3502. Those cut the ceiling 305 -> 278 and made the rule accurate. This closes the remaining structural weakness: suppression was FILE-WIDE, so a single justified exemption silently absolved every other source-grep in that file, forever, including ones added later by someone else. hasAllowAnnotation did comments.some(...) over the whole file and returned {} early. A marker is now checked per report: a violation is suppressed only by a marker on its own line, or on a line above it with nothing but blank lines and other comments in between, bounded by MAX_MARKER_LOOKAHEAD_LINES = 8. The bound is comment-purity rather than raw distance, and that distinction is load-bearing: an intervening line of real code (a `test(...)` opener, say) ends the window even when the marker is physically close. Chosen from the actual placements in the affected files rather than picked a priori -- the repo's convention puts several lines of prose rationale between the marker and the code, so a tighter rule would have invalidated legitimate existing markers and forced churn for no correctness gain. Measured before writing any code, by running the real rule with the suppression check neutralized across all 1194 files its globs match: 14 violation sites in 8 files, and ZERO in files carrying no marker -- so the green build was legitimate, and the entire migration surface was those 14. 11 sites were mechanical: an existing marker already stated the right reason, it just sat too far away. Those were relocated to their call sites with the original #NNN citations preserved. Three were orphans -- the file's markers were about an entirely different concern and nobody had ever justified these reads. All three are fixed BEHAVIORALLY, with no new markers: install-minimal-hooks.test.cjs:975 asserted src.includes('gsd-update-check') && src.includes('replace(') against bin/install.js. It now calls the exported stripStaleGsdHookBlocks() on a legacy TOML fixture and asserts the actual stripped output. This is the case this phase was opened around: it could be added with no review friction and stay invisible indefinitely under file-wide amnesty. config.test.cjs:1917 regex-tested src/init.cts for detectGitCreateTag. It now drives `init complete-milestone` and asserts the git_create_tag field. config-schema.property.test.cjs:1107 did the same for detectFallowConfig; it now drives `init code-review` and asserts fallow_enabled. Each was proven RED against a broken production file and GREEN against the real one, with src/init.cts and bin/install.js confirmed byte-identical afterwards. Marker lines in the 8 files went 20 -> 24, against a filed expectation of "must not increase" (projected 14). That projection was wrong and is corrected on #3508 rather than met by deletion. It assumed every existing marker was a distant blanket that site-scoping would consolidate. Some are already site-adjacent and guard real source-greps the rule CANNOT detect -- verified in install-minimal-hooks.test.cjs:2686-2757, where seven markers each sit directly above a readFileSync(reloadScript) + .includes() pair reading hooks/gsd-config-reload.js. Removing them to hit a number would have repeated the Phase 1 mistake: deleting markers on "the rule doesn't fire" evidence when the rule provably cannot see the violation. An earlier revision of this commit message attributed that invisibility to the #3502 dynamic-path blind spot, on the grounds that reloadScript is a variable. Adversarial review caught that as a false causal claim and it is corrected here. looksLikeSourcePath's hasSourceDir regex is /['"](?:bin|lib|gsd-core|src)['"]/i, and those reads target hooks/ -- so a fully literal path.join(ROOT,'hooks','gsd-config-reload.js') is equally invisible. The variable indirection is irrelevant. This is a FIFTH, distinct blind spot: the source-dir allowlist omits hooks/, which is a real shipped production directory (eslint.config.mjs registers its own rule block for hooks/**/*.js). Recorded in 40-design.md Known limits and left for a follow-on phase -- widening the allowlist is unmeasured, and measuring before widening is the discipline #3502 established. The conclusion was right; the stated mechanism was not, and asserting an unverified cause is the error being corrected. The honest metric is not fewer markers. It is that every marker now sits adjacent to the specific read it justifies instead of absolving a whole file. Site-scoping turns one blanket marker covering N sites into N site markers by design; the count rising is the mechanism working. A second review finding is fixed here too. Suppression originally keyed only off the text-search line, so a marker placed directly above the readFileSync() call -- the intuitive place to annotate "this read is fine" -- did NOT suppress when the search sat on the following line, because the read's own assignment line breaks comment-purity. It failed safe (a loud error, never silent suppression), but it was a trap contributors would hit, and it contradicted this change's own claim that the placement rule would not force churn. A violation is now suppressed by a marker adjacent to EITHER the search site or the originating read. The violation is fundamentally the read+search pair, so annotating either half is legitimate, and it stays strictly site-scoped -- the decisive isolation row still holds. 17 RuleTester rows cover the new semantics. The decisive one asserts that a marker adjacent to one violation does NOT suppress an unrelated violation elsewhere in the same file -- exactly 1 error, reported at the second site. Teeth-checked by reverting the predicate to file-wide, confirming that row and two others flip pass->fail, then restoring. Two pre-existing RuleTester cases that asserted the old file-wide semantics were corrected. Compatibility held where it matters: 277 marker-bearing files have no detectable violation at all, and site-scoping makes their markers no-ops rather than errors. All stay green, untouched. Ceiling unchanged at 278; lint-allow-test-rule-refs reports 278/278. Deliberately not done here, and recorded for the follow-on phase: the same measurement found only 8 of 285 marker-bearing files contain a detectable violation. That suggests a large honest ceiling drop, but "the rule doesn't fire" is the unsound oracle that forced the Phase 1 revert of 295 files, and the rule still has documented blind spots -- as install-minimal-hooks itself demonstrates above. It needs two independent signals agreeing, which is only credible now that the rule is accurate. With suppression site-scoped, "an effective exemption" is finally well-defined, which is what makes re-pointing the ratchet at effective exemptions -- rather than at marker-text presence -- the natural next step. Closes #3508 Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
3022 lines
104 KiB
JavaScript
3022 lines
104 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* eslint-rules.test.cjs
|
|
*
|
|
* RuleTester unit tests for the local ESLint rules:
|
|
* - local/no-source-grep
|
|
* - local/no-magic-sleep-in-tests
|
|
* - local/no-elapsed-assertion
|
|
* - local/no-raw-rmsync-in-tests
|
|
* - local/no-adhoc-markdown-parsing
|
|
* - local/require-subprocess-timeout
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { RuleTester, ESLint } = require('eslint');
|
|
const path = require('node:path');
|
|
const fc = require('fast-check');
|
|
|
|
const noSourceGrep = require('../eslint-rules/no-source-grep.cjs');
|
|
const noMagicSleepInTests = require('../eslint-rules/no-magic-sleep-in-tests.cjs');
|
|
const noElapsedAssertion = require('../eslint-rules/no-elapsed-assertion.cjs');
|
|
const noRawRmsyncInTests = require('../eslint-rules/no-raw-rmsync-in-tests.cjs');
|
|
const noTautologicalAssert = require('../eslint-rules/no-tautological-assert.cjs');
|
|
const noAdhocMarkdownParsing = require('../eslint-rules/no-adhoc-markdown-parsing.cjs');
|
|
const noDuplicateFoldMarker = require('../eslint-rules/no-duplicate-fold-marker.cjs');
|
|
const requireSubprocessTimeout = require('../eslint-rules/require-subprocess-timeout.cjs');
|
|
|
|
const ruleTester = new RuleTester({
|
|
languageOptions: {
|
|
ecmaVersion: 2022,
|
|
sourceType: 'commonjs',
|
|
},
|
|
});
|
|
|
|
// ─── no-source-grep ──────────────────────────────────────────────────────────
|
|
|
|
describe('no-source-grep rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noSourceGrep.create, 'function');
|
|
});
|
|
|
|
test('valid: readFileSync on .md file is allowed', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const content = fs.readFileSync(path.join(__dirname, '..', 'docs', 'readme.md'), 'utf-8');
|
|
content.includes('hello');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const content = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'workflows', 'config.json'), 'utf-8');
|
|
content.includes('key');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: readFileSync on .cjs source file followed by .includes()', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'io.cjs'), 'utf-8');
|
|
src.includes('someFunction');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: readFileSync on .cjs source file followed by .match()', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'foo.cjs'), 'utf-8');
|
|
src.match(/pattern/);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: allow-test-rule annotation adjacent to the read exempts that site (#3508: site-scoped, not file-wide)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
// The marker sits directly above the read+search it suppresses.
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
// allow-test-rule: pending migration
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'io.cjs'), 'utf-8'); src.includes('someFunction');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: require() of a .cjs file is allowed (not readFileSync)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const mod = require('../gsd-core/bin/lib/io.cjs');
|
|
mod.someMethod();
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-source-grep widening (#3502 / Phase 3 of #3464) ─────────────────────
|
|
//
|
|
// One RuleTester case per row of .gsd/phase/chore-3464-widen-source-grep/
|
|
// 50-test-matrix.md. Row numbers in test names refer to that matrix.
|
|
|
|
describe('no-source-grep rule — widening (#3502)', () => {
|
|
test('row 1: baseline literal .cjs read + .includes() (happy regression)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
src.includes('x');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 2: .cts source read + .match() (gap B)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const ROOT = '/repo';
|
|
const src = fs.readFileSync(path.join(ROOT, 'src', 'verification.cts'), 'utf-8');
|
|
src.match(/x/);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 3: .mts source read + .match() (gap B)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const ROOT = '/repo';
|
|
const src = fs.readFileSync(path.join(ROOT, 'src', 'x.mts'), 'utf-8');
|
|
src.match(/x/);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 4: .mjs source read + .match() (gap B)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const ROOT = '/repo';
|
|
const src = fs.readFileSync(path.join(ROOT, 'src', 'x.mjs'), 'utf-8');
|
|
src.match(/x/);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 5: .matchAll() on a tracked read (gap A)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
src.matchAll(/x/g);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 6: regex.test(tracked) (gap A, argument-side detection)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const re = /x/;
|
|
re.test(src);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 7: /lit/.test(tracked) (gap A, argument-side detection)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
/x/.test(src);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 8: .split() / .replace() probes (gap A)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
src.split('\\n');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
src.replace(/x/, '');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 9: two-hop derived variable (gap C)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
function strip(x) { return x; }
|
|
const a = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const b = strip(a);
|
|
b.match(/x/);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 10: three-hop derived variable — at the depth bound (gap C, boundary)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const a = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const b = a;
|
|
const c = b;
|
|
c.includes('x');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 11: hop chain beyond the configured depth is a documented limit (gap C, boundary)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const a = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const b = a;
|
|
const c = b;
|
|
const d = c;
|
|
d.includes('x');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 12: shadowed same-name param — false-positive guard (gap D)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const fn = (src) => src.replace(/x/, 'y');
|
|
fn('unrelated');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 13: same name, sibling block scopes — false-positive guard (gap D)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
{
|
|
const c = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
}
|
|
{
|
|
const c = 'x';
|
|
c.includes('y');
|
|
}
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 14: .md literal read + .includes() (negative space, unchanged)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const content = fs.readFileSync(path.join(__dirname, '..', 'workflows', 'a.md'), 'utf-8');
|
|
content.includes('x');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 15: .json literal read + .match() (negative space, unchanged)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const content = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.json'), 'utf-8');
|
|
content.match(/x/);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 16: dynamic path variable → .includes() — deliberately not flagged (rejected widening)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
function readIt(p) {
|
|
const content = fs.readFileSync(p, 'utf-8');
|
|
content.includes('x');
|
|
}
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 17: tracked read, no text search (negative space, unchanged)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'));
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 18: require() of a .cjs (negative space, unchanged)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const mod = require('../lib/a.cjs');
|
|
mod.someMethod();
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 19: a marker adjacent to the read+search suppresses it (#3508: site-scoped, not file-wide)', () => {
|
|
// The raw marker text is assembled via string concatenation so this
|
|
// FILE's own bytes never contain a contiguous "allow" + "-test-rule:"
|
|
// token (scripts/lint-allow-test-rule-refs.cjs does a raw whole-file
|
|
// substring scan). At RuleTester-run time the concatenation resolves to
|
|
// a real single-line comment, which the rule under test honors normally.
|
|
// The marker sits directly above the read+search (site-scoped, #3508),
|
|
// not merely somewhere earlier in the file (the pre-#3508 file-wide form
|
|
// this row originally exercised).
|
|
const marker = '// ' + 'allow' + '-test-rule: split marker for row 19, see #3502';
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
marker,
|
|
"const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); src.includes('x');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 20: marker text inside a string literal (not a comment) does not suppress', () => {
|
|
// Same split-marker technique as row 19, applied to a STRING literal
|
|
// (not a comment) — this row exists to prove the rule's suppression
|
|
// check only honors an actual comment, per the #3465 discriminator.
|
|
const stringMarkerLine = "const note = '" + 'allow' + "-test-rule: this is just data, not a directive';";
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
stringMarkerLine,
|
|
"const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');",
|
|
'src.includes(note);',
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-source-grep site-scoped suppression (#3508 / Phase 4 of #3464) ──────
|
|
//
|
|
// One RuleTester case per row of
|
|
// .gsd/phase/chore-3464-site-scoped-suppression/50-test-matrix.md, rows 1-12.
|
|
// Row 4 is the one that actually proves the defect is closed: file-wide
|
|
// amnesty is gone, so a marker adjacent to one violation must NOT reach an
|
|
// unrelated violation later in the same file. Rows 3 and 6 are the
|
|
// compatibility guards (prose between marker and read; marker + zero
|
|
// violations) that must keep working or this would break the 277
|
|
// marker-bearing files that rely on file-level markers being a documented
|
|
// no-op when there's nothing to suppress.
|
|
//
|
|
// Marker text is always assembled via string concatenation (`AT` below) so
|
|
// THIS file's raw bytes never contain a contiguous "allow" + "-test-rule:"
|
|
// token — same fixture-host discipline as the row 19/20 cases above
|
|
// (scripts/lint-allow-test-rule-refs.cjs does a raw whole-file substring
|
|
// scan and must not newly count this file).
|
|
//
|
|
// NOTE: row 9's fixture length is tied to MAX_MARKER_LOOKAHEAD_LINES (8) in
|
|
// eslint-rules/no-source-grep.cjs — if that constant changes, this fixture's
|
|
// filler-line count must change with it.
|
|
// Row 12 ("marker with no #NNN") is explicitly a script-level check, not a
|
|
// RuleTester case (test-matrix.md marks it "(script, not RuleTester)") —
|
|
// it's covered by `node scripts/lint-allow-test-rule-refs.cjs` instead.
|
|
|
|
describe('no-source-grep rule — site-scoped suppression (#3508)', () => {
|
|
const AT = 'allow' + '-test-rule:';
|
|
|
|
test('row 1: marker directly above the read+search is suppressed (site-scoped)', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 2: marker trailing on the same line as the search is suppressed', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x'); // ${AT} reason (#1)`,
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 3: marker with prose lines between it and the read is still suppressed (repo real-style guard)', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
'// continuation prose line one explaining the reason',
|
|
'// continuation prose line two continuing the explanation',
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 4: marker adjacent to V1 does NOT reach an unrelated V2 later in the file (the defect this phase closes)', () => {
|
|
const filler = Array.from({ length: 40 }, (_, i) => `// unrelated filler line ${i + 1}, pushing V2 well past the lookahead bound`);
|
|
const lines = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason for V1 (#1)`,
|
|
"const s1 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s1.includes('x');",
|
|
...filler,
|
|
"const s2 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'b.cjs'), 'utf-8'); s2.includes('y');",
|
|
];
|
|
const code = lines.join('\n');
|
|
const v2Line = lines.length; // s2's line is the last line of the fixture
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
// Exactly ONE error, reported at V2 -- V1 stays suppressed, and the
|
|
// marker's reach does NOT extend to the unrelated V2 40 lines later.
|
|
errors: [{ messageId: 'noSourceGrep', line: v2Line }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 5: marker far above a violation with no marker text of its own is not suppressed', () => {
|
|
const filler = Array.from({ length: 100 }, (_, i) => `// unrelated filler line ${i + 1}`);
|
|
const lines = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
...filler,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
|
|
];
|
|
const code = lines.join('\n');
|
|
const violationLine = lines.length;
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep', line: violationLine }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 6: file with a marker and zero violations stays green (the 277 inert-marker files compatibility guard)', () => {
|
|
const code = [
|
|
`// ${AT} reason (#1)`,
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
"const content = fs.readFileSync(path.join(__dirname, '..', 'docs', 'readme.md'), 'utf-8');",
|
|
"content.includes('hello');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 7: no marker, one violation is flagged (baseline unchanged)', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 8: two violations, two adjacent markers -- per-site marking works', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason for V1 (#1)`,
|
|
"const s1 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s1.includes('x');",
|
|
`// ${AT} reason for V2 (#1)`,
|
|
"const s2 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'b.cjs'), 'utf-8'); s2.includes('y');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 9: marker beyond the lookahead bound does not suppress (the bound is where it claims)', () => {
|
|
// MAX_MARKER_LOOKAHEAD_LINES is 8 in eslint-rules/no-source-grep.cjs.
|
|
// 9 filler comment lines between the marker and the read pushes the gap
|
|
// to 10 lines (> 8), just past the bound.
|
|
const filler = Array.from({ length: 9 }, (_, i) => `// filler comment line ${i + 1}`);
|
|
const lines = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
...filler,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
|
|
];
|
|
const code = lines.join('\n');
|
|
const violationLine = lines.length;
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep', line: violationLine }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 10: marker text inside a fixture string (not a real comment) is not a directive', () => {
|
|
// The marker-looking text lives inside a STRING LITERAL in the linted
|
|
// fixture, never as a `//` comment -- ESLint's comment AST (what the
|
|
// rule inspects) never sees string-literal contents, so this must not
|
|
// suppress the real, unmarked violation below it (the #3465 lesson).
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`const note = 'not a directive: ${AT} fake reason';`,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes(note);",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 11: marker citing #NNN on the same line still suppresses (citation contract unaffected)', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason for this read (#3508)`,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ─── read-site suppression (adversarial-review fix, ITEM 1) ────────────
|
|
//
|
|
// A violation is fundamentally about a read+search PAIR. Before this fix,
|
|
// a marker adjacent to the readFileSync() call (the intuitive annotation
|
|
// spot) failed to suppress once the search happened on a later line,
|
|
// because the readFileSync assignment line itself is "real code" and
|
|
// broke comment-purity on the marker->search lookahead path. The rule now
|
|
// also checks a marker's site-scoping against the ORIGINATING read call's
|
|
// own line, independent of the marker->search path.
|
|
|
|
test('valid: marker directly above the read, search on the very next (non-comment) line', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
"const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
|
|
"src.includes('x');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: marker directly above the read, search several comment-pure lines later (read-line real code no longer breaks the marker->search path)', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
"const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
|
|
'// comment-pure line one',
|
|
'// comment-pure line two',
|
|
'// comment-pure line three',
|
|
"src.includes('x');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: marker above the read suppresses that pair, but an unrelated tracked variable searched further down is still flagged', () => {
|
|
const lines = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason for V1 (#1)`,
|
|
"const s1 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
|
|
"s1.includes('x');",
|
|
"const s2 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'b.cjs'), 'utf8');",
|
|
"s2.includes('y');",
|
|
];
|
|
const code = lines.join('\n');
|
|
const v2Line = lines.length; // s2.includes(...) is the last line
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep', line: v2Line }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: marker far from both the read and the search is still flagged', () => {
|
|
const filler = Array.from({ length: 20 }, (_, i) => `// unrelated filler line ${i + 1}`);
|
|
const lines = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
...filler,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
|
|
"s.includes('x');",
|
|
];
|
|
const code = lines.join('\n');
|
|
const violationLine = lines.length; // s.includes(...) is the last line
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep', line: violationLine }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('boundary: marker exactly MAX_MARKER_LOOKAHEAD_LINES (8) above the read is suppressed via the read-site path', () => {
|
|
// 7 comment-pure filler lines between the marker and the read puts the
|
|
// read exactly 8 lines below the marker -- the inclusive boundary.
|
|
const filler = Array.from({ length: 7 }, (_, i) => `// filler comment line ${i + 1}`);
|
|
const lines = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
...filler,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
|
|
"s.includes('x');",
|
|
];
|
|
const code = lines.join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('boundary: marker one line beyond MAX_MARKER_LOOKAHEAD_LINES (9) above the read is not suppressed', () => {
|
|
// 8 comment-pure filler lines between the marker and the read puts the
|
|
// read 9 lines below the marker -- one past the inclusive boundary.
|
|
const filler = Array.from({ length: 8 }, (_, i) => `// filler comment line ${i + 1}`);
|
|
const lines = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
...filler,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
|
|
"s.includes('x');",
|
|
];
|
|
const code = lines.join('\n');
|
|
const violationLine = lines.length; // s.includes(...) is the last line
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep', line: violationLine }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-source-grep hop-propagation value-shape (adversarial-review fix) ────
|
|
//
|
|
// minTrackedHop() used to walk EVERY Identifier under a derivation's RHS
|
|
// and treat any bare reference to a tracked variable as propagating,
|
|
// regardless of whether the derived VALUE could still carry text (e.g.
|
|
// `.length`). These rows cover the value-shape gate that replaced that
|
|
// blind walk: propagate only through derivations that plausibly still
|
|
// carry the source file's text; do not propagate through scalar-producing
|
|
// shapes (member access, numeric/boolean methods, comparisons, Number()
|
|
// et al).
|
|
|
|
describe('no-source-grep rule — hop-propagation value-shape (adversarial-review fix)', () => {
|
|
test('valid: .length derivation does not propagate (reported false-positive repro)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const raw = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const len = raw.length;
|
|
if (/^\\d+$/.test(len)) {}
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: numeric-returning method derivation does not cascade to a second error', () => {
|
|
// raw.indexOf('x') is itself already flagged directly (indexOf is one
|
|
// of the TEXT_METHODS this rule flags on a tracked receiver, unrelated
|
|
// to hop propagation). The important assertion here is that there is
|
|
// exactly ONE error, not two: the numeric result of .indexOf() must
|
|
// NOT stay tracked, so String(n).includes('1') is not a second finding.
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const raw = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const n = raw.indexOf('x');
|
|
String(n).includes('1');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: boolean-returning method derivation does not cascade to a second error', () => {
|
|
// Same shape as above with a boolean-returning method: raw.includes('x')
|
|
// is itself already flagged directly. The boolean result must NOT stay
|
|
// tracked, so String(ok).includes('true') is not a second finding.
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const raw = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const ok = raw.includes('x');
|
|
String(ok).includes('true');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: comparison of a tracked derivation does not propagate', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const raw = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const same = raw.length === 0;
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: string-returning method derivation still propagates and is caught', () => {
|
|
// raw.replace(...) is flagged directly (replace is a TEXT_METHOD, same
|
|
// as the indexOf/includes rows above) AND the string-returning result
|
|
// (b) correctly stays tracked, so b.includes('y') is a second, distinct
|
|
// finding. Two errors total, both real.
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const raw = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const b = raw.replace(/x/, '');
|
|
b.includes('y');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }, { messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: template-literal derivation still propagates and is caught', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const raw = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const b = \`\${raw}\`;
|
|
b.match(/y/);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: direct .includes() on the tracked source read is unchanged (no regression)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const raw = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
raw.includes('x');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-magic-sleep-in-tests ─────────────────────────────────────────────────
|
|
|
|
describe('no-magic-sleep-in-tests rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noMagicSleepInTests.create, 'function');
|
|
});
|
|
|
|
test('valid: setTimeout used outside tests (no-op since rule only applies to *.test.cjs)', () => {
|
|
// Rule only applies to *.test.cjs files; a non-test filename is always valid
|
|
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const delay = new Promise(resolve => setTimeout(resolve, 100));
|
|
`,
|
|
filename: 'scripts/some-script.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: Atomics.wait() in test file', () => {
|
|
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const shared = new SharedArrayBuffer(4);
|
|
const arr = new Int32Array(shared);
|
|
Atomics.wait(arr, 0, 0, 100);
|
|
`,
|
|
filename: 'tests/some.test.cjs',
|
|
errors: [{ messageId: 'atomicsWaitSleep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: setTimeout used for synchronization in Promise in test file', () => {
|
|
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
async function waitABit() {
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
}
|
|
`,
|
|
filename: 'tests/some.test.cjs',
|
|
errors: [{ messageId: 'setTimeoutSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: setTimeout with callback (not synchronization pattern) in test file', () => {
|
|
// A setTimeout with no second arg or with a callback that does real work
|
|
// is allowed. The rule only flags the await-new-Promise(setTimeout) pattern.
|
|
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
function doSomethingLater(cb) {
|
|
setTimeout(cb, 100);
|
|
}
|
|
`,
|
|
filename: 'tests/some.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-elapsed-assertion ─────────────────────────────────────────────────────
|
|
|
|
describe('no-elapsed-assertion rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noElapsedAssertion.create, 'function');
|
|
});
|
|
|
|
test('valid: assert on non-timing property', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
const result = { count: 5 };
|
|
assert.equal(result.count, 5);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(result.success);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert on .elapsed property', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
const result = { elapsed: 150 };
|
|
assert.ok(result.elapsed < 200);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert on .duration property', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.equal(stats.duration, 100);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert on .took property', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(result.took < 500);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert on .ms property', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(result.ms > 0);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.equal with timing comparison', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.equal(result.elapsed > 0, true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-raw-rmsync-in-tests ──────────────────────────────────────────────────
|
|
|
|
describe('no-raw-rmsync-in-tests rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noRawRmsyncInTests.create, 'function');
|
|
});
|
|
|
|
// ── INVALID cases (must error) ────────────────────────────────────────────
|
|
|
|
test('invalid: fs.rmSync() in a test file', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noRawRmSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: computed member fs["rmSync"]() in a test file', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
fs['rmSync'](d, { recursive: true, force: true });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noRawRmSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: destructured rmSync from require("fs") in a test file', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const { rmSync } = require('fs');
|
|
rmSync(d, { recursive: true, force: true });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noRawRmSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: aliased const del = fs.rmSync; del() in a test file', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const del = fs.rmSync;
|
|
del(d, { recursive: true, force: true });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noRawRmSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: allow-test-rule annotation no longer suppresses this rule (Defect 1 fixed)', () => {
|
|
// A file with // allow-test-rule: <source-grep reason> must still error
|
|
// on raw rmSync calls. The file-level annotation is for no-source-grep only.
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
// allow-test-rule: source-text-is-the-product
|
|
const fs = require('fs');
|
|
fs.rmSync(d, { recursive: true, force: true });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noRawRmSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── VALID cases (must NOT error) ──────────────────────────────────────────
|
|
|
|
test('valid: helpers.cleanup() in a test file (no error)', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const { cleanup } = require('../helpers.cjs');
|
|
cleanup(tmpDir);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: bare rmSync() that is NOT fs-derived (local function) is not flagged', () => {
|
|
// A locally defined function named rmSync must not be flagged — the rule
|
|
// only tracks names that were bound from require("fs").
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const rmSync = () => {};
|
|
rmSync(d);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// NOTE: The inline `// eslint-disable-next-line local/no-raw-rmsync-in-tests -- reason`
|
|
// escape hatch is handled entirely by ESLint's own disable-comment mechanism and
|
|
// cannot be unit-tested here via RuleTester (RuleTester runs the rule under a
|
|
// different internal namespace so the comment's rule-id doesn't match). The escape
|
|
// hatch works correctly when ESLint processes real files via `npx eslint`.
|
|
|
|
test('valid: fs.rmSync() in a non-test file (rule is inert outside *.test.cjs)', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
`,
|
|
filename: 'scripts/foo.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: member access / assignment without calling (not a CallExpression)', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const orig = fs.rmSync;
|
|
fs.rmSync = orig;
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-tautological-assert ──────────────────────────────────────────────────
|
|
|
|
describe('no-tautological-assert rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noTautologicalAssert.create, 'function');
|
|
});
|
|
|
|
// ── VALID cases (must NOT error) ──────────────────────────────────────────
|
|
|
|
test('valid: assert.ok with a non-literal identifier argument', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(result);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.strictEqual with mixed literal/identifier arguments', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.strictEqual(actual, true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.strictEqual with identifier and numeric literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.strictEqual(x, 5);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.ok with a CallExpression argument', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(fn());
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.deepStrictEqual with two identifier arguments', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.deepStrictEqual(got, expected);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.strictEqual with two different identifier arguments', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.strictEqual(a, b);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── INVALID cases (must error) ────────────────────────────────────────────
|
|
|
|
test('invalid: assert.ok(true) — always-truthy boolean literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert(true) — bare assert with always-truthy boolean literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert');
|
|
assert(true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.ok(1) — always-truthy non-zero numeric literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(1);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.ok("always") — always-truthy non-empty string literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok('always');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.ok([]) — always-truthy array literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok([]);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.ok(cond || true) — logical OR whose right side is true', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(cond || true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.strictEqual(true, true) — identical boolean literals', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.strictEqual(true, true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalEquality' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.equal(1, 1) — identical numeric literals', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.equal(1, 1);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalEquality' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── Fix #3: true || cond (left-side true) ────────────────────────────────
|
|
|
|
test('invalid: assert.ok(true || x) — left side is literal true (always short-circuits)', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(true || x);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert(true || y) — bare assert, left side is literal true', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert');
|
|
assert(true || y);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── Fix #4: empty [] / {} deep-equality ──────────────────────────────────
|
|
|
|
test('invalid: assert.deepStrictEqual([], []) — two empty arrays are always deep-equal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.deepStrictEqual([], []);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalEquality' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.deepStrictEqual({}, {}) — two empty objects are always deep-equal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.deepStrictEqual({}, {});
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalEquality' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── Conservative: non-empty arrays/objects must NOT be flagged ────────────
|
|
|
|
test('valid: assert.deepStrictEqual([1], [2]) — non-empty arrays with different content are not flagged', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.deepStrictEqual([1], [2]);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.deepStrictEqual(got, expected) — identifier arguments are not flagged', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.deepStrictEqual(got, expected);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-adhoc-markdown-parsing ───────────────────────────────────────────────
|
|
|
|
describe('no-adhoc-markdown-parsing rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noAdhocMarkdownParsing.create, 'function');
|
|
});
|
|
|
|
// ── POSITIVE cases: flag fence-block-strip and section-collect ────────────
|
|
|
|
test('invalid: fence-block-strip regex with triple-backtick and multiline body', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// /```[\s\S]*?```/ — triple-backtick + [\s\S] body → flagged as fenceRegex
|
|
code: String.raw`const stripFences = /` + '```' + String.raw`[\s\S]*?` + '```' + '/;',
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'fenceRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: fence-block-strip regex with triple-tilde and multiline body', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// /~~~[\s\S]*?~~~/ — triple-tilde + [\s\S] body → flagged as fenceRegex
|
|
code: String.raw`const stripTildes = /~~~[\s\S]*?~~~/;`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'fenceRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: section-collect regex with heading capture, multiline body, heading lookahead', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// /(##\s*X\n)([\s\S]*?)(?=\n##|$)/ — the classic section-collect fingerprint
|
|
code: String.raw`const pat = /(##\s*X\n)([\s\S]*?)(?=\n##|$)/;`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'sectionCollect' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── NEGATIVE cases: single-line fence tests and heading matches NOT flagged ─
|
|
|
|
test('valid: bare single-line fence-opener /^```/ is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: 'const fenceRegex = /^' + '```' + '/;',
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: /^\\s*(?:```|~~~)/ fence-line test is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const isFenceLine = /^\s*(?:` + '```' + String.raw`|~~~)/;`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: /^#\\s+/ single-line title-find is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const titleRe = /^#\s+/;`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: /^###\\s+(.+?)\\s*$/ single-line heading-category match is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const headingRe = /^###\s+(.+?)\s*$/;`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: /^(#{1,6})\\s+(.*)/ single-line heading match is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const headingM = line.match(/^(#{1,6})\s+(.*)/);`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: seam usage (no regex, just an import reference) is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const { collectSection } = require('./markdown-sectionizer');
|
|
const result = collectSection(content, 'Introduction');
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: annotated fence-block-strip with allow-adhoc-markdown is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
// Trailing annotation on the same line suppresses the finding
|
|
code:
|
|
'const stripFences = /```' +
|
|
String.raw`[\s\S]*?` +
|
|
'`' +
|
|
'``/; // allow-adhoc-markdown: pre-seam write path; pending migration #1372',
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: rule is inert outside src/*.cts files', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
// Same fence-block-strip regex in a test file → rule does not apply
|
|
code: String.raw`const stripFences = /~~~[\s\S]*?~~~/;`,
|
|
filename: 'tests/some.test.cjs',
|
|
},
|
|
{
|
|
// Same regex in a scripts file → rule does not apply
|
|
code: String.raw`const p = /(##\s*X\n)([\s\S]*?)(?=\n##|$)/;`,
|
|
filename: 'scripts/helper.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── TABLE-REGEX (ADR-2143 §7) ──────────────────────────────────────────────
|
|
|
|
test('invalid: table-row/cell regex with escaped pipe and negated-pipe cell class', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// /\|[^|]*\|/ — the classic hand-rolled table-row/cell scan fingerprint
|
|
code: String.raw`const rowRe = /\|[^|]*\|/;`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: table-cell regex with escaped-pipe class variant [^\\|]', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`const cellRe = /\|\s*([^\|]+)\s*\|/;`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: parseMarkdownTable() seam call is NOT flagged (no regex literal)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const { parseMarkdownTable } = require('./markdown-table');
|
|
const result = parseMarkdownTable(sectionText);
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: escaped pipe alone (no negated-pipe cell class) is NOT flagged', () => {
|
|
// A bare delimiter probe like /^\|/ or /\|\|/ has an escaped pipe but no
|
|
// [^|] cell-capture class — not a table-row/cell scan, so it must stay
|
|
// conservative and not fire.
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const isPipeDelim = /^\|/;`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
{
|
|
code: String.raw`const orDelim = /a\|b/;`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: annotated table-regex with allow-adhoc-markdown is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const rowRe = /\|[^|]*\|/; // allow-adhoc-markdown: not a table scan, protocol-marker probe`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: table-regex in a non-src/*.cts file is not flagged (rule is inert there)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const rowRe = /\|[^|]*\|/;`,
|
|
filename: 'scripts/helper.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── TABLE-REGEX via new RegExp(<Literal-string | TemplateLiteral>) (#2143 Phase 4) ─
|
|
|
|
test('invalid: new RegExp(<string literal>) matching the table fingerprint', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// new RegExp('\|[^|]*\|') — doubled backslashes cook to a literal \|
|
|
code: String.raw`const rowRe = new RegExp('\\|[^|]*\\|');`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: new RegExp(<template literal>) whose STATIC quasis match the table fingerprint (dynamic segment ignored)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// new RegExp(`^(\|\s*${phase}\.?\s[^|]*(?:\|[^\n]*))$`) — the exact
|
|
// roadmap.cts/phase.cts tableRowPattern shape, dynamic ${phase} in the middle.
|
|
code: 'const tableRowPattern = new RegExp(`^(\\\\|\\\\s*${phase}\\\\.?\\\\s[^|]*(?:\\\\|[^\\\\n]*))$`, \'im\');',
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: new RegExp(`## Phase ${x}`) — dynamic heading pattern, static quasis are not table/section (non-table)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: 'const headingRe = new RegExp(`## Phase ${x}`, \'i\');',
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: new RegExp(someIdentifier) — pattern built elsewhere and referenced by variable is out of scope for this check', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const pattern = buildRowPattern();
|
|
const rowRe = new RegExp(pattern, 'im');
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── new RegExp(identifier) resolved via resolveVariableInit scope walk (#2245 recall-hole fix) ─
|
|
|
|
test('invalid: new RegExp(identifier) resolves a const-declared identifier whose pattern matches the table fingerprint', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// const tablePattern = '\|[^|]*\|' (doubled backslashes cook to a literal \|),
|
|
// then new RegExp(tablePattern) — the pattern is built one hop away via a
|
|
// const-declared identifier instead of inline, which used to escape the
|
|
// fingerprint check entirely (the recall hole this fix closes).
|
|
code: String.raw`
|
|
const tablePattern = '\\|[^|]*\\|';
|
|
const rowRe = new RegExp(tablePattern);
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: new RegExp(identifier) resolves a const-declared identifier whose pattern is NOT table-shaped', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const headingPattern = '## Phase';
|
|
const headingRe = new RegExp(headingPattern);
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: new RegExp(identifier) does NOT resolve a function-parameter identifier (documented boundary)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`
|
|
function buildRowRegex(tablePattern) {
|
|
return new RegExp(tablePattern);
|
|
}
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: annotated new RegExp(...) table-regex with allow-adhoc-markdown is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const rowRe = new RegExp('\\|[^|]*\\|'); // allow-adhoc-markdown: protocol-marker probe, not a table scan`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: new RegExp(...) table-regex in a non-src/*.cts file is not flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const rowRe = new RegExp('\\|[^|]*\\|');`,
|
|
filename: 'scripts/helper.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── ADHOC-REPLACE-MUTATION: .replace() on a roadmap/state receiver (#2143 Phase 4) ─
|
|
|
|
test('invalid: roadmapContent.replace(<inline table-fingerprint literal>, ...) trips both the CallExpression and Literal detectors', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`roadmapContent.replace(/\|[^|]*\|/, 'x');`,
|
|
filename: 'src/some-module.cts',
|
|
// CallExpression is the outer/enter-first node; its Literal argument
|
|
// (visited next, on descent) is a second, independent finding.
|
|
errors: [{ messageId: 'adhocReplaceMutation' }, { messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: stateContent.replace(<variable holding a table-fingerprint regex>, ...) resolves the variable via scope', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`
|
|
const rowRe = /\|[^|]*\|/;
|
|
stateContent.replace(rowRe, 'x');
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }, { messageId: 'adhocReplaceMutation' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: withSection(...) call is NOT a .replace() and is never flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const { withSection } = require('./markdown-sectionizer');
|
|
const result = withSection(content, 'x', (body) => body);
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: foo.replace(/x/, "y") — neither the receiver name nor the pattern match, not flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: `foo.replace(/x/, 'y');`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: state.replace(/x/, "y") — matching receiver name but non-matching pattern, not flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: `state.replace(/x/, 'y');`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: allow-adhoc-markdown suppresses an inline ad-hoc .replace() mutation', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`roadmapContent.replace(/\|[^|]*\|/, 'x'); // allow-adhoc-markdown: pre-seam write path`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: .replace() ad-hoc mutation in a non-src/*.cts file is not flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`roadmapContent.replace(/\|[^|]*\|/, 'x');`,
|
|
filename: 'scripts/helper.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── TABLE-REGEX widening: [^|\n] and escaped-pipe-plus-others classes (#2880) ──
|
|
|
|
test('invalid: content.replace(<inline [^|\\n] cell-class regex>) — the exact shape that evaded the rule before #2880', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// /\|[^|\n]*\|/ — pipe-excluding cell class ALSO excludes newline; this
|
|
// is the src/state-document.cts shape that the sole-member-class check
|
|
// missed prior to the #2880 widening.
|
|
code: String.raw`content.replace(/\|[^|\n]*\|/, 'x');`,
|
|
filename: 'src/state-document.cts',
|
|
// CallExpression is the outer/enter-first node (adhocReplaceMutation);
|
|
// its Literal argument (visited next, on descent) is the second,
|
|
// independent tableRegex finding — same ordering as the established
|
|
// roadmapContent.replace(...) case above.
|
|
errors: [{ messageId: 'adhocReplaceMutation' }, { messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: factory function returning new RegExp(<template literal with [^|\\n] cell class>)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: 'function buildRowPattern() {\n return new RegExp(`\\\\|[^|\\\\n]*\\\\|`, \'im\');\n}',
|
|
filename: 'src/state-document.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: cell class with the pipe escaped alongside another excluded member [^\\|\\n]', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`const cellRe = /\|[^\|\n]*\|/;`,
|
|
filename: 'src/state-document.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: negated class with NO pipe at all is not a cell scan (e.g. /^[^\\n]*$/)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`content.replace(/^[^\n]*$/, 'x');`,
|
|
filename: 'src/state-document.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: body.replace(...) — non-matching receiver name (bounded withSection callback) suppresses ONLY adhocReplaceMutation; the regex literal itself is still an independent tableRegex finding', () => {
|
|
// The ADHOC-REPLACE-MUTATION check is scoped to receivers matching
|
|
// /roadmap|state|reqContent|content/i — "body" (the withSection callback
|
|
// parameter name) does not match, so no adhocReplaceMutation fires here.
|
|
// But the standalone Literal visitor inspects EVERY regex literal in the
|
|
// file regardless of call-site context, so the pipe-excluding-class regex
|
|
// is still caught as a bare tableRegex finding either way.
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`body.replace(/\|[^|\n]*\|/, 'x');`,
|
|
filename: 'src/state-document.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: allow-adhoc-markdown suppresses the widened [^|\\n] shape', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`content.replace(/\|[^|\n]*\|/, 'x'); // allow-adhoc-markdown: reason`,
|
|
filename: 'src/state-document.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── TABLE-REGEX narrowing: negated class excluding pipe + something ELSE
|
|
// is a different (non-table) idiom, not flagged (#2880 FIX 4) ───────────
|
|
|
|
test('valid: [^\\s|] (pipe excluded alongside \\s, not a pure line-terminator class) is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const re = /[^\s|]+\|cmd/;`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: [^"|] (pipe excluded alongside a quote) is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const re = /\|[^"|]*\|/;`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: [^|\\r\\n] (pipe plus only line-terminator escapes) IS flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`const rowRe = /\|[^|\r\n]*\|/;`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('performance: a 256000-char adversarial regex-literal source does not hang the rule (ReDoS regression)', () => {
|
|
// The previous regex-based fingerprint, /\[\^[^\]]*\\?\|[^\]]*\]/, was
|
|
// quadratic on failure — an unclosed negated class of this size took
|
|
// ~23s. The single-pass scanner must stay linear. The adversarial text is
|
|
// embedded directly inside a single string-literal argument to
|
|
// `new RegExp(...)` (not built via `+` at the source-code level under
|
|
// test) so `getNewRegExpSource` actually resolves it and the scanner
|
|
// walks the full 256000-char unclosed negated class.
|
|
const bigPipeRun = '|'.repeat(256000);
|
|
const code = `const re = new RegExp('\\\\|[^${bigPipeRun}');`;
|
|
// The 256000-char input is the regression guard for the O(n^2) scan fixed
|
|
// in #2880: the pre-fix regex took ~23s on this input. There is deliberately
|
|
// no elapsed-time assertion (banned by local/no-elapsed-assertion and flaky
|
|
// by nature) — if the quadratic path is ever reintroduced this test stops
|
|
// completing, which surfaces as a suite timeout rather than a silent pass.
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── property test: negated-pipe-class scanner (hasQualifyingNegatedPipeClass) ──
|
|
test('property: single-pass negated-class scanner verdict matches an independent reference implementation', () => {
|
|
// hasQualifyingNegatedPipeClass is a closure private to the rule's
|
|
// `create(context)` — it cannot be called directly, so it is exercised
|
|
// through the public surface: `new RegExp(<string literal>)` feeds
|
|
// `arg.value` through UNCHANGED as the "effective regex source" (see
|
|
// getNewRegExpSource), so any generated string, however malformed as a
|
|
// real regex, reaches the scanner byte-for-byte via JSON.stringify(...).
|
|
// A guaranteed literal `\|` is prepended so isTableRegexSource's OTHER
|
|
// gate (`src.includes('\\|')`) is always satisfied — the property is then
|
|
// solely a probe of the negated-class scanner's own verdict, matching the
|
|
// instruction to test the scanner in isolation.
|
|
//
|
|
// Reference implementation (independent tokenizer, NOT a copy of the
|
|
// scanner under test): tokenize `src` once into {esc, text} units,
|
|
// tracking escapes; then walk the tokens with a MONOTONIC cursor: on
|
|
// finding a `[` char-token immediately followed by a `^` char-token,
|
|
// consume forward to the first unescaped `]` (or to the end if there is
|
|
// none) as a single committed unit, decide qualification for that unit,
|
|
// and resume scanning strictly AFTER whatever was consumed — a class
|
|
// candidate found INSIDE an already-consumed (opened) class body is
|
|
// never separately reconsidered. Qualifies iff the collected body
|
|
// contains a pipe (bare `|` or escaped `\|`) AND every other member is
|
|
// one of the escapes `\n`, `\r`, `\t`.
|
|
function referenceHasQualifyingNegatedPipeClass(src) {
|
|
const tokens = [];
|
|
let i = 0;
|
|
while (i < src.length) {
|
|
if (src[i] === '\\') {
|
|
const next = i + 1 < src.length ? src[i + 1] : '';
|
|
tokens.push({ esc: true, text: next });
|
|
i += 2;
|
|
}
|
|
else {
|
|
tokens.push({ esc: false, text: src[i] });
|
|
i += 1;
|
|
}
|
|
}
|
|
let t = 0;
|
|
while (t < tokens.length) {
|
|
const opensClass = !tokens[t].esc && tokens[t].text === '['
|
|
&& t + 1 < tokens.length && !tokens[t + 1].esc && tokens[t + 1].text === '^';
|
|
if (!opensClass) {
|
|
t += 1;
|
|
continue;
|
|
}
|
|
let u = t + 2;
|
|
const members = [];
|
|
let closed = false;
|
|
while (u < tokens.length) {
|
|
const tok = tokens[u];
|
|
if (!tok.esc && tok.text === ']') {
|
|
closed = true;
|
|
u += 1;
|
|
break;
|
|
}
|
|
members.push(tok);
|
|
u += 1;
|
|
}
|
|
if (closed) {
|
|
let hasPipe = false;
|
|
let isPure = true;
|
|
for (const m of members) {
|
|
if (!m.esc && m.text === '|') {
|
|
hasPipe = true;
|
|
continue;
|
|
}
|
|
if (m.esc && m.text === '|') {
|
|
hasPipe = true;
|
|
continue;
|
|
}
|
|
if (m.esc && (m.text === 'n' || m.text === 'r' || m.text === 't')) continue;
|
|
isPure = false;
|
|
}
|
|
if (hasPipe && isPure) return true;
|
|
}
|
|
// Monotonic advance: whether this candidate qualified, failed, or
|
|
// ran off the end unclosed, never re-enter the bytes just consumed.
|
|
t = closed ? u : tokens.length;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Composed of random characters PLUS randomly inserted `[^...]` classes
|
|
// with and without pipes (some closed, some not; some qualifying, some
|
|
// not) so both the "flagged" and "not flagged" verdicts are well
|
|
// exercised — a purely uniform character soup almost never assembles a
|
|
// well-formed `[^...|...]` class by chance.
|
|
const pipeMemberArb = fc.constantFrom('|', '\\|');
|
|
const pureFillerArb = fc.constantFrom('\\n', '\\r', '\\t');
|
|
const impureFillerArb = fc.constantFrom('a', 'Z', '1', '\\s', '\\d', '\\w', '\\\\', '-', ' ', '\\]', '\\[');
|
|
const classMemberArb = fc.oneof(pipeMemberArb, pureFillerArb, impureFillerArb);
|
|
const classBodyArb = fc.array(classMemberArb, { minLength: 1, maxLength: 3 }).map((members) => members.join(''));
|
|
const classChunkArb = fc
|
|
.record({ body: classBodyArb, closed: fc.boolean() })
|
|
.map(({ body, closed }) => '[^' + body + (closed ? ']' : ''));
|
|
const noiseCharArb = fc.constantFrom('[', ']', '^', 'x', 'y', '0', '9', ' ', '.', '-', '(', ')');
|
|
const escapeChunkArb = fc
|
|
.tuple(fc.constant('\\'), fc.constantFrom('n', 'r', 't', '|', 's', 'd', '\\', '[', ']', '^', 'a'))
|
|
.map(([bs, c]) => bs + c);
|
|
const chunkArb = fc.oneof(
|
|
{ weight: 5, arbitrary: classChunkArb },
|
|
{ weight: 2, arbitrary: escapeChunkArb },
|
|
{ weight: 2, arbitrary: noiseCharArb },
|
|
);
|
|
const srcArb = fc.array(chunkArb, { minLength: 0, maxLength: 5 }).map((chunks) => chunks.join(''));
|
|
|
|
fc.assert(
|
|
fc.property(srcArb, (fuzzed) => {
|
|
const src = '\\|' + fuzzed;
|
|
const expected = referenceHasQualifyingNegatedPipeClass(src);
|
|
const code = `const re = new RegExp(${JSON.stringify(src)});`;
|
|
if (expected) {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
}
|
|
else {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [{ code, filename: 'src/some-module.cts' }],
|
|
invalid: [],
|
|
});
|
|
}
|
|
}),
|
|
{ numRuns: 200, seed: 2880 },
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── no-duplicate-fold-marker ────────────────────────────────────────
|
|
|
|
describe('no-duplicate-fold-marker rule', () => {
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
|
|
/** Build a source string whose line numbers are the array indices + 1. */
|
|
const src = (...lines) => lines.join('\n');
|
|
|
|
const FOLD_A_B1 = '__foldDescribe("folded:a (consolidation epic #1969 B1 #1970)", () => {});';
|
|
const FOLD_A_B5 = '__foldDescribe("folded:a (consolidation epic #1969 B5 #1975)", () => {});';
|
|
const FOLD_B_B1 = '__foldDescribe("folded:b (consolidation epic #1969 B1 #1970)", () => {});';
|
|
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noDuplicateFoldMarker.create, 'function');
|
|
});
|
|
|
|
// ── Row 1: the #3271 regression, asserted against the real tree ────────────
|
|
//
|
|
// The unit cases below prove the rule can fire. THIS proves the tree it
|
|
// guards is actually clean — it is the assertion that was red before the 25
|
|
// duplicated regions were deleted (18 in install.test.cjs, 5 in
|
|
// install-minimal-hooks.test.cjs, 2 in install-write-confinement.test.cjs).
|
|
//
|
|
// Driven through the real ESLint API over the production glob rather than a
|
|
// hand-rolled scan of file text: a readFileSync + .match() scan of a .cjs
|
|
// path is exactly the shape `local/no-source-grep` bans in tests/**.
|
|
test('regression #3271: the real tests/ tree has no duplicate fold markers', async () => {
|
|
const eslint = new ESLint({
|
|
cwd: REPO_ROOT,
|
|
overrideConfigFile: true,
|
|
overrideConfig: {
|
|
files: ['tests/**/*.cjs'],
|
|
plugins: { local: { rules: { 'no-duplicate-fold-marker': noDuplicateFoldMarker } } },
|
|
languageOptions: { ecmaVersion: 2022, sourceType: 'commonjs' },
|
|
rules: { 'local/no-duplicate-fold-marker': 'error' },
|
|
},
|
|
});
|
|
|
|
const results = await eslint.lintFiles(['tests/**/*.cjs']);
|
|
|
|
// Filter to THIS rule: an ad-hoc config also surfaces "rule not found" for
|
|
// inline eslint-disable directives naming rules it does not register.
|
|
const violations = results.flatMap((r) =>
|
|
r.messages
|
|
.filter((m) => m.ruleId === 'local/no-duplicate-fold-marker')
|
|
.map((m) => `${path.relative(REPO_ROOT, r.filePath)}:${m.line} ${m.message}`),
|
|
);
|
|
|
|
// Non-vacuous: if the glob silently matched nothing, the empty result below
|
|
// would be meaningless.
|
|
assert.ok(results.length > 100, `expected the tests/ glob to match many files, got ${results.length}`);
|
|
assert.deepStrictEqual(violations, [], `duplicate folded suites found:\n${violations.join('\n')}`);
|
|
});
|
|
|
|
test('the rule is registered at error for tests/**/*.cjs in the real config', async () => {
|
|
const eslint = new ESLint({ cwd: REPO_ROOT });
|
|
const config = await eslint.calculateConfigForFile(
|
|
path.join(REPO_ROOT, 'tests', 'install.test.cjs'),
|
|
);
|
|
assert.deepStrictEqual(config.rules['local/no-duplicate-fold-marker'], [2]);
|
|
});
|
|
|
|
// ── Occurrence-count boundary: 1 (clean) / 2 (one report) / 3 (two) ────────
|
|
|
|
test('valid: a single folded marker in a file', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [{ code: src(FOLD_A_B1), filename: 'tests/host.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: the same folded marker twice in one file', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: src(FOLD_A_B1, FOLD_A_B1),
|
|
filename: 'tests/host.test.cjs',
|
|
errors: [
|
|
{ messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 2 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: three occurrences report the 2nd and 3rd', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: src(FOLD_A_B1, FOLD_A_B1, FOLD_A_B1),
|
|
filename: 'tests/host.test.cjs',
|
|
errors: [
|
|
{ messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 2 },
|
|
{ messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 3 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: two distinct folded markers', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [{ code: src(FOLD_A_B1, FOLD_B_B1), filename: 'tests/host.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── Negative space (10-diagnosis.md) ──────────────────────────────────────
|
|
|
|
// #3271's own reproduction regex (`folded:[a-z0-9-]*`) stops at `.` and
|
|
// collides these two genuinely distinct suites, which coexist in
|
|
// tests/model-resolver.test.cjs. A guard written to that key would red the
|
|
// build on `next` forever.
|
|
test('valid: dot-suffixed marker is distinct from its prefix (model-resolver #3271 false positive)', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{
|
|
code: src(
|
|
'__foldDescribe("folded:feat-443-effort-fast-mode.integration (consolidation epic #1969 B8 #1977)", () => {});',
|
|
'__foldDescribe("folded:feat-443-effort-fast-mode (consolidation epic #1969 B8 #1977)", () => {});',
|
|
),
|
|
filename: 'tests/model-resolver.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// tests/review-default-reviewers-workflow.test.cjs reuses the fold alias for
|
|
// an ordinary describe block. Those carry no uniqueness obligation.
|
|
test('valid: __foldDescribe titles without a folded: prefix are ignored', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{
|
|
code: src(
|
|
"__foldDescribe('#1936: OpenCode reviewer empty-output hardening', () => {});",
|
|
"__foldDescribe('#1936: OpenCode reviewer empty-output hardening', () => {});",
|
|
),
|
|
filename: 'tests/host.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: a file with no fold markers', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [{ code: src('describe("ordinary", () => {});'), filename: 'tests/host.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: plain describe with a folded: title is not the fold convention', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{
|
|
code: src(
|
|
'describe("folded:a (consolidation epic #1969 B1 #1970)", () => {});',
|
|
'describe("folded:a (consolidation epic #1969 B1 #1970)", () => {});',
|
|
),
|
|
filename: 'tests/host.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// Documented non-goal, pinned so the behavior is deliberate rather than
|
|
// accidental: the rule keys on the callee identifier being literally
|
|
// __foldDescribe. Every one of the 365 fold sites calls it directly.
|
|
test('valid: a call through a further alias of the fold alias is not tracked', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{
|
|
code: src(
|
|
'const d = __foldDescribe;',
|
|
'd("folded:a (consolidation epic #1969 B1 #1970)", () => {});',
|
|
'd("folded:a (consolidation epic #1969 B1 #1970)", () => {});',
|
|
),
|
|
filename: 'tests/host.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: a member-expression call named __foldDescribe is not the fold alias', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{
|
|
code: src(
|
|
'helpers.__foldDescribe("folded:a (consolidation epic #1969 B1 #1970)", () => {});',
|
|
'helpers.__foldDescribe("folded:a (consolidation epic #1969 B1 #1970)", () => {});',
|
|
),
|
|
filename: 'tests/host.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// The same marker in two different HOST files is not intra-file duplication.
|
|
// RuleTester lints each entry as its own file, so this also proves the
|
|
// per-file state is rebuilt rather than shared across files.
|
|
test('valid: the same marker in two different files is not an intra-file duplicate', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{ code: src(FOLD_A_B1), filename: 'tests/host-one.test.cjs' },
|
|
{ code: src(FOLD_A_B1), filename: 'tests/host-two.test.cjs' },
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── Ordering / identity ───────────────────────────────────────────────────
|
|
|
|
test('invalid: interleaved duplicates each report against their own first occurrence', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: src(FOLD_A_B1, FOLD_B_B1, FOLD_A_B1, FOLD_B_B1),
|
|
filename: 'tests/host.test.cjs',
|
|
errors: [
|
|
{ messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 3 },
|
|
{ messageId: 'duplicateFoldMarker', data: { marker: 'b', firstLine: '2' }, line: 4 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// The batch label is provenance, not identity — a re-fold under a different
|
|
// batch must not evade the guard. This is the exact shape of #3271: #1975
|
|
// re-applied #1970's blocks.
|
|
test('invalid: a duplicate marker is reported even when the batch label differs', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: src(FOLD_A_B1, FOLD_A_B5),
|
|
filename: 'tests/host.test.cjs',
|
|
errors: [
|
|
{ messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 2 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── Title shapes that cannot be resolved statically ───────────────────────
|
|
|
|
test('invalid: substitution-free template-literal fold titles are resolved', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: src(
|
|
'__foldDescribe(`folded:a (consolidation epic #1969 B1 #1970)`, () => {});',
|
|
'__foldDescribe(`folded:a (consolidation epic #1969 B1 #1970)`, () => {});',
|
|
),
|
|
filename: 'tests/host.test.cjs',
|
|
errors: [
|
|
{ messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 2 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: non-literal fold titles are skipped without throwing', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{
|
|
code: src(
|
|
'const name = "folded:a";',
|
|
'const x = "a";',
|
|
'__foldDescribe(name, () => {});',
|
|
'__foldDescribe(name, () => {});',
|
|
'__foldDescribe(`folded:${x} (epic)`, () => {});',
|
|
'__foldDescribe(`folded:${x} (epic)`, () => {});',
|
|
'__foldDescribe(42, () => {});',
|
|
'__foldDescribe(42, () => {});',
|
|
),
|
|
filename: 'tests/host.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: __foldDescribe with no arguments does not throw', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{ code: src('__foldDescribe();', '__foldDescribe();'), filename: 'tests/host.test.cjs' },
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: an empty marker after the folded: prefix is not tracked', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{
|
|
code: src(
|
|
'__foldDescribe("folded: (consolidation epic #1969 B1 #1970)", () => {});',
|
|
'__foldDescribe("folded: (consolidation epic #1969 B1 #1970)", () => {});',
|
|
),
|
|
filename: 'tests/host.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// Property: marker identity is the whole whitespace-delimited token.
|
|
//
|
|
// This is the generative form of the #3271 correctness question. An
|
|
// implementation that keyed on the issue's `[a-z0-9-]*` slice would truncate
|
|
// at `.` and pass arm 1 while failing arm 2 on any pair like
|
|
// (`a.integration`, `a`) — which is exactly the tests/model-resolver.test.cjs
|
|
// false positive. The alphabet deliberately includes `.` and `_` so those
|
|
// pairs are generated, not hoped for.
|
|
//
|
|
// `fc` is already imported at the top of this file and used by the
|
|
// no-adhoc-markdown-parsing suite; this follows the same
|
|
// fc.property-driving-ruleTester shape.
|
|
test('property: a marker is identified by its whole token, so distinct markers never collide', () => {
|
|
const markerArb = fc
|
|
.array(fc.constantFrom('a', 'z', 'q', '0', '9', '-', '.', '_'), { minLength: 1, maxLength: 12 })
|
|
.map((chars) => chars.join(''));
|
|
|
|
const fold = (marker) =>
|
|
`__foldDescribe("folded:${marker} (consolidation epic #1969 B1 #1970)", () => {});`;
|
|
|
|
// Arm 1: the SAME marker twice is always reported exactly once, against
|
|
// the first occurrence.
|
|
fc.assert(
|
|
fc.property(markerArb, (marker) => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: src(fold(marker), fold(marker)),
|
|
filename: 'tests/host.test.cjs',
|
|
errors: [
|
|
{ messageId: 'duplicateFoldMarker', data: { marker, firstLine: '1' }, line: 2 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
}),
|
|
{ numRuns: 150, seed: 3271 },
|
|
);
|
|
|
|
// Arm 2: two DISTINCT markers never collide, however they differ.
|
|
fc.assert(
|
|
fc.property(markerArb, markerArb, (a, b) => {
|
|
fc.pre(a !== b);
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [{ code: src(fold(a), fold(b)), filename: 'tests/host.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
}),
|
|
{ numRuns: 150, seed: 3271 },
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── require-subprocess-timeout ────────────────────────────────────
|
|
|
|
describe('require-subprocess-timeout rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof requireSubprocessTimeout.create, 'function');
|
|
});
|
|
|
|
// ── INVALID cases (must error) ────────────────────────────────────────────
|
|
|
|
test('invalid: execFileSync("git", args, { cwd }) — object-literal options with no timeout key', () => {
|
|
ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const { execFileSync } = require('node:child_process');
|
|
const args = ['status'];
|
|
const cwd = '/repo';
|
|
execFileSync('git', args, { cwd });
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'requireSubprocessTimeout' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: execSync("npm ci", { encoding: "utf8" }) — object-literal options with no timeout key', () => {
|
|
ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const { execSync } = require('node:child_process');
|
|
execSync('npm ci', { encoding: 'utf8' });
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'requireSubprocessTimeout' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: spawnSync with a dotted childProcess.spawnSync callee and no timeout', () => {
|
|
ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const childProcess = require('node:child_process');
|
|
childProcess.spawnSync('git', ['log'], { cwd: '/repo', encoding: 'utf-8' });
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'requireSubprocessTimeout' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: execFileSync with NO options argument at all — categorically no timeout', () => {
|
|
ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const { execFileSync } = require('node:child_process');
|
|
execFileSync('git', ['status']);
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'requireSubprocessTimeout' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── VALID cases (must NOT error) ──────────────────────────────────────────
|
|
|
|
test('valid: execFileSync("git", args, { cwd, timeout: 30000 }) — timeout key present', () => {
|
|
ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const { execFileSync } = require('node:child_process');
|
|
const args = ['status'];
|
|
const cwd = '/repo';
|
|
execFileSync('git', args, { cwd, timeout: 30000 });
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: options as a pre-built identifier — execFileSync("git", args, opts) is not traced', () => {
|
|
ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const { execFileSync } = require('node:child_process');
|
|
const args = ['status'];
|
|
const opts = { cwd: '/repo', timeout: 30000 };
|
|
execFileSync('git', args, opts);
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: same unbounded call under a tests/** filename — rule is inert outside src/*.cts', () => {
|
|
ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const { execFileSync } = require('node:child_process');
|
|
execFileSync('git', ['status'], { cwd: '/repo' });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: allow-unbounded-subprocess suppression comment on the call line', () => {
|
|
ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const { execFileSync } = require('node:child_process');
|
|
execFileSync('git', ['status'], { cwd: '/repo' }); // allow-unbounded-subprocess: bounded by caller's own watchdog
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
});
|