* fix(#3951): two lint rules that could not reach the code they govern B6 names two widenings. Measuring them first turned up a defect the criterion did not know about, and refuted the reason it gave for one of them. 1. no-adhoc-markdown-parsing self-gates on its own filename. Lines 107-110 short-circuit create() to {} unless the path matches /(?:^|\/)src\/[^/]+\.cts$/. B6 says to widen the files: glob in eslint.config.mjs - but doing only that ships an INERT rule, because the gate still returns {} for every new path. Both halves have to change, and the gate is the load-bearing one. That same regex hides a live hole: [^/]+ is FLAT-ONLY, so it requires the file to sit directly in src/. The registered glob is src/**/*.cts, which includes subdirectories. 28 .cts files - health-diagnostic-rules/ (10), installer-migrations/ (11), observability/ (3), host-integration-adapters/ (2), vendor/ (2) - are inside the registered glob and silently skipped. Measured with the gate neutralized: 0 violations there today. The hole is hiding nothing right now, and is fixed anyway, because "no violations today" is not a property that keeps holding. The fix is not invented: require-subprocess-timeout.cjs:196 already carries the correct form of this guard, /(?:^|\/)src\/.*\.cts$/ with .*, one directory over. Checked the other 21 rules for the same bug - no-adhoc-regex-escape and no-private-binary-resolution short-circuit only to exempt their own seam file, which is the right shape, and no-crlf-fragile-split has no filename gate at all. This bug is unique to the one rule. 2. no-adhoc-regex-escape could not see the shape that actually occurs. Line 396 gated the whole UNSAFE-NEW-REGEXP arm on arg.type === 'Identifier'. Every check below it - the _SOURCE provenance check, the isSoleReturnOfOwnParameter shape - lives inside that branch, so new RegExp(obj['key']) and new RegExp(cfg.pattern) were never examined at all. Runtime data arrives as a property access far more often than as a bare identifier, which is exactly why this rule never fired on the #3477 ReDoS. Widened to MemberExpression, measured by AST walk across all five registered blocks rather than by grep. 27 sites, zero TSAsExpression: 18 safe new RegExp(X.source, flags) -> exempted, keyed strictly on the PROPERTY being `source`, never on the object. Keying on the object would wave through X.anything and buy nothing. B6 estimated ~10; that was an undercount. 3 _SOURCE-suffixed constants reached through a required module namespace (phaseId.BRACKET_PHASE_TOKEN_SOURCE) -> the same provenance-exempt class the rule already recognizes for bare identifiers, extended to reach them. Without this the widening produces 3 false flags. 6 real findings -> marked, each a test extracting a pattern from a shipped file at test time, where the runtime contract IS the product. Deliberately the NARROW MemberExpression form. The rule's own isSoleReturnOfOwnParameter doc comment records that an earlier broad "any non-literal identifier" heuristic produced ~25 false positives and was rejected; a re-run of the census after this change flags exactly the 6 above and nothing else. Verified by execution, not by reading: the gate now accepts src/<subdir>/x.cts, still accepts flat src/x.cts, and still exempts paths outside src/ - each pinned by a test proven to fail against the old regex. build:lib, lint and lint:ci all exit 0. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3951): give no-adhoc-markdown-parsing its reach, and fix the 80 parses it finds The rule self-gates on filename AND is registered on one glob, so widening either half alone is inert. Both move here: the gate now accepts tests/**/*.cjs and scripts/**/*.cjs alongside src/**/*.cts, and eslint.config.mjs registers it on the same two. A test pins that the gate and the registration AGREE, in both directions. The original defect was a gate narrower than its registration; the failure mode of this fix is a gate wider than its registration. Both are silent, so the test asserts the pair rather than either half. 80 violations across 43 files, all in tests/, zero in scripts/. 70 are routed through the existing seams - scanFencedBlocks, collectSection, stripFencedCode, tokenizeHeadings from markdown-sectionizer; splitTableRow, parseMarkdownTable, findTableWithColumns from markdown-table. Headerless STATE.md tables use splitTableRow per line, because parseMarkdownTable needs a real delimiter row. 10 are suppressed, 12.5%, well under the third that would have meant the rule is mis-scoped for tests/ rather than the tests carrying debt. Each names its reason: three regression guards (#3873 / bug-#21) are deliberately independent of the generator's own fence handling, and routing them through the seam would have them test the generator against itself; one is a negative-text probe that extracts nothing; six are a shell-pipe-to-jq detector whose regex coincidentally matches the table fingerprint and is not markdown parsing at all. All ten sit in tests whose subject is .md content, which is normally a reason to prefer the seam. The marker used is allow-adhoc-markdown, distinct from no-source-grep's allow-test-rule, and lint:ci's lint-allow-test-rule-refs reports the same 280/280 unverified count as before - checked rather than assumed, because those two markers are easy to conflate. The widening earned its keep immediately: it found a test that passed for the wrong reason. tests/config-field-docs.test.cjs asserted notEqual(<cell>, '600') against the TYPE column instead of the DEFAULT column. notEqual('number', '600') is true forever, so the guard against workflow.subagent_timeout regressing to the old seconds default could never fire. docs/CONFIGURATION.md:434 is `| workflow.subagent_timeout | number | 300000 | ... |`, so the default is cell index 2; the assertion is now row-scoped through splitTableRow and reads 300000. That is the argument for the widening in one case: the violation was invisible to lint, the suite was green, and the assertion was vacuous. A rule that cannot reach a file cannot tell you the file is lying. Not fixed here, and recorded rather than assumed: #3426/#3239 are NOT reachable by this widening. tests/package-legitimacy-gate.test.cjs yields zero violations even with the gate bypassed - its hand-rolled scans are real, but built from line filters and split('|') rather than the regex-literal fingerprints this rule detects. They need new detectors. The epic assumed a wider glob would catch them. build:lib, lint and lint:ci all exit 0; the post-fix census across tests/** and scripts/** is 0 violations. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3951): B7 — and #3356's defects were still live in the code B7 asks that each closed child be driven fail-first with a behavioral identity test at the CONSUMER's output. Four of eleven children had no test citing their issue number. Auditing them by BEHAVIOR rather than by number-grep changed the answer for three of the four. #3364 and #2540 — traceability only. Both were implemented by #3941 and their consumer-output tests exist and were shown failing-first; neither cited its originating issue, so an audit that greps for the number reports them uncovered. Tagged the specific asserting test in each file, following the citation form those files already use. #3372 — covered, but only at helper level, and the triage narrowed it. Of the four commands the issue names, only estimate-cli's collectCalibrationSamples actually enumerates phase dirs from disk; smart-entry, audit and roadmap-upgrade derive from ROADMAP/body text and never reach the sentinel path, so they are benign by construction and were left alone rather than "fixed" into churn. The existing #3882 rows asserted the helper's return value. Added a consumer-output test driving `query estimate-calibrate` and asserting sample_count and the persisted document. RED proof: reverted collectCalibrationSamples to a raw readdirSync and ran the real CLI - sample_count 3, sentinel leaked; restored - sample_count 2. #3356 — NOT covered, and BOTH halves of the defect were still live in source. The issue is closed; the bug was not fixed. Fixed here rather than writing tests that document a bug as correct. Defect 1, the contradicted row. quick.md:627 claimed `quick-tasks-append` performs "the equivalent write" to the Step 7c row. It did not: the `#` cell was a positional ordinal and `Directory` read `—`, because the route had no way to receive a quick id or task directory. Added OPTIONAL `--quick-id` / `--slug` / `--directory`. A caller with neither - fast.md, the original #2133 caller - omits them and gets the byte-identical prior row, so nothing existing changes. A caller that HAS a real id and directory now gets the canonical row quick.md:632 renders. The false-equivalence sentence itself is corrected rather than left to mislead the next reader. Defect 2, the forced re-derive. The route called readModifyWriteStateMd with no options, so a body-only append to the Quick Tasks table triggered a full re-derive of the disk-derived progress.* frontmatter. Every other body-only writer passes { resync: false } - src/state.cts's own docstring prescribes it - and this route was the lone outlier. RED proof: reverted the option, seeded a project with 2 real phase dirs and a curated total_phases of 25, ran quick-tasks-append; total_phases collapsed to 2. Restored; it stayed 25. That second one is the shape this epic exists to close: a silent write that replaces curated state with a re-derivation nobody asked for, exit 0 throughout. build:lib, lint and lint:ci all exit 0. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3951): amend B6's ledger to what was measured, and document the new flags The ADR gains a ledger amendment in its own correction style - the sixth wrong premise it records, found the same way as the other five, by measuring before building. B6 says the net guard count must fall. It rose: 62 -> 69, +7, measured from the epic's filing commit to origin/next. The attribution is the point, though. Five of the seven came from PRs unrelated to this epic, one was added by a phase of it, and the epic did retire something sub-file - #3884 removed a detector with an explicit "net: -1 detector, 0 added" ledger. Every named casualty is load-bearing, two already carry retractions in this same document, and a sweep of all 22 rules plus every scripts/lint-* found no provably dead guard. There is no honest way to make the count fall; forcing it would trade coverage for a number, which is the Goodhart outcome Decision 6 exists to prevent. The amendment also records that B6's own prescribed fix for one widening was inert. no-adhoc-markdown-parsing self-gates on its filename, so widening only the files: glob - which is what the criterion says to do - ships a rule that still returns {} for every new path. And #3426/#3239 are not reachable by that widening at all; their scans use line filters and split('|'), not the regex fingerprints the rule detects. The roster row tracked them against the wrong mechanism. Three roster rows updated from aspiration to fact: the two widenings are DONE with their measured counts, and lint-phase-enumeration-drift is marked RETAINED rather than "expected casualty - verify before retiring", because Phase 5 verified it and kept it. The rule Decision 6 should carry forward is stated plainly: a guard ledger is a claim about COVERAGE, not about COUNT. "Net count must fall" is measurable and wrong. "Every guard is reachable, and each retirement names what makes its defect unrepresentable" is the property that was actually wanted. CLI-TOOLS.md documents the optional --quick-id/--slug/--directory flags and says plainly that omitting them keeps the pre-#3356 row byte-identical, plus that the append no longer re-derives progress frontmatter. New features fragment (id 3951); FEATURES.md regenerated rather than hand-edited. Changeset is Changed, pr:0 pending backfill. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3951): correct four rows that pinned the lint rule's old narrow reach The remote suite came back RED with 5 failures, all in tests/eslint-rules.test.cjs. They are stale tests, not a regression: four rows assert that no-adhoc-markdown-parsing is inert outside src/*.cts, which is exactly the contract this deliverable changes. Confirmed by reading rather than inferred from the names - the row at :1981 used filename: 'tests/some.test.cjs' and filename: 'scripts/helper.cjs', the two roots the rule now covers on purpose. Worth recording WHY local gates missed this. npm run lint and lint:ci were green, and the touched test files passed standalone. Lint only reports violations in real files; these rows assert the rule's REACH using synthetic RuleTester filenames, so nothing but the full suite could see them. Local green on a rule change says nothing about the rule's own tests. Each row is rewritten with BOTH halves rather than flipped from valid to invalid: - the same fingerprint under tests/ or scripts/ is now flagged, with the right messageId - the negative space is preserved - the same fingerprint under a path outside all three roots (gsd-core/bin/lib/foo.cjs) is still NOT flagged The second half is the one that matters. Without it the rule has no boundary and nothing would catch an over-wide gate later, which is the mirror image of the bug this deliverable just fixed. Each row is renamed to state the current contract; the old names said "non-src/*.cts ... is not flagged" and would have been actively misleading once the bodies changed. Proven to test the widening rather than restate it: every flagged half was run against HEAD~2's pre-widening rule and does NOT fire there, then against the current rule and does. 12/12 on that probe; the full file is 178/178. Swept for the same staleness elsewhere and found none. require-subprocess-timeout's own "inert outside src/*.cts" row is untouched - that rule's gate was not widened here - and no-adhoc-regex-escape's test file already carries correctly-targeted rows. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3951): acknowledge the quick.md growth the attribution guard reported The full suite came back RED with one failure, and it is mine: 1 file(s) grew without an acknowledgment: quick.md grew 364 bytes gsd-core/workflows/quick.md is runtime-loaded emitted content, so correcting its false 'performs the equivalent write' claim trips emitted-attribution by construction. This is the acknowledgment, not a workaround - there is nothing to regenerate. The fragment names ONE path, which is the only one the guard reported. The four spent acknowledgments it also listed (audit-uat, plan-phase, progress, review) belong to other fragments whose ripple the base already absorbs; they are inert, not failures, and are deliberately NOT copied here - naming paths I did not change would make this record false in the other direction. Byte figure corrected before committing: the guard reported 37220 -> 37584 (+364), but origin/next has since moved and quick.md is 37232 there now, so the measured delta is +352. The reason text says so and names the base as a moving figure rather than pinning a number that is already stale. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3951): move the quick.md growth ack to a trailer, delete the obsolete fragment The acknowledgment mechanism changed under this branch. Merging next brought in the redesign - it also deleted .github/workflows/ack-fragment-sweep.yml, which was in the merge status and which I did not register at the time - and the guard now says so directly: Add a trailer to a commit in this PR (never a new file). Emitted-Drift-Ack-Growth: quick.md - <why this growth is deliberate> So tests/emitted-drift-acks/3951-quick-append-equivalence.json is obsolete on arrival. A fragment file is no longer read by anything, and leaving it would be a dead record that looks like an active one. It is deleted here rather than kept "just in case". The byte figure moved again with the merge: 37232 -> 37596, +364. The earlier fragment said +352, measured before the merge auto-merged quick.md itself. The trailer carries no number, which is the better design - the figure was stale twice in two attempts. Refs #3951 Emitted-Drift-Ack-Growth: quick.md — #3356/#3951 replaces a false claim with an accurate one. Line 627 said the `quick-tasks-append` shortcut "performs the equivalent write" to the Step 7c row rendered above it; it did not, and that was the documented half of #3356 — with no quick id or task directory the route emitted a positional ordinal in `#` and an em-dash in `Directory`, a visibly different row. The corrected sentence has to carry three facts the original elided: what the shortcut actually writes when it has neither input, that this is honest behavior for its real caller (`fast.md`, which has neither), and how a caller with both now gets the byte-identical canonical row via the new optional `--quick-id`/`--slug`/`--directory` flags. Prose is the product here — an executing agent reads this line to decide whether the shortcut is safe for its case, and a shorter correction would either drop the flags (leaving the reader unable to act on the fix) or drop the limitation (recreating the false claim in gentler words). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3951): backfill changeset pr number Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
3186 lines
110 KiB
JavaScript
3186 lines
110 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
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { RuleTester, ESLint } = require('eslint');
|
|
const path = require('node:path');
|
|
const fc = require('fast-check');
|
|
|
|
const noSourceGrep = require('../eslint-rules/no-source-grep.cjs');
|
|
const noMagicSleepInTests = require('../eslint-rules/no-magic-sleep-in-tests.cjs');
|
|
const noElapsedAssertion = require('../eslint-rules/no-elapsed-assertion.cjs');
|
|
const noRawRmsyncInTests = require('../eslint-rules/no-raw-rmsync-in-tests.cjs');
|
|
const noTautologicalAssert = require('../eslint-rules/no-tautological-assert.cjs');
|
|
const noAdhocMarkdownParsing = require('../eslint-rules/no-adhoc-markdown-parsing.cjs');
|
|
const noDuplicateFoldMarker = require('../eslint-rules/no-duplicate-fold-marker.cjs');
|
|
const requireSubprocessTimeout = require('../eslint-rules/require-subprocess-timeout.cjs');
|
|
|
|
const ruleTester = new RuleTester({
|
|
languageOptions: {
|
|
ecmaVersion: 2022,
|
|
sourceType: 'commonjs',
|
|
},
|
|
});
|
|
|
|
// ─── no-source-grep ──────────────────────────────────────────────────────────
|
|
|
|
describe('no-source-grep rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noSourceGrep.create, 'function');
|
|
});
|
|
|
|
test('valid: readFileSync on .md file is allowed', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const content = fs.readFileSync(path.join(__dirname, '..', 'docs', 'readme.md'), 'utf-8');
|
|
content.includes('hello');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const content = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'workflows', 'config.json'), 'utf-8');
|
|
content.includes('key');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: readFileSync on .cjs source file followed by .includes()', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'io.cjs'), 'utf-8');
|
|
src.includes('someFunction');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: readFileSync on .cjs source file followed by .match()', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'foo.cjs'), 'utf-8');
|
|
src.match(/pattern/);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: allow-test-rule annotation adjacent to the read exempts that site (#3508: site-scoped, not file-wide)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
// The marker sits directly above the read+search it suppresses.
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
// allow-test-rule: pending migration
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'io.cjs'), 'utf-8'); src.includes('someFunction');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: require() of a .cjs file is allowed (not readFileSync)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const mod = require('../gsd-core/bin/lib/io.cjs');
|
|
mod.someMethod();
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-source-grep widening (#3502 / Phase 3 of #3464) ─────────────────────
|
|
//
|
|
// One RuleTester case per row of .gsd/phase/chore-3464-widen-source-grep/
|
|
// 50-test-matrix.md. Row numbers in test names refer to that matrix.
|
|
|
|
describe('no-source-grep rule — widening (#3502)', () => {
|
|
test('row 1: baseline literal .cjs read + .includes() (happy regression)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
src.includes('x');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 2: .cts source read + .match() (gap B)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const ROOT = '/repo';
|
|
const src = fs.readFileSync(path.join(ROOT, 'src', 'verification.cts'), 'utf-8');
|
|
src.match(/x/);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 3: .mts source read + .match() (gap B)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const ROOT = '/repo';
|
|
const src = fs.readFileSync(path.join(ROOT, 'src', 'x.mts'), 'utf-8');
|
|
src.match(/x/);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 4: .mjs source read + .match() (gap B)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const ROOT = '/repo';
|
|
const src = fs.readFileSync(path.join(ROOT, 'src', 'x.mjs'), 'utf-8');
|
|
src.match(/x/);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 5: .matchAll() on a tracked read (gap A)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
src.matchAll(/x/g);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 6: regex.test(tracked) (gap A, argument-side detection)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const re = /x/;
|
|
re.test(src);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 7: /lit/.test(tracked) (gap A, argument-side detection)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
/x/.test(src);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 8: .split() / .replace() probes (gap A)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
src.split('\\n');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
src.replace(/x/, '');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 9: two-hop derived variable (gap C)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
function strip(x) { return x; }
|
|
const a = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const b = strip(a);
|
|
b.match(/x/);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 10: three-hop derived variable — at the depth bound (gap C, boundary)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const a = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const b = a;
|
|
const c = b;
|
|
c.includes('x');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 11: hop chain beyond the configured depth is a documented limit (gap C, boundary)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const a = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const b = a;
|
|
const c = b;
|
|
const d = c;
|
|
d.includes('x');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 12: shadowed same-name param — false-positive guard (gap D)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const fn = (src) => src.replace(/x/, 'y');
|
|
fn('unrelated');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 13: same name, sibling block scopes — false-positive guard (gap D)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
{
|
|
const c = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
}
|
|
{
|
|
const c = 'x';
|
|
c.includes('y');
|
|
}
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 14: .md literal read + .includes() (negative space, unchanged)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const content = fs.readFileSync(path.join(__dirname, '..', 'workflows', 'a.md'), 'utf-8');
|
|
content.includes('x');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 15: .json literal read + .match() (negative space, unchanged)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const content = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.json'), 'utf-8');
|
|
content.match(/x/);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 16: dynamic path variable → .includes() — deliberately not flagged (rejected widening)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
function readIt(p) {
|
|
const content = fs.readFileSync(p, 'utf-8');
|
|
content.includes('x');
|
|
}
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 17: tracked read, no text search (negative space, unchanged)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'));
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 18: require() of a .cjs (negative space, unchanged)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const mod = require('../lib/a.cjs');
|
|
mod.someMethod();
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 19: a marker adjacent to the read+search suppresses it (#3508: site-scoped, not file-wide)', () => {
|
|
// The raw marker text is assembled via string concatenation so this
|
|
// FILE's own bytes never contain a contiguous "allow" + "-test-rule:"
|
|
// token (scripts/lint-allow-test-rule-refs.cjs does a raw whole-file
|
|
// substring scan). At RuleTester-run time the concatenation resolves to
|
|
// a real single-line comment, which the rule under test honors normally.
|
|
// The marker sits directly above the read+search (site-scoped, #3508),
|
|
// not merely somewhere earlier in the file (the pre-#3508 file-wide form
|
|
// this row originally exercised).
|
|
const marker = '// ' + 'allow' + '-test-rule: split marker for row 19, see #3502';
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
marker,
|
|
"const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); src.includes('x');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 20: marker text inside a string literal (not a comment) does not suppress', () => {
|
|
// Same split-marker technique as row 19, applied to a STRING literal
|
|
// (not a comment) — this row exists to prove the rule's suppression
|
|
// check only honors an actual comment, per the #3465 discriminator.
|
|
const stringMarkerLine = "const note = '" + 'allow' + "-test-rule: this is just data, not a directive';";
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
stringMarkerLine,
|
|
"const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');",
|
|
'src.includes(note);',
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-source-grep site-scoped suppression (#3508 / Phase 4 of #3464) ──────
|
|
//
|
|
// One RuleTester case per row of
|
|
// .gsd/phase/chore-3464-site-scoped-suppression/50-test-matrix.md, rows 1-12.
|
|
// Row 4 is the one that actually proves the defect is closed: file-wide
|
|
// amnesty is gone, so a marker adjacent to one violation must NOT reach an
|
|
// unrelated violation later in the same file. Rows 3 and 6 are the
|
|
// compatibility guards (prose between marker and read; marker + zero
|
|
// violations) that must keep working or this would break the 277
|
|
// marker-bearing files that rely on file-level markers being a documented
|
|
// no-op when there's nothing to suppress.
|
|
//
|
|
// Marker text is always assembled via string concatenation (`AT` below) so
|
|
// THIS file's raw bytes never contain a contiguous "allow" + "-test-rule:"
|
|
// token — same fixture-host discipline as the row 19/20 cases above
|
|
// (scripts/lint-allow-test-rule-refs.cjs does a raw whole-file substring
|
|
// scan and must not newly count this file).
|
|
//
|
|
// NOTE: row 9's fixture length is tied to MAX_MARKER_LOOKAHEAD_LINES (8) in
|
|
// eslint-rules/no-source-grep.cjs — if that constant changes, this fixture's
|
|
// filler-line count must change with it.
|
|
// Row 12 ("marker with no #NNN") is explicitly a script-level check, not a
|
|
// RuleTester case (test-matrix.md marks it "(script, not RuleTester)") —
|
|
// it's covered by `node scripts/lint-allow-test-rule-refs.cjs` instead.
|
|
|
|
describe('no-source-grep rule — site-scoped suppression (#3508)', () => {
|
|
const AT = 'allow' + '-test-rule:';
|
|
|
|
test('row 1: marker directly above the read+search is suppressed (site-scoped)', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 2: marker trailing on the same line as the search is suppressed', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x'); // ${AT} reason (#1)`,
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 3: marker with prose lines between it and the read is still suppressed (repo real-style guard)', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
'// continuation prose line one explaining the reason',
|
|
'// continuation prose line two continuing the explanation',
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 4: marker adjacent to V1 does NOT reach an unrelated V2 later in the file (the defect this phase closes)', () => {
|
|
const filler = Array.from({ length: 40 }, (_, i) => `// unrelated filler line ${i + 1}, pushing V2 well past the lookahead bound`);
|
|
const lines = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason for V1 (#1)`,
|
|
"const s1 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s1.includes('x');",
|
|
...filler,
|
|
"const s2 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'b.cjs'), 'utf-8'); s2.includes('y');",
|
|
];
|
|
const code = lines.join('\n');
|
|
const v2Line = lines.length; // s2's line is the last line of the fixture
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
// Exactly ONE error, reported at V2 -- V1 stays suppressed, and the
|
|
// marker's reach does NOT extend to the unrelated V2 40 lines later.
|
|
errors: [{ messageId: 'noSourceGrep', line: v2Line }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 5: marker far above a violation with no marker text of its own is not suppressed', () => {
|
|
const filler = Array.from({ length: 100 }, (_, i) => `// unrelated filler line ${i + 1}`);
|
|
const lines = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
...filler,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
|
|
];
|
|
const code = lines.join('\n');
|
|
const violationLine = lines.length;
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep', line: violationLine }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 6: file with a marker and zero violations stays green (the 277 inert-marker files compatibility guard)', () => {
|
|
const code = [
|
|
`// ${AT} reason (#1)`,
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
"const content = fs.readFileSync(path.join(__dirname, '..', 'docs', 'readme.md'), 'utf-8');",
|
|
"content.includes('hello');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 7: no marker, one violation is flagged (baseline unchanged)', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 8: two violations, two adjacent markers -- per-site marking works', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason for V1 (#1)`,
|
|
"const s1 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s1.includes('x');",
|
|
`// ${AT} reason for V2 (#1)`,
|
|
"const s2 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'b.cjs'), 'utf-8'); s2.includes('y');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('row 9: marker beyond the lookahead bound does not suppress (the bound is where it claims)', () => {
|
|
// MAX_MARKER_LOOKAHEAD_LINES is 8 in eslint-rules/no-source-grep.cjs.
|
|
// 9 filler comment lines between the marker and the read pushes the gap
|
|
// to 10 lines (> 8), just past the bound.
|
|
const filler = Array.from({ length: 9 }, (_, i) => `// filler comment line ${i + 1}`);
|
|
const lines = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
...filler,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
|
|
];
|
|
const code = lines.join('\n');
|
|
const violationLine = lines.length;
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep', line: violationLine }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 10: marker text inside a fixture string (not a real comment) is not a directive', () => {
|
|
// The marker-looking text lives inside a STRING LITERAL in the linted
|
|
// fixture, never as a `//` comment -- ESLint's comment AST (what the
|
|
// rule inspects) never sees string-literal contents, so this must not
|
|
// suppress the real, unmarked violation below it (the #3465 lesson).
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`const note = 'not a directive: ${AT} fake reason';`,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes(note);",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('row 11: marker citing #NNN on the same line still suppresses (citation contract unaffected)', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason for this read (#3508)`,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ─── read-site suppression (adversarial-review fix, ITEM 1) ────────────
|
|
//
|
|
// A violation is fundamentally about a read+search PAIR. Before this fix,
|
|
// a marker adjacent to the readFileSync() call (the intuitive annotation
|
|
// spot) failed to suppress once the search happened on a later line,
|
|
// because the readFileSync assignment line itself is "real code" and
|
|
// broke comment-purity on the marker->search lookahead path. The rule now
|
|
// also checks a marker's site-scoping against the ORIGINATING read call's
|
|
// own line, independent of the marker->search path.
|
|
|
|
test('valid: marker directly above the read, search on the very next (non-comment) line', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
"const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
|
|
"src.includes('x');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: marker directly above the read, search several comment-pure lines later (read-line real code no longer breaks the marker->search path)', () => {
|
|
const code = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
"const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
|
|
'// comment-pure line one',
|
|
'// comment-pure line two',
|
|
'// comment-pure line three',
|
|
"src.includes('x');",
|
|
].join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: marker above the read suppresses that pair, but an unrelated tracked variable searched further down is still flagged', () => {
|
|
const lines = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason for V1 (#1)`,
|
|
"const s1 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
|
|
"s1.includes('x');",
|
|
"const s2 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'b.cjs'), 'utf8');",
|
|
"s2.includes('y');",
|
|
];
|
|
const code = lines.join('\n');
|
|
const v2Line = lines.length; // s2.includes(...) is the last line
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep', line: v2Line }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: marker far from both the read and the search is still flagged', () => {
|
|
const filler = Array.from({ length: 20 }, (_, i) => `// unrelated filler line ${i + 1}`);
|
|
const lines = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
...filler,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
|
|
"s.includes('x');",
|
|
];
|
|
const code = lines.join('\n');
|
|
const violationLine = lines.length; // s.includes(...) is the last line
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep', line: violationLine }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('boundary: marker exactly MAX_MARKER_LOOKAHEAD_LINES (8) above the read is suppressed via the read-site path', () => {
|
|
// 7 comment-pure filler lines between the marker and the read puts the
|
|
// read exactly 8 lines below the marker -- the inclusive boundary.
|
|
const filler = Array.from({ length: 7 }, (_, i) => `// filler comment line ${i + 1}`);
|
|
const lines = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
...filler,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
|
|
"s.includes('x');",
|
|
];
|
|
const code = lines.join('\n');
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [{ code, filename: 'tests/foo.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('boundary: marker one line beyond MAX_MARKER_LOOKAHEAD_LINES (9) above the read is not suppressed', () => {
|
|
// 8 comment-pure filler lines between the marker and the read puts the
|
|
// read 9 lines below the marker -- one past the inclusive boundary.
|
|
const filler = Array.from({ length: 8 }, (_, i) => `// filler comment line ${i + 1}`);
|
|
const lines = [
|
|
"const fs = require('fs');",
|
|
"const path = require('path');",
|
|
`// ${AT} reason (#1)`,
|
|
...filler,
|
|
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
|
|
"s.includes('x');",
|
|
];
|
|
const code = lines.join('\n');
|
|
const violationLine = lines.length; // s.includes(...) is the last line
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep', line: violationLine }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-source-grep hop-propagation value-shape (adversarial-review fix) ────
|
|
//
|
|
// minTrackedHop() used to walk EVERY Identifier under a derivation's RHS
|
|
// and treat any bare reference to a tracked variable as propagating,
|
|
// regardless of whether the derived VALUE could still carry text (e.g.
|
|
// `.length`). These rows cover the value-shape gate that replaced that
|
|
// blind walk: propagate only through derivations that plausibly still
|
|
// carry the source file's text; do not propagate through scalar-producing
|
|
// shapes (member access, numeric/boolean methods, comparisons, Number()
|
|
// et al).
|
|
|
|
describe('no-source-grep rule — hop-propagation value-shape (adversarial-review fix)', () => {
|
|
test('valid: .length derivation does not propagate (reported false-positive repro)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const raw = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const len = raw.length;
|
|
if (/^\\d+$/.test(len)) {}
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: numeric-returning method derivation does not cascade to a second error', () => {
|
|
// raw.indexOf('x') is itself already flagged directly (indexOf is one
|
|
// of the TEXT_METHODS this rule flags on a tracked receiver, unrelated
|
|
// to hop propagation). The important assertion here is that there is
|
|
// exactly ONE error, not two: the numeric result of .indexOf() must
|
|
// NOT stay tracked, so String(n).includes('1') is not a second finding.
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const raw = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const n = raw.indexOf('x');
|
|
String(n).includes('1');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: boolean-returning method derivation does not cascade to a second error', () => {
|
|
// Same shape as above with a boolean-returning method: raw.includes('x')
|
|
// is itself already flagged directly. The boolean result must NOT stay
|
|
// tracked, so String(ok).includes('true') is not a second finding.
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const raw = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const ok = raw.includes('x');
|
|
String(ok).includes('true');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: comparison of a tracked derivation does not propagate', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const raw = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const same = raw.length === 0;
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: string-returning method derivation still propagates and is caught', () => {
|
|
// raw.replace(...) is flagged directly (replace is a TEXT_METHOD, same
|
|
// as the indexOf/includes rows above) AND the string-returning result
|
|
// (b) correctly stays tracked, so b.includes('y') is a second, distinct
|
|
// finding. Two errors total, both real.
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const raw = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const b = raw.replace(/x/, '');
|
|
b.includes('y');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }, { messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: template-literal derivation still propagates and is caught', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const raw = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
const b = \`\${raw}\`;
|
|
b.match(/y/);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: direct .includes() on the tracked source read is unchanged (no regression)', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const raw = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');
|
|
raw.includes('x');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noSourceGrep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-magic-sleep-in-tests ─────────────────────────────────────────────────
|
|
|
|
describe('no-magic-sleep-in-tests rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noMagicSleepInTests.create, 'function');
|
|
});
|
|
|
|
test('valid: setTimeout used outside tests (no-op since rule only applies to *.test.cjs)', () => {
|
|
// Rule only applies to *.test.cjs files; a non-test filename is always valid
|
|
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const delay = new Promise(resolve => setTimeout(resolve, 100));
|
|
`,
|
|
filename: 'scripts/some-script.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: Atomics.wait() in test file', () => {
|
|
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const shared = new SharedArrayBuffer(4);
|
|
const arr = new Int32Array(shared);
|
|
Atomics.wait(arr, 0, 0, 100);
|
|
`,
|
|
filename: 'tests/some.test.cjs',
|
|
errors: [{ messageId: 'atomicsWaitSleep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: setTimeout used for synchronization in Promise in test file', () => {
|
|
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
async function waitABit() {
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
}
|
|
`,
|
|
filename: 'tests/some.test.cjs',
|
|
errors: [{ messageId: 'setTimeoutSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: setTimeout with callback (not synchronization pattern) in test file', () => {
|
|
// A setTimeout with no second arg or with a callback that does real work
|
|
// is allowed. The rule only flags the await-new-Promise(setTimeout) pattern.
|
|
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
function doSomethingLater(cb) {
|
|
setTimeout(cb, 100);
|
|
}
|
|
`,
|
|
filename: 'tests/some.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-elapsed-assertion ─────────────────────────────────────────────────────
|
|
|
|
describe('no-elapsed-assertion rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noElapsedAssertion.create, 'function');
|
|
});
|
|
|
|
test('valid: assert on non-timing property', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
const result = { count: 5 };
|
|
assert.equal(result.count, 5);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(result.success);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert on .elapsed property', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
const result = { elapsed: 150 };
|
|
assert.ok(result.elapsed < 200);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert on .duration property', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.equal(stats.duration, 100);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert on .took property', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(result.took < 500);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert on .ms property', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(result.ms > 0);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.equal with timing comparison', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.equal(result.elapsed > 0, true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-raw-rmsync-in-tests ──────────────────────────────────────────────────
|
|
|
|
describe('no-raw-rmsync-in-tests rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noRawRmsyncInTests.create, 'function');
|
|
});
|
|
|
|
// ── INVALID cases (must error) ────────────────────────────────────────────
|
|
|
|
test('invalid: fs.rmSync() in a test file', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noRawRmSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: computed member fs["rmSync"]() in a test file', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
fs['rmSync'](d, { recursive: true, force: true });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noRawRmSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: destructured rmSync from require("fs") in a test file', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const { rmSync } = require('fs');
|
|
rmSync(d, { recursive: true, force: true });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noRawRmSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: aliased const del = fs.rmSync; del() in a test file', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const del = fs.rmSync;
|
|
del(d, { recursive: true, force: true });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noRawRmSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: allow-test-rule annotation no longer suppresses this rule (Defect 1 fixed)', () => {
|
|
// A file with // allow-test-rule: <source-grep reason> must still error
|
|
// on raw rmSync calls. The file-level annotation is for no-source-grep only.
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
// allow-test-rule: source-text-is-the-product
|
|
const fs = require('fs');
|
|
fs.rmSync(d, { recursive: true, force: true });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noRawRmSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── VALID cases (must NOT error) ──────────────────────────────────────────
|
|
|
|
test('valid: helpers.cleanup() in a test file (no error)', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const { cleanup } = require('../helpers.cjs');
|
|
cleanup(tmpDir);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: bare rmSync() that is NOT fs-derived (local function) is not flagged', () => {
|
|
// A locally defined function named rmSync must not be flagged — the rule
|
|
// only tracks names that were bound from require("fs").
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const rmSync = () => {};
|
|
rmSync(d);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// NOTE: The inline `// eslint-disable-next-line local/no-raw-rmsync-in-tests -- reason`
|
|
// escape hatch is handled entirely by ESLint's own disable-comment mechanism and
|
|
// cannot be unit-tested here via RuleTester (RuleTester runs the rule under a
|
|
// different internal namespace so the comment's rule-id doesn't match). The escape
|
|
// hatch works correctly when ESLint processes real files via `npx eslint`.
|
|
|
|
test('valid: fs.rmSync() in a non-test file (rule is inert outside *.test.cjs)', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
`,
|
|
filename: 'scripts/foo.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: member access / assignment without calling (not a CallExpression)', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const orig = fs.rmSync;
|
|
fs.rmSync = orig;
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-tautological-assert ──────────────────────────────────────────────────
|
|
|
|
describe('no-tautological-assert rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noTautologicalAssert.create, 'function');
|
|
});
|
|
|
|
// ── VALID cases (must NOT error) ──────────────────────────────────────────
|
|
|
|
test('valid: assert.ok with a non-literal identifier argument', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(result);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.strictEqual with mixed literal/identifier arguments', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.strictEqual(actual, true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.strictEqual with identifier and numeric literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.strictEqual(x, 5);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.ok with a CallExpression argument', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(fn());
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.deepStrictEqual with two identifier arguments', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.deepStrictEqual(got, expected);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.strictEqual with two different identifier arguments', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.strictEqual(a, b);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── INVALID cases (must error) ────────────────────────────────────────────
|
|
|
|
test('invalid: assert.ok(true) — always-truthy boolean literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert(true) — bare assert with always-truthy boolean literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert');
|
|
assert(true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.ok(1) — always-truthy non-zero numeric literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(1);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.ok("always") — always-truthy non-empty string literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok('always');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.ok([]) — always-truthy array literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok([]);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.ok(cond || true) — logical OR whose right side is true', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(cond || true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.strictEqual(true, true) — identical boolean literals', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.strictEqual(true, true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalEquality' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.equal(1, 1) — identical numeric literals', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.equal(1, 1);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalEquality' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── Fix #3: true || cond (left-side true) ────────────────────────────────
|
|
|
|
test('invalid: assert.ok(true || x) — left side is literal true (always short-circuits)', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(true || x);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert(true || y) — bare assert, left side is literal true', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert');
|
|
assert(true || y);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── Fix #4: empty [] / {} deep-equality ──────────────────────────────────
|
|
|
|
test('invalid: assert.deepStrictEqual([], []) — two empty arrays are always deep-equal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.deepStrictEqual([], []);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalEquality' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.deepStrictEqual({}, {}) — two empty objects are always deep-equal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.deepStrictEqual({}, {});
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalEquality' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── Conservative: non-empty arrays/objects must NOT be flagged ────────────
|
|
|
|
test('valid: assert.deepStrictEqual([1], [2]) — non-empty arrays with different content are not flagged', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.deepStrictEqual([1], [2]);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.deepStrictEqual(got, expected) — identifier arguments are not flagged', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.deepStrictEqual(got, expected);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-adhoc-markdown-parsing ───────────────────────────────────────────────
|
|
|
|
describe('no-adhoc-markdown-parsing rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noAdhocMarkdownParsing.create, 'function');
|
|
});
|
|
|
|
// ── #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: [],
|
|
});
|
|
});
|
|
});
|