* fix(#3914): retire n/no-process-exit where its successor governs
Epic #3889 criterion 5 — no phase closes with a guard added and its
predecessor left standing — is violated in the tree by the epic that wrote it.
local/require-registered-exit was registered on gsd-core/bin/**/*.cjs and
scripts/**/*.cjs, while n/no-process-exit stayed 'error' over a nine-glob block
covering those same two. Only the hooks 'off' exemption ever came down; the
predecessor's registration never did. Both rules have been enforcing the same
property on the same surfaces since P6.
Narrowed, not deleted. Seven of those nine globs have NO successor —
eslint-rules/, bin/lib/, pi/, examples/, vscode/, .kilo/, .opencode/ — so
deleting the rule outright would silently drop enforcement on all seven. That
is the inversion this epic has already hit three times: removing a coarse guard
because a narrower one exists somewhere it does not reach. Flat config is
last-match-wins and both successor blocks come after the nine-glob block, so
'n/no-process-exit': 'off' in exactly those two retires the predecessor
precisely where the successor governs and nowhere else.
The successor is strictly more precise: it permits process.exit only inside
terminateNow in cli-exit.cts, the single sanctioned terminator (ADR-3889 §3),
where n/no-process-exit permits none and would flag terminateNow's own
generated copy.
Asserted at the consumer's altitude via ESLint.calculateConfigForFile on real
paths, with the positive control that matters: n/no-process-exit is still
'error' on six of the seven successor-less globs, so a future edit that turns
this into a blanket disable goes red. bin/lib/ has no file in this checkout and
is reported as untested rather than given an invented path. Severity is
normalized across the string/numeric/array forms the API can return, and the
normalized value asserted — not truthiness.
Verified by running calculateConfigForFile myself on both superseded globs and
four controls before trusting the test.
Found and fixed inline: the change made an eslint-disable directive at
gsd-tools.cjs:257 partially unused, which --max-warnings 0 rejects; narrowed to
the one rule still in force.
Verification runs on the remote runner.
Refs #3914
* docs(#3914): the epic added three guards, it did not remove one
The audit reconciled the epic ledger against what actually landed. The net is
+3, not -1: four lint:generated-sync --check arms (gen-scripts-cli-exit,
gen-hooks-cli-exit, gen-exit-code-registry, gen-exit-code-docs) plus one rule,
against two retirements.
An epic whose thesis was consolidation ended with a larger guard surface than
it started with. The additions are each defensible; the claim that the total
fell was never true.
Two of the three prior errors in this amendment are mine. It said "Net -1 by
count" above terms reading -1 -1 +1 +1 +1, which sums to +1 — an arithmetic
error in the paragraph directly below the sentence arguing that an ADR about
honest accounting must not pad its own ledger. And the term list omitted two of
the four --check arms, which is what turns that +1 into the real +3.
Recorded rather than quietly rewritten. This ledger has now been wrong three
times — the original -2, the -1 that replaced it, and #3914's own table, which
states -1 above terms summing to 0 — and a written claim nobody checked against
the thing it describes is the exact failure this epic exists to close.
Refs #3914
* fix(#3914): make the successor actually supersede before retiring the predecessor
An isolated security review found that the previous commit turned off a guard
that was still doing work. Reproduced by executing both rules against a
fixture, not inferred:
const exit = 'exit';
process[exit](1);
n/no-process-exit flags it; local/require-registered-exit did not, because it
early-returned on callee.computed. So retiring the predecessor on
gsd-core/bin/**/*.cjs and scripts/**/*.cjs un-guarded that shape on precisely
the two globs this epic's exit contract cares most about.
This is the third time in this epic I have removed a coarse guard on the claim
that a narrower one covered it, without checking construct-level parity — after
the allowlist key-to-prefix-to-exact-membership sequence and the band
ranges-to-categories one. The rule is the same every time: a narrower guard
supersedes a coarser one only where it demonstrably reaches at least as far,
and "demonstrably" means executing both against the constructs, not reading
either.
The successor now resolves computed property access for the statically
determinable cases — a string Literal, and an Identifier bound once to a string
Literal, resolved through scope — and leaves genuinely dynamic properties
alone so the rule does not over-fire. Measured after the fix: plain
process.exit flagged, process['exit']() flagged, process[exit]() flagged,
process[globalThis.k]() not flagged. That makes it a strict superset of the
predecessor on these globs, since process['exit']() was caught by NEITHER rule
before.
The second finding is worse than the first, because it was reasoning rather
than oversight. My justification comment claimed n/no-process-exit "would flag
terminateNow's own generated copy here". It would not — that file is in the
global ignore list, so neither rule ever lints it. There was no conflict to
resolve; I wrote a rationale I had not checked, in a change whose entire
subject is written claims nobody verified. Both comment blocks now state the
real basis.
The tests that should have caught this asserted only rule SEVERITY per glob and
never construct REACH, which is exactly how a coverage hole passed. A parity
matrix now pins all five shapes, including a RED/GREEN regression pin against
an inlined reproduction of the pre-fix rule — inlined rather than loaded from
HEAD, because HEAD resolves to the fixed commit under the remote runner and
would silently stop testing anything.
Verification runs on the remote runner.
Refs #3914
* fix(#3914): the two exit rules are complementary — keep both
Reverts this branch's retirement of n/no-process-exit. The premise was wrong
twice, and the second review proved the change itself was wrong.
I claimed local/require-registered-exit was a strict superset on
gsd-core/bin/**/*.cjs and scripts/**/*.cjs. Measured, successor vs predecessor:
function f(exit) { process[exit](1); } 0 vs 1
let exit='exit'; exit='exit'; process[exit]() 0 vs 1
const { exit } = ...; process[exit](1) 0 vs 1
plus for-of bindings, let-then-assign, var redeclaration, catch params, and an
undeclared global named exit. The predecessor matches any identifier NAMED
exit however it is bound; the successor resolves only a string literal or a
single-write const. It never was a superset — I asserted the relationship after
fixing one construct and did not re-check the rest.
The justification was independently false: all three generated cli-exit copies
are in the global ignore list, so n/no-process-exit was never flagging
terminateNow. There was no conflict to resolve. I wrote a rationale I had not
verified, in the phase whose subject is written claims nobody checked.
So criterion 5 does not apply to this pair. They are not predecessor and
successor — they are complementary, each catching constructs the other misses.
The epic's criterion assumed a replacement relationship that does not exist
here, and retiring either rule loses real coverage. The ADR ledger now says so
with the measured shapes.
What survives is the genuine improvement: the computed-property strengthening.
local/require-registered-exit now catches process['exit'](1) and optional-chain
terminators like process?.[k]?.(1), which NEITHER rule caught before, while
correctly ignoring a genuinely dynamic property so it does not over-fire.
The parity tests are rewritten to assert what is true rather than what I wanted
to be true: a bidirectional matrix where each rule is shown catching shapes the
other misses. The previous matrix tested only the four shapes where the
successor wins, which is precisely why the regression shipped — a test set
selected to confirm the thesis.
Also corrected: a stale ADR sentence claiming a third wrong ledger version that
does not exist (the table it described now reads +3 over terms summing to +3),
and a changeset whose stated motivation was the false generated-copy conflict.
Verification runs on the remote runner.
Refs #3914
* fix(#3914): the exemption term was a no-op — the net is +4
Fourth correction to this ledger, and a fourth error of the same kind.
Every version counted removing the n/no-process-exit 'off' entry from the hooks
block as -1. Measured: calculateConfigForFile returns undefined for that rule on
hooks/**. It was never registered there, and no broader block sets it globally,
so the 'off' entry overrode nothing and removing it changed no enforcement at
all. A no-op removal, not a guard removal — the same category error as counting
baseline acknowledgement entries: a thing that is not a guard, in guard units.
It is misattributed too; that block came down in d98b55562 (#3910), already on
next before this branch existed.
So the epic added FOUR guards, not three.
This surfaced from a test of mine that overclaimed. I asserted n/no-process-exit
was error on "all nine CommonJS/hook globs" — but hooks is not one of the nine,
and the rule resolves to undefined there. Fixing the test to match reality is
what exposed the ledger term, which is the argument for tests that assert
identity rather than a comfortable shape.
The hooks state is now pinned explicitly rather than glossed: n/no-process-exit
unregistered, local/require-registered-exit error. It is mildly surprising and
therefore worth a test.
Also updates a pre-existing test that documented the old name-based-only
boundary as intentional. The computed-property strengthening deliberately moves
that boundary — process['exit'](0) was caught by NEITHER rule before — so the
test now asserts the new contract and cites the ADR, rather than being left to
fail or the rule weakened to satisfy it. A contract change should read as
deliberate in the test that pins it.
Verification runs on the remote runner.
Refs #3914
* chore(#3914): backfill changeset pr number to 4018
---------
Co-authored-by: sim <sim@local>
3775 lines
134 KiB
JavaScript
3775 lines
134 KiB
JavaScript
'use strict';
|
|
|
|
// docs-guard-exempt: 'docs/readme.md' appears only inside literal RuleTester
|
|
// fixture `code` strings (sample source text fed to no-source-grep for AST
|
|
// linting) — this file never itself reads a real docs/ file off disk.
|
|
|
|
/**
|
|
* 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
|
|
* - local/require-registered-exit
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { RuleTester, ESLint, Linter } = require('eslint');
|
|
const path = require('node:path');
|
|
const fc = require('fast-check');
|
|
const pluginN = require('eslint-plugin-n');
|
|
|
|
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 requireRegisteredExit = require('../eslint-rules/require-registered-exit.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' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ─── #3987: camelCase/suffixed evasion (elapsedMs escaped the exact-name
|
|
// regex; CI caught the resulting flake instead of lint catching the
|
|
// anti-pattern) ───────────────────────────────────────────────────────
|
|
|
|
test('invalid: assert on .elapsedMs property (the exact identifier that evaded the pre-widening exact-name regex)', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
const result = { elapsedMs: 150 };
|
|
assert.ok(result.elapsedMs < 200);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert on tookMs/durationMs/msElapsed/elapsedTime/startMs/endMs — camelCase family the widened rule must catch', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `assert.ok(x.tookMs < 500);`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
{
|
|
code: `assert.ok(x.durationMs > 0);`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
{
|
|
code: `assert.ok(x.msElapsed > 0);`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
{
|
|
code: `assert.ok(x.elapsedTime < 1000);`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
{
|
|
code: `assert.ok(x.endMs - x.startMs < 100);`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: non-timing camelCase identifiers containing "ms" as a plain substring do not flag (params/items/forms/terms/dirnames — and a configured-bound timeoutMs)', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [
|
|
{ code: `assert.equal(params.length, 2);`, filename: 'tests/foo.test.cjs' },
|
|
{ code: `assert.equal(items.length, 0);`, filename: 'tests/foo.test.cjs' },
|
|
{ code: `assert.ok(forms.valid);`, filename: 'tests/foo.test.cjs' },
|
|
{ code: `assert.equal(terms.length, 3);`, filename: 'tests/foo.test.cjs' },
|
|
{ code: `assert.equal(dirnames.length, 1);`, filename: 'tests/foo.test.cjs' },
|
|
{
|
|
// A configured bound (deterministic pass-through), not a measured
|
|
// wall-clock elapsed value — must not be caught by the widening.
|
|
code: `assert.equal(seen[0].timeoutMs, HOOK_FANOUT_TIMEOUT_MS);`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── 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');
|
|
});
|
|
|
|
// ── #3951 B6(b): filename-gate reach — src/**/*.cts, subdirectories included ──
|
|
// The gate used to be `/(?:^|\/)src\/[^/]+\.cts$/` (flat-only), which
|
|
// silently exempted 28 files in src/ subdirectories
|
|
// (health-diagnostic-rules/, installer-migrations/, observability/,
|
|
// host-integration-adapters/, vendor/) even though the eslint.config.mjs
|
|
// registration (src/**/*.cts) already covers them. These three rows pin
|
|
// that the gate and the registration agree — a subdirectory path is
|
|
// linted, a flat src/ path keeps working, and a path outside src/ stays
|
|
// exempt.
|
|
|
|
test('invalid: a table-regex fingerprint under a src/ SUBDIRECTORY is linted (gate reach)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`const cellPattern = /\|[^|]*\|/;`,
|
|
filename: 'src/health-diagnostic-rules/some-check.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: the same fingerprint under a FLAT src/*.cts path still is linted (regression, not exempt)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`const cellPattern = /\|[^|]*\|/;`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: the same fingerprint OUTSIDE src/+tests/+scripts/ is NOT linted (gate and registration must agree)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const cellPattern = /\|[^|]*\|/;`,
|
|
filename: 'gsd-core/bin/lib/foo.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── #3951 Rung B: filename-gate reach — tests/**/*.cjs and scripts/**/*.cjs ──
|
|
// The gate self-restricted to src/**/*.cts only. eslint.config.mjs also
|
|
// registers the rule on tests/**/*.cjs and scripts/**/*.cjs (Rung B); these
|
|
// rows pin that the gate and the registration agree for BOTH new globs —
|
|
// a path each registration covers must not be silently skipped by the
|
|
// gate, and a path outside all three globs stays exempt (mirrors the
|
|
// src/ subdirectory rows above, which pinned the same contract for #3951
|
|
// B6(b)).
|
|
|
|
test('invalid: a table-regex fingerprint under tests/**/*.cjs is linted (gate/registration parity)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`const cellPattern = /\|[^|]*\|/;`,
|
|
filename: 'tests/some.test.cjs',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: a table-regex fingerprint under a tests/ SUBDIRECTORY is linted (gate reach)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`const cellPattern = /\|[^|]*\|/;`,
|
|
filename: 'tests/fixtures/some.test.cjs',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: a table-regex fingerprint under scripts/**/*.cjs is linted (gate/registration parity)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`const cellPattern = /\|[^|]*\|/;`,
|
|
filename: 'scripts/some-tool.cjs',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── 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: [],
|
|
});
|
|
});
|
|
|
|
// #3951 Rung B: the gate's reach is src/**/*.cts, tests/**/*.cjs and
|
|
// scripts/**/*.cjs — the same fingerprints under those three roots are now
|
|
// linted, and the negative space (a path outside all three) stays exempt.
|
|
test('invalid: fence-block-strip and section-collect fingerprints under tests/ and scripts/ are now flagged (gate/registration parity)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// Same fence-block-strip regex under tests/**/*.cjs → now linted
|
|
code: String.raw`const stripFences = /~~~[\s\S]*?~~~/;`,
|
|
filename: 'tests/some.test.cjs',
|
|
errors: [{ messageId: 'fenceRegex' }],
|
|
},
|
|
{
|
|
// Same section-collect regex under scripts/**/*.cjs → now linted
|
|
code: String.raw`const p = /(##\s*X\n)([\s\S]*?)(?=\n##|$)/;`,
|
|
filename: 'scripts/helper.cjs',
|
|
errors: [{ messageId: 'sectionCollect' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: the same fence-block-strip fingerprint OUTSIDE src/+tests/+scripts/ stays NOT flagged (negative space preserved)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const stripFences = /~~~[\s\S]*?~~~/;`,
|
|
filename: 'gsd-core/bin/lib/foo.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: [],
|
|
});
|
|
});
|
|
|
|
// #3951 Rung B: table-regex under scripts/**/*.cjs is now linted (gate/
|
|
// registration parity); the same fingerprint outside src/+tests/+scripts/
|
|
// stays exempt (negative space preserved).
|
|
test('invalid: table-regex under scripts/**/*.cjs is now flagged (gate/registration parity)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`const rowRe = /\|[^|]*\|/;`,
|
|
filename: 'scripts/helper.cjs',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: the same table-regex fingerprint OUTSIDE src/+tests/+scripts/ stays NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const rowRe = /\|[^|]*\|/;`,
|
|
filename: 'gsd-core/bin/lib/foo.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: [],
|
|
});
|
|
});
|
|
|
|
// #3951 Rung B: a new RegExp(...) table-regex under tests/**/*.cjs is now
|
|
// linted; the same fingerprint outside src/+tests/+scripts/ stays exempt.
|
|
test('invalid: new RegExp(...) table-regex under tests/**/*.cjs is now flagged (gate/registration parity)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`const rowRe = new RegExp('\\|[^|]*\\|');`,
|
|
filename: 'tests/some.test.cjs',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: the same new RegExp(...) table-regex fingerprint OUTSIDE src/+tests/+scripts/ stays NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const rowRe = new RegExp('\\|[^|]*\\|');`,
|
|
filename: 'gsd-core/bin/lib/foo.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: [],
|
|
});
|
|
});
|
|
|
|
// #3951 Rung B: an ad-hoc .replace() mutation under scripts/**/*.cjs is now
|
|
// linted (both the CallExpression and its Literal argument fire); the same
|
|
// fingerprint outside src/+tests/+scripts/ stays exempt.
|
|
test('invalid: .replace() ad-hoc mutation under scripts/**/*.cjs is now flagged (gate/registration parity)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`roadmapContent.replace(/\|[^|]*\|/, 'x');`,
|
|
filename: 'scripts/helper.cjs',
|
|
errors: [{ messageId: 'adhocReplaceMutation' }, { messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: the same .replace() ad-hoc mutation fingerprint OUTSIDE src/+tests/+scripts/ stays NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`roadmapContent.replace(/\|[^|]*\|/, 'x');`,
|
|
filename: 'gsd-core/bin/lib/foo.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: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── require-registered-exit (#3910, epic #3889 Phase 6) ──────────────────
|
|
//
|
|
// See .gsd/phase/enhance-3910-ban-raw-terminator/50-test-matrix.md for the
|
|
// enumerated input-class matrix these tests implement.
|
|
|
|
describe('require-registered-exit rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof requireRegisteredExit.create, 'function');
|
|
});
|
|
|
|
// ── Positive control: one per registered glob (matrix rows 1-4) ──────────
|
|
|
|
test('invalid: process.exit() at top level — src/**/*.cts glob', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `process.exit(0);`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'rawProcessExit' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: process.exit() at top level — scripts/**/*.cjs glob', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `process.exit(1);`,
|
|
filename: 'scripts/some-script.cjs',
|
|
errors: [{ messageId: 'rawProcessExit' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: process.exit() at top level — hooks/**/*.js glob', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `process.exit(2);`,
|
|
filename: 'hooks/some-hook.js',
|
|
errors: [{ messageId: 'rawProcessExit' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: process.exit() at top level — gsd-core/bin/**/*.cjs glob', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `process.exit(1);`,
|
|
filename: 'gsd-core/bin/gsd-tools.cjs',
|
|
errors: [{ messageId: 'rawProcessExit' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── Negative control: process.exitCode must NEVER be flagged (matrix rows 5-8) ──
|
|
//
|
|
// Required negative control: conflating process.exitCode (the CORRECT
|
|
// drain-then-exit pattern) with process.exit() is what inflated this
|
|
// epic's original raw-exit census 2x.
|
|
|
|
test('valid: process.exitCode = 1 is not flagged — src/**/*.cts glob', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [
|
|
{ code: `process.exitCode = 1;`, filename: 'src/some-module.cts' },
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: process.exitCode = 1 is not flagged — scripts/**/*.cjs glob', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [
|
|
{ code: `process.exitCode = 1;`, filename: 'scripts/some-script.cjs' },
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: process.exitCode = 1 is not flagged — hooks/**/*.js glob', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [
|
|
{ code: `process.exitCode = 1;`, filename: 'hooks/some-hook.js' },
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: process.exitCode = 1 is not flagged — gsd-core/bin/**/*.cjs glob', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [
|
|
{ code: `process.exitCode = 1;`, filename: 'gsd-core/bin/gsd-tools.cjs' },
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── Allowlist boundary: the ONE sanctioned terminator (matrix rows 9-11) ──
|
|
|
|
test('valid: process.exit() lexically inside a function named terminateNow is allowlisted', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
function terminateNow(outcome, payload) {
|
|
try {
|
|
process.exit(0);
|
|
} catch (err) {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
`,
|
|
filename: 'src/cli-exit.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: near-miss — same shape, function named something else IS flagged', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
function notTerminateNow(outcome, payload) {
|
|
process.exit(0);
|
|
}
|
|
`,
|
|
filename: 'src/cli-exit.cts',
|
|
errors: [{ messageId: 'rawProcessExit' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: a top-level process.exit() is flagged even when an unrelated terminateNow exists elsewhere in the same file (allowlist is structural nesting, not file-wide)', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
function terminateNow() {
|
|
// unrelated to the top-level exit below
|
|
}
|
|
process.exit(0);
|
|
`,
|
|
filename: 'src/cli-exit.cts',
|
|
errors: [{ messageId: 'rawProcessExit' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── Independence (matrix rows 12-13) ──────────────────────────────────────
|
|
|
|
test('valid: a bare (non-process) exit(...) call is not flagged', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
function exit(code) { return code; }
|
|
exit(0);
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// #3914 (epic #3889 Phase 7 follow-up) moved this boundary on purpose: see
|
|
// docs/adr/3889-process-exit-contract.md ~:350-362. Previously
|
|
// process['exit'](0) was caught by NEITHER this rule (name-based matching
|
|
// only) nor n/no-process-exit's Identifier-only property match, so it was
|
|
// a genuine, silent evasion. The rule now resolves a string-literal
|
|
// computed property the same as a dotted one, closing that gap. This is
|
|
// NOT a weakening — the old "not flagged" behavior documented below is
|
|
// superseded and should never be restored to make this test pass again.
|
|
test('invalid: computed member access process["exit"](0) IS flagged (boundary moved by #3914, ADR-3889)', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `process['exit'](0);`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'rawProcessExit' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── Finding 5: KNOWN LIMITS, pinned — the rule does NOT catch these evasions
|
|
// today. These tests do not endorse the patterns; they pin the CURRENT
|
|
// behavior so that a future change which starts catching one of them is a
|
|
// visible, deliberate diff (an intentionally-failing pinning test) rather
|
|
// than a silent behavior change discovered later. See the rule's header
|
|
// doc comment for the same limits documented for a human reader.
|
|
|
|
test('KNOWN LIMIT (pinned, not endorsed): aliasing process.exit to a local binding evades detection', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [
|
|
{
|
|
code: `const e = process.exit; e(1);`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('KNOWN LIMIT (pinned, not endorsed): process.exit.call(...) evades detection', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [
|
|
{
|
|
code: `process.exit.call(null, 1);`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('KNOWN LIMIT (pinned, not endorsed): process.exit.apply(...) evades detection', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [
|
|
{
|
|
code: `process.exit.apply(null, [1]);`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── Allowlist is basename-AND-name gated, not name-only (finding: any file
|
|
// named terminateNow would otherwise inherit the allowlist for free) ───────
|
|
|
|
test('invalid: a function named terminateNow in a file that is NOT cli-exit.cts is still flagged', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
function terminateNow(outcome, payload) {
|
|
process.exit(0);
|
|
}
|
|
`,
|
|
filename: 'src/some-other-module.cts',
|
|
errors: [{ messageId: 'rawProcessExit' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: a function named terminateNow in gsd-core/bin/gsd-tools.cjs (not cli-exit.cts) is still flagged', () => {
|
|
ruleTester.run('require-registered-exit', requireRegisteredExit, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
function terminateNow(outcome, payload) {
|
|
process.exit(0);
|
|
}
|
|
`,
|
|
filename: 'gsd-core/bin/gsd-tools.cjs',
|
|
errors: [{ messageId: 'rawProcessExit' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── Finding 4: registration proof, not just filename-agnostic rule logic ──
|
|
//
|
|
// The RuleTester cases above vary `filename` directly, which RuleTester
|
|
// never resolves against eslint.config.mjs — they prove the rule's AST
|
|
// logic, not that it is actually WIRED to the four globs. This proves
|
|
// wiring: it resolves the real config for one representative path per
|
|
// glob and asserts the rule is enabled there. This test fails if a glob
|
|
// registration is ever removed from eslint.config.mjs (verified live:
|
|
// temporarily deleting the gsd-core/bin/**/*.cjs registration flipped
|
|
// this test red before it was restored).
|
|
test('the rule is registered at error for one representative path per glob in the real config', async () => {
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
const eslint = new ESLint({ cwd: REPO_ROOT });
|
|
const representativePaths = [
|
|
path.join(REPO_ROOT, 'src', 'cli-exit.cts'),
|
|
path.join(REPO_ROOT, 'scripts', 'affected-tests-lib.cjs'),
|
|
path.join(REPO_ROOT, 'hooks', 'gsd-check-update.js'),
|
|
path.join(REPO_ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs'),
|
|
];
|
|
for (const p of representativePaths) {
|
|
// Sequential config resolution (not a hot loop) — no-await-in-loop is
|
|
// not registered on this glob, so no disable directive is needed here.
|
|
const config = await eslint.calculateConfigForFile(p);
|
|
assert.deepStrictEqual(
|
|
config.rules['local/require-registered-exit'],
|
|
[2],
|
|
`expected local/require-registered-exit to be registered at error for ${path.relative(REPO_ROOT, p)}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ── #3914 (epic #3889 criterion 5, CORRECTED): the two rules are
|
|
// COMPLEMENTARY, not predecessor/successor ──────────────────────────────
|
|
//
|
|
// An isolated review, plus live measurement (see the parity matrix below),
|
|
// proved the original #3914 retirement of n/no-process-exit on
|
|
// gsd-core/bin/**/*.cjs and scripts/**/*.cjs was WRONG: local/require-
|
|
// registered-exit is NOT a strict superset there. n/no-process-exit's
|
|
// esquery selector `[property.name="exit"]` matches ANY MemberExpression
|
|
// property node whose own AST `.name` reads `exit`, computed or not,
|
|
// regardless of whether the value is statically resolvable — so it catches
|
|
// a function parameter, a destructured binding, a for-of loop variable, a
|
|
// reassign-to-the-same-string, a `var` redeclaration, a catch param, or an
|
|
// undeclared global, all named `exit`, none of which
|
|
// local/require-registered-exit's narrower (declaration-must-resolve-to-a-
|
|
// single-string-literal) analysis reaches. Conversely, the successor
|
|
// catches a string-literal computed property (`process['exit']()`) and an
|
|
// optional-chained computed property, neither of which the predecessor's
|
|
// Identifier-only property match reaches. Both rules therefore remain
|
|
// 'error' everywhere they were already registered; this section documents
|
|
// and pins that complementary relationship instead of a supersession that
|
|
// does not exist.
|
|
//
|
|
// n/no-process-exit resolves to a bare string OR an array whose first
|
|
// element is severity (possibly numeric 0/1/2) depending on how ESLint
|
|
// merges the flat config; normalize before asserting.
|
|
function normalizeSeverity(entry) {
|
|
const raw = Array.isArray(entry) ? entry[0] : entry;
|
|
if (raw === 'off' || raw === 0) return 'off';
|
|
if (raw === 'warn' || raw === 1) return 'warn';
|
|
if (raw === 'error' || raw === 2) return 'error';
|
|
return raw;
|
|
}
|
|
|
|
// n/no-process-exit is registered ('error') on exactly the nine globs of
|
|
// the shared CommonJS block (eslint.config.mjs ~:476-486): gsd-core/bin/**/*.cjs,
|
|
// scripts/**/*.cjs, eslint-rules/**/*.cjs, bin/lib/**/*.cjs, pi/**/*.cjs,
|
|
// examples/**/*.cjs, vscode/*.js, .kilo/plugins/*.js, .opencode/plugins/*.js.
|
|
// hooks/**/*.js is NOT one of them — the hooks block (eslint.config.mjs
|
|
// ~:594-619) registers the `n` plugin (for n/no-path-concat) but never sets
|
|
// the n/no-process-exit rule key, so it resolves to undefined there, not
|
|
// 'off' and not 'error'. Asserting 'error' for a hooks path is a false
|
|
// claim about the rule's actual reach; see the companion test below, which
|
|
// pins that undefined resolution explicitly.
|
|
// bin/lib/**/*.cjs is skipped here: it is a build-time-generated directory
|
|
// with no file checked into this repo, so there is no real representative
|
|
// path to resolve config for. Eight of the nine globs are exercised.
|
|
test('n/no-process-exit is error on eight of its nine CommonJS globs (bin/lib/** has no checked-in file; no supersession)', async () => {
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
const eslint = new ESLint({ cwd: REPO_ROOT });
|
|
const allPaths = [
|
|
path.join(REPO_ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs'),
|
|
path.join(REPO_ROOT, 'scripts', 'affected-tests-lib.cjs'),
|
|
path.join(REPO_ROOT, 'eslint-rules', 'no-source-grep.cjs'),
|
|
path.join(REPO_ROOT, 'pi', 'gsd.cjs'),
|
|
path.join(REPO_ROOT, 'examples', 'dynamic-context-management', 'demo.cjs'),
|
|
path.join(REPO_ROOT, 'vscode', 'extension.js'),
|
|
path.join(REPO_ROOT, '.kilo', 'plugins', 'gsd-core.js'),
|
|
path.join(REPO_ROOT, '.opencode', 'plugins', 'gsd-core.js'),
|
|
];
|
|
for (const p of allPaths) {
|
|
const config = await eslint.calculateConfigForFile(p);
|
|
assert.strictEqual(
|
|
normalizeSeverity(config.rules['n/no-process-exit']),
|
|
'error',
|
|
`expected n/no-process-exit to be error for ${path.relative(REPO_ROOT, p)}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// hooks/**/*.js is NOT among n/no-process-exit's registered globs (see
|
|
// above): the rule resolves to undefined there, while local/require-
|
|
// registered-exit — the successor rule for this surface — is 'error'.
|
|
// This pins the real, slightly surprising state (an unregistered rule,
|
|
// not an 'off' rule) rather than papering over it with a false 'error'
|
|
// claim.
|
|
test('on hooks/** n/no-process-exit is unregistered (undefined) while local/require-registered-exit is error', async () => {
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
const eslint = new ESLint({ cwd: REPO_ROOT });
|
|
const p = path.join(REPO_ROOT, 'hooks', 'gsd-check-update.js');
|
|
const config = await eslint.calculateConfigForFile(p);
|
|
assert.strictEqual(
|
|
config.rules['n/no-process-exit'],
|
|
undefined,
|
|
`expected n/no-process-exit to be unregistered for ${path.relative(REPO_ROOT, p)}`,
|
|
);
|
|
assert.strictEqual(
|
|
normalizeSeverity(config.rules['local/require-registered-exit']),
|
|
'error',
|
|
`expected local/require-registered-exit to be error for ${path.relative(REPO_ROOT, p)}`,
|
|
);
|
|
});
|
|
|
|
test('local/require-registered-exit is error on all four of its globs', async () => {
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
const eslint = new ESLint({ cwd: REPO_ROOT });
|
|
const registeredPaths = [
|
|
path.join(REPO_ROOT, 'src', 'cli-exit.cts'),
|
|
path.join(REPO_ROOT, 'scripts', 'affected-tests-lib.cjs'),
|
|
path.join(REPO_ROOT, 'hooks', 'gsd-check-update.js'),
|
|
path.join(REPO_ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs'),
|
|
];
|
|
for (const p of registeredPaths) {
|
|
const config = await eslint.calculateConfigForFile(p);
|
|
assert.strictEqual(
|
|
normalizeSeverity(config.rules['local/require-registered-exit']),
|
|
'error',
|
|
`expected local/require-registered-exit to be error for ${path.relative(REPO_ROOT, p)}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ── #3914 (corrected): bidirectional construct-parity matrix ────────────
|
|
//
|
|
// The severity-registration tests above prove both rules are 'error' on
|
|
// the shared globs — they don't prove the two rules' AST reach relative to
|
|
// each other. This matrix lints each measured shape through BOTH rules
|
|
// directly (via `Linter`) and asserts the true, bidirectional relationship:
|
|
// each rule catches constructs the other misses; neither over-fires on a
|
|
// genuinely dynamic property.
|
|
describe('construct parity with n/no-process-exit (complementary, not predecessor/successor)', () => {
|
|
const FILES_GLOB = ['**/*.js', '**/*.cjs', '**/*.cts'];
|
|
|
|
function lintLocal(ruleModule, code, filename) {
|
|
const linter = new Linter();
|
|
const config = {
|
|
files: FILES_GLOB,
|
|
languageOptions: { ecmaVersion: 2022, sourceType: 'commonjs' },
|
|
plugins: { local: { rules: { 'require-registered-exit': ruleModule } } },
|
|
rules: { 'local/require-registered-exit': 'error' },
|
|
};
|
|
return linter.verify(code, config, { filename });
|
|
}
|
|
|
|
function lintPredecessor(code, filename) {
|
|
const linter = new Linter();
|
|
const config = {
|
|
files: FILES_GLOB,
|
|
languageOptions: { ecmaVersion: 2022, sourceType: 'commonjs' },
|
|
plugins: { n: pluginN },
|
|
rules: { 'n/no-process-exit': 'error' },
|
|
};
|
|
return linter.verify(code, config, { filename });
|
|
}
|
|
|
|
test('process.exit(1) — BOTH rules flag (plain member access)', () => {
|
|
const code = 'process.exit(1);';
|
|
assert.strictEqual(lintPredecessor(code, 'x.cjs').length, 1);
|
|
assert.strictEqual(lintLocal(requireRegisteredExit, code, 'x.cjs').length, 1);
|
|
});
|
|
|
|
test("process['exit'](1) — successor-ONLY (string-literal computed property; predecessor's Identifier-only property match cannot see it)", () => {
|
|
const code = "process['exit'](1);";
|
|
assert.strictEqual(lintPredecessor(code, 'x.cjs').length, 0);
|
|
assert.strictEqual(lintLocal(requireRegisteredExit, code, 'x.cjs').length, 1);
|
|
});
|
|
|
|
test('process?.[k]?.(1) with k statically "exit" — successor-ONLY (optional-chained computed property)', () => {
|
|
const code = "const k = 'exit'; process?.[k]?.(1);";
|
|
assert.strictEqual(lintPredecessor(code, 'x.cjs').length, 0);
|
|
assert.strictEqual(lintLocal(requireRegisteredExit, code, 'x.cjs').length, 1);
|
|
});
|
|
|
|
test('function f(exit) { process[exit](1); } — predecessor-ONLY (parameter named exit; not a resolvable literal binding)', () => {
|
|
const code = 'function f(exit) { process[exit](1); }';
|
|
assert.strictEqual(lintPredecessor(code, 'x.cjs').length, 1);
|
|
assert.strictEqual(lintLocal(requireRegisteredExit, code, 'x.cjs').length, 0);
|
|
});
|
|
|
|
test("let exit='exit'; exit='exit'; process[exit]() — predecessor-ONLY (reassigned-to-same-value binding disqualifies the successor's single-write check)", () => {
|
|
const code = "let exit = 'exit'; exit = 'exit'; process[exit]();";
|
|
assert.strictEqual(lintPredecessor(code, 'x.cjs').length, 1);
|
|
assert.strictEqual(lintLocal(requireRegisteredExit, code, 'x.cjs').length, 0);
|
|
});
|
|
|
|
test('const { exit } = obj; process[exit](1) — predecessor-ONLY (destructured binding; no string-literal initializer to resolve)', () => {
|
|
const code = "const { exit } = require('x'); process[exit](1);";
|
|
assert.strictEqual(lintPredecessor(code, 'x.cjs').length, 1);
|
|
assert.strictEqual(lintLocal(requireRegisteredExit, code, 'x.cjs').length, 0);
|
|
});
|
|
|
|
test('process[someRuntimeValue](1) with a genuinely dynamic value — NEITHER rule flags (no over-firing)', () => {
|
|
const code =
|
|
'function pick(v) { return v; } '
|
|
+ 'const someRuntimeValue = pick("exit"); '
|
|
+ 'process[someRuntimeValue](1);';
|
|
assert.strictEqual(lintPredecessor(code, 'x.cjs').length, 0);
|
|
assert.strictEqual(lintLocal(requireRegisteredExit, code, 'x.cjs').length, 0);
|
|
});
|
|
|
|
test('a sanctioned process.exit() inside terminateNow in cli-exit.cts is still not flagged by the successor (allowlist unaffected)', () => {
|
|
const code = 'function terminateNow(outcome, payload) {\n process.exit(2);\n}';
|
|
assert.strictEqual(lintLocal(requireRegisteredExit, code, 'src/cli-exit.cts').length, 0);
|
|
});
|
|
|
|
test('a sanctioned process.exit() inside terminateNow is still flagged by the predecessor (why both directives are needed at that call site)', () => {
|
|
const code = 'function terminateNow(outcome, payload) {\n process.exit(2);\n}';
|
|
assert.strictEqual(lintPredecessor(code, 'src/cli-exit.cts').length, 1);
|
|
});
|
|
});
|
|
});
|