* test(#3271): guard against a folded suite appearing twice in one host Adds local/no-duplicate-fold-marker, an AST rule that reports the second and every subsequent `folded:<name>` marker in a host file, plus RuleTester cases and a tree-wide regression assertion. Failing-first on purpose: the rule is registered at error and the 25 duplicated regions are still present, so eslint and the new tree-wide test are RED. The deletions land in the next commit. The marker key is the whitespace-delimited token after `folded:` — not the issue's `[a-z0-9-]*` slice, which truncates at `.` and false-positives on tests/model-resolver.test.cjs where feat-443-effort-fast-mode.integration and feat-443-effort-fast-mode are two distinct folded suites. Refs #3271 * fix(#3271): delete 25 duplicated folded suites from three install hosts Three consolidated install suites each carried a verbatim second copy of a contiguous run of #1969 B1 folded blocks. Byte-identical, constant offset, and green — each duplicated block registered and ran twice on every lane. tests/install.test.cjs 5981-9937 (3957 lines, 18 blocks) tests/install-minimal-hooks.test.cjs 2734-4015 (1282 lines, 5 blocks) tests/install-write-confinement.test.cjs 1754-2321 ( 568 lines, 2 blocks) Introduced by6d072435d(#1975 re-applying #1970's hunks on a tree that already had them, 2026-07-03) — one stale-base re-application, three files, one commit. Verified by marker-count bisect: 1 at4f779eda4and0cc7a1a42, 2 from6d072435donward. The later copy is deleted in each case, so every file returns to what its authoring batch produced and blame on the surviving lines stays accurate. local/no-duplicate-fold-marker, red on the previous commit, is now green. tests/model-resolver.test.cjs is untouched: the issue lists it, but its two blocks are folded from two different files and are not identical. It is a false positive of the issue's own grep, whose `[a-z0-9-]*` key truncates at `.`. Fixes #3271 * test(#3271): property-test marker identity and pin the alias non-goal Three review findings, all fixed inline: 1. foldMarkerOf is a parser and carried no fast-check property test. Raised independently by the /code-review standards axis and the isolated adversarial pass; the file already establishes the fc.property-driving-ruleTester idiom for a sibling rule. Added, two arms over markers generated from [a-z0-9-._]: the same marker twice always reports exactly once against firstLine 1, and two distinct markers never collide. The alphabet includes `.` on purpose — an implementation keyed on the issue's [a-z0-9-]* slice passes arm 1 and fails arm 2, which is exactly the model-resolver false positive. 2. meta.docs.category was the novel value 'Test hygiene'; all 16 sibling local rules use 'Best Practices', 'Portability' or 'Reliability'. Now 'Best Practices'. 3. A call through a further alias (const d = __foldDescribe) was unreported and undocumented — accidental rather than deliberate. It is now the fourth entry in the rule's documented non-goals, with the reason, and pinned by a valid RuleTester case so it cannot drift silently. Refs #3271 * test(#3271): name the step and elapsed time when a baseline build fails buildBaselineAtRef runs four bounded steps and, when one exceeded its bound, threw a bare "spawnSync ETIMEDOUT" naming neither the step nor how long anything took. Diagnosing one real failure took four separate experiments to recover information the throw already had. Each step is now timed, and any throw carries the breakdown: which step failed, its elapsed time, the timings of every step that completed before it, all three bounds, and the tail of the child's captured stdout/stderr. The failure message is deliberately the carrier. On the remote runner the captured output field comes back empty in failures.json while error and stack survive verbatim, so the message is the only channel that reaches a reader of a remote verdict. Refs #3271 * fix(#3271): size the baseline generator bound for the machine it runs on Instrumentation from a real remote-runner failure gave the breakdown: git-worktree-add=15.1s npm-run-build-lib=19.8s gen-emitted-baseline=FAILED@300.1s Steps 1 and 2 are comfortable. Only the generator exceeds its bound, and it is not hung — it needs more than 300s there. Measured ladder for that step: ~22s idle in a container, ~39s end-to-end in a clean container, ~142s with 8 CPU burners on 8 cores, and >300s under the real suite. Its cost is 19 sequential installer spawns, and spawn latency is exactly where a container degrades worst (3.9x slower than host, against 1.1x for file IO) — which is why a CPU-only load test did not reproduce it and why four earlier hypotheses (container slowness, network, shallow clone, CPU contention) all measured clean. The 300s bound was sized on an idle machine for a step that never runs on one. Under the remote runner the on-disk baseline cache is structurally absent — CI restores it via actions/cache keyed on github.event.pull_request.base.sha, a key that exists only inside GitHub Actions — so this slow path runs on every remote verification. The result: this gate has passed 0 times in 754 runs, failing 80 times and never once executing successfully. Raised to the 600000ms ceiling that local/no-unbounded-spawn treats as the largest meaningful bound; the other two bounds are untouched. This makes the gate RUN, which is the point: the alternative considered and rejected was degrading the timeout to a skip, and that was measured to turn the suite green with the gate silently not running at all. The real remedy is making the cache reachable from the remote runner so the in-job build returns to being the rare fallback ADR-2719 §5 describes. That is a gsd-test-runner change, not one this repo can make. Refs #3271 * fix(#3271): tolerate an overlay source that vanishes mid-walk Observed on the remote runner, three runs across three different branches: ENOENT: no such file or directory, link '/work/hooks/dist/gsd-config-reload.js' -> '/tmp/gsd-2930-overlay-6nOZay/hooks/dist/gsd-config-reload.js' buildOverlayRepo enumerates names with readdirSync and then acts on each one, so statSync, copyFileSync and linkSync all sit in a TOCTOU window. hooks/dist is regenerated by an ATOMIC REPLACE (scripts/build-hooks.js unlinks and renames), so any concurrently running test that rebuilds hooks retires a just-listed name mid-walk and the overlay dies on it. linkOrCopyFile already tolerated EXDEV and EPERM; ENOENT went straight through. On ENOENT the source is now re-examined ONCE rather than slept on. An atomic rename is a single syscall, so by the time the failure surfaces the successor is either already in place (the retry succeeds) or the path has genuinely left the tree, in which case there is nothing to mirror and the leaf is skipped. No sleep and no spin: a timing-based wait here would be the very flake being fixed. Every other errno still propagates untouched, so a real permission or IO fault stays a hard failure. Five tests hold the boundary: gone-for-good skips without retrying, mid-replace retries exactly once and places the file, EACCES still throws, a real linkSync ENOENT is injected by monkeypatching fs and restoring it in a finally (never a mode-bit trick, which root bypasses), and isMissingPath accepts only ENOENT. Refs #3271 * fix(#3271): order the timeout ladder inward-out and lock it Two review blockers, both real. The generator bound had been raised to 600000ms — exactly the whole-chunk timeout in scripts/run-tests.cjs:973. A step bound equal to the chunk ceiling loses the race: the chunk is killed first and the failure arrives as an opaque "no failed step" kill, so the per-step diagnostic added a commit earlier was built and then made unreachable in the same change. Separately the #2767 test declared a per-test timeout of 300000ms, BELOW the inner bound it was meant to permit, so it could still die at the exact 300s ceiling this was supposed to lift — via node:test's timeout rather than spawnSync's. Its sibling declared 900000ms, above the chunk ceiling, which is the same opaque-kill hazard from the other direction. The three bounds only produce a useful failure if they fire inward-out, so they now do: step 360s, per-test 480s, chunk 600s. 360s is ~3x the passing observation (91.6s / 115.8s) and 20% above the censored 300.1s timeout, while leaving 240s of chunk headroom for every other file sharing it. Four tests lock the ordering, including a drift guard on the exported values — without it, editing a call site's literal timeout would leave the ordering assertions passing while the real ladder inverted. Also from review: - err.gsdBaselineStep and err.gsdBaselineTimings were written and never read anywhere in the tree; only the rewritten message is consumed. Removed rather than kept as speculative surface. - buildOverlayRepo discarded placeVanishableLeaf's boolean at both call sites, so a vanished leaf left the overlay with no accounting at all. It now collects the skipped paths and warns once. Not thrown: a source that left the tree really is not part of the snapshot, and throwing would reintroduce the crash the tolerance removes — but silence would let a dropped leaf resurface later as an unrelated missing-file assertion. - The instrumentation commit shipped no test. One now drives a real failure and asserts the message names the step, its elapsed time, and the bounds. Refs #3271 * chore(#3271): backfill the changeset PR number * fix(#3271): bound a hook fan-out as its own class, not as a bare probe CI failure on PR #3285, job full test (windows-latest, 22, shard 2/3) — every other lane green, including windows-latest node 24 across all three shards: not ok 1 - blocks push when any to-be-pushed commit matches local blocked regex error: bash .githooks\pre-push failed — outcome=timed_out exitCode=null stderr= duration_ms: 15040.2168 A bound, not a hang: the test supplies stdin via input:, so the hook is not blocked reading its ref list, and the duration lands exactly on the 15000ms bound. The site used PROBE_TIMEOUT_MS, which tests/helpers/timeouts.cjs documents as "a single short CLI query or node -e probe against a temp fixture". This is not that. It spawns bash running .githooks/pre-push, and the hook then invokes a MOCK git that is itself a bash script, so one runHook is roughly four Git Bash spawns. On Windows each is Defender-scanned and the first hook test in a file pays cold start on top. That module's own docstring warns against precisely this: a call site that differs from its class must not be forced onto a shared value that does not describe it. HOOK_FANOUT_TIMEOUT_MS is that missing class — 60000ms, 4x the bound that failed and half INSTALL_TIMEOUT_MS, which is the right order: a hook fan-out is much lighter than a full installer run and far heavier than reading back a version string. Two tests lock the ordering against both neighbours, including one asserting real margin over the censored 15040ms observation, since a bound that merely matched what was measured would be the same defect again. Scoped deliberately: the other ~360 runHook sites keep their current bounds. This adds the norm and applies it where a real failure demonstrated the need, rather than sweeping a value across sites with no evidence for any of them. Refs #3271 --------- Co-authored-by: sim <sim@local>
1965 lines
67 KiB
JavaScript
1965 lines
67 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* eslint-rules.test.cjs
|
|
*
|
|
* RuleTester unit tests for the local ESLint rules:
|
|
* - local/no-source-grep
|
|
* - local/no-magic-sleep-in-tests
|
|
* - local/no-elapsed-assertion
|
|
* - local/no-raw-rmsync-in-tests
|
|
* - local/no-adhoc-markdown-parsing
|
|
*/
|
|
|
|
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 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: file with allow-test-rule annotation is exempt', () => {
|
|
ruleTester.run('no-source-grep', noSourceGrep, {
|
|
valid: [
|
|
{
|
|
// The allow annotation exempts the whole file
|
|
code: `
|
|
// allow-test-rule: pending migration
|
|
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',
|
|
},
|
|
],
|
|
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-magic-sleep-in-tests ─────────────────────────────────────────────────
|
|
|
|
describe('no-magic-sleep-in-tests rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noMagicSleepInTests.create, 'function');
|
|
});
|
|
|
|
test('valid: setTimeout used outside tests (no-op since rule only applies to *.test.cjs)', () => {
|
|
// Rule only applies to *.test.cjs files; a non-test filename is always valid
|
|
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const delay = new Promise(resolve => setTimeout(resolve, 100));
|
|
`,
|
|
filename: 'scripts/some-script.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: Atomics.wait() in test file', () => {
|
|
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const shared = new SharedArrayBuffer(4);
|
|
const arr = new Int32Array(shared);
|
|
Atomics.wait(arr, 0, 0, 100);
|
|
`,
|
|
filename: 'tests/some.test.cjs',
|
|
errors: [{ messageId: 'atomicsWaitSleep' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: setTimeout used for synchronization in Promise in test file', () => {
|
|
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
async function waitABit() {
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
}
|
|
`,
|
|
filename: 'tests/some.test.cjs',
|
|
errors: [{ messageId: 'setTimeoutSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: setTimeout with callback (not synchronization pattern) in test file', () => {
|
|
// A setTimeout with no second arg or with a callback that does real work
|
|
// is allowed. The rule only flags the await-new-Promise(setTimeout) pattern.
|
|
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
function doSomethingLater(cb) {
|
|
setTimeout(cb, 100);
|
|
}
|
|
`,
|
|
filename: 'tests/some.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-elapsed-assertion ─────────────────────────────────────────────────────
|
|
|
|
describe('no-elapsed-assertion rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noElapsedAssertion.create, 'function');
|
|
});
|
|
|
|
test('valid: assert on non-timing property', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
const result = { count: 5 };
|
|
assert.equal(result.count, 5);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(result.success);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert on .elapsed property', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
const result = { elapsed: 150 };
|
|
assert.ok(result.elapsed < 200);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert on .duration property', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.equal(stats.duration, 100);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert on .took property', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(result.took < 500);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert on .ms property', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(result.ms > 0);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.equal with timing comparison', () => {
|
|
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.equal(result.elapsed > 0, true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noElapsedAssertion' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-raw-rmsync-in-tests ──────────────────────────────────────────────────
|
|
|
|
describe('no-raw-rmsync-in-tests rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noRawRmsyncInTests.create, 'function');
|
|
});
|
|
|
|
// ── INVALID cases (must error) ────────────────────────────────────────────
|
|
|
|
test('invalid: fs.rmSync() in a test file', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noRawRmSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: computed member fs["rmSync"]() in a test file', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
fs['rmSync'](d, { recursive: true, force: true });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noRawRmSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: destructured rmSync from require("fs") in a test file', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const { rmSync } = require('fs');
|
|
rmSync(d, { recursive: true, force: true });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noRawRmSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: aliased const del = fs.rmSync; del() in a test file', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const del = fs.rmSync;
|
|
del(d, { recursive: true, force: true });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noRawRmSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: allow-test-rule annotation no longer suppresses this rule (Defect 1 fixed)', () => {
|
|
// A file with // allow-test-rule: <source-grep reason> must still error
|
|
// on raw rmSync calls. The file-level annotation is for no-source-grep only.
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
// allow-test-rule: source-text-is-the-product
|
|
const fs = require('fs');
|
|
fs.rmSync(d, { recursive: true, force: true });
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'noRawRmSync' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── VALID cases (must NOT error) ──────────────────────────────────────────
|
|
|
|
test('valid: helpers.cleanup() in a test file (no error)', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const { cleanup } = require('../helpers.cjs');
|
|
cleanup(tmpDir);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: bare rmSync() that is NOT fs-derived (local function) is not flagged', () => {
|
|
// A locally defined function named rmSync must not be flagged — the rule
|
|
// only tracks names that were bound from require("fs").
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const rmSync = () => {};
|
|
rmSync(d);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// NOTE: The inline `// eslint-disable-next-line local/no-raw-rmsync-in-tests -- reason`
|
|
// escape hatch is handled entirely by ESLint's own disable-comment mechanism and
|
|
// cannot be unit-tested here via RuleTester (RuleTester runs the rule under a
|
|
// different internal namespace so the comment's rule-id doesn't match). The escape
|
|
// hatch works correctly when ESLint processes real files via `npx eslint`.
|
|
|
|
test('valid: fs.rmSync() in a non-test file (rule is inert outside *.test.cjs)', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
`,
|
|
filename: 'scripts/foo.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: member access / assignment without calling (not a CallExpression)', () => {
|
|
ruleTester.run('no-raw-rmsync-in-tests', noRawRmsyncInTests, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const fs = require('fs');
|
|
const orig = fs.rmSync;
|
|
fs.rmSync = orig;
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-tautological-assert ──────────────────────────────────────────────────
|
|
|
|
describe('no-tautological-assert rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noTautologicalAssert.create, 'function');
|
|
});
|
|
|
|
// ── VALID cases (must NOT error) ──────────────────────────────────────────
|
|
|
|
test('valid: assert.ok with a non-literal identifier argument', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(result);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.strictEqual with mixed literal/identifier arguments', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.strictEqual(actual, true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.strictEqual with identifier and numeric literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.strictEqual(x, 5);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.ok with a CallExpression argument', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(fn());
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.deepStrictEqual with two identifier arguments', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.deepStrictEqual(got, expected);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.strictEqual with two different identifier arguments', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.strictEqual(a, b);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── INVALID cases (must error) ────────────────────────────────────────────
|
|
|
|
test('invalid: assert.ok(true) — always-truthy boolean literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert(true) — bare assert with always-truthy boolean literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert');
|
|
assert(true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.ok(1) — always-truthy non-zero numeric literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(1);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.ok("always") — always-truthy non-empty string literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok('always');
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.ok([]) — always-truthy array literal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok([]);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.ok(cond || true) — logical OR whose right side is true', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(cond || true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.strictEqual(true, true) — identical boolean literals', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.strictEqual(true, true);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalEquality' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.equal(1, 1) — identical numeric literals', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.equal(1, 1);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalEquality' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── Fix #3: true || cond (left-side true) ────────────────────────────────
|
|
|
|
test('invalid: assert.ok(true || x) — left side is literal true (always short-circuits)', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.ok(true || x);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert(true || y) — bare assert, left side is literal true', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert');
|
|
assert(true || y);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalTruthiness' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── Fix #4: empty [] / {} deep-equality ──────────────────────────────────
|
|
|
|
test('invalid: assert.deepStrictEqual([], []) — two empty arrays are always deep-equal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.deepStrictEqual([], []);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalEquality' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: assert.deepStrictEqual({}, {}) — two empty objects are always deep-equal', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.deepStrictEqual({}, {});
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'tautologicalEquality' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── Conservative: non-empty arrays/objects must NOT be flagged ────────────
|
|
|
|
test('valid: assert.deepStrictEqual([1], [2]) — non-empty arrays with different content are not flagged', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.deepStrictEqual([1], [2]);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: assert.deepStrictEqual(got, expected) — identifier arguments are not flagged', () => {
|
|
ruleTester.run('no-tautological-assert', noTautologicalAssert, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const assert = require('node:assert/strict');
|
|
assert.deepStrictEqual(got, expected);
|
|
`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── no-adhoc-markdown-parsing ───────────────────────────────────────────────
|
|
|
|
describe('no-adhoc-markdown-parsing rule', () => {
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noAdhocMarkdownParsing.create, 'function');
|
|
});
|
|
|
|
// ── POSITIVE cases: flag fence-block-strip and section-collect ────────────
|
|
|
|
test('invalid: fence-block-strip regex with triple-backtick and multiline body', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// /```[\s\S]*?```/ — triple-backtick + [\s\S] body → flagged as fenceRegex
|
|
code: String.raw`const stripFences = /` + '```' + String.raw`[\s\S]*?` + '```' + '/;',
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'fenceRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: fence-block-strip regex with triple-tilde and multiline body', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// /~~~[\s\S]*?~~~/ — triple-tilde + [\s\S] body → flagged as fenceRegex
|
|
code: String.raw`const stripTildes = /~~~[\s\S]*?~~~/;`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'fenceRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: section-collect regex with heading capture, multiline body, heading lookahead', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// /(##\s*X\n)([\s\S]*?)(?=\n##|$)/ — the classic section-collect fingerprint
|
|
code: String.raw`const pat = /(##\s*X\n)([\s\S]*?)(?=\n##|$)/;`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'sectionCollect' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── NEGATIVE cases: single-line fence tests and heading matches NOT flagged ─
|
|
|
|
test('valid: bare single-line fence-opener /^```/ is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: 'const fenceRegex = /^' + '```' + '/;',
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: /^\\s*(?:```|~~~)/ fence-line test is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const isFenceLine = /^\s*(?:` + '```' + String.raw`|~~~)/;`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: /^#\\s+/ single-line title-find is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const titleRe = /^#\s+/;`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: /^###\\s+(.+?)\\s*$/ single-line heading-category match is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const headingRe = /^###\s+(.+?)\s*$/;`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: /^(#{1,6})\\s+(.*)/ single-line heading match is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const headingM = line.match(/^(#{1,6})\s+(.*)/);`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: seam usage (no regex, just an import reference) is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const { collectSection } = require('./markdown-sectionizer');
|
|
const result = collectSection(content, 'Introduction');
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: annotated fence-block-strip with allow-adhoc-markdown is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
// Trailing annotation on the same line suppresses the finding
|
|
code:
|
|
'const stripFences = /```' +
|
|
String.raw`[\s\S]*?` +
|
|
'`' +
|
|
'``/; // allow-adhoc-markdown: pre-seam write path; pending migration #1372',
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: rule is inert outside src/*.cts files', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
// Same fence-block-strip regex in a test file → rule does not apply
|
|
code: String.raw`const stripFences = /~~~[\s\S]*?~~~/;`,
|
|
filename: 'tests/some.test.cjs',
|
|
},
|
|
{
|
|
// Same regex in a scripts file → rule does not apply
|
|
code: String.raw`const p = /(##\s*X\n)([\s\S]*?)(?=\n##|$)/;`,
|
|
filename: 'scripts/helper.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── TABLE-REGEX (ADR-2143 §7) ──────────────────────────────────────────────
|
|
|
|
test('invalid: table-row/cell regex with escaped pipe and negated-pipe cell class', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// /\|[^|]*\|/ — the classic hand-rolled table-row/cell scan fingerprint
|
|
code: String.raw`const rowRe = /\|[^|]*\|/;`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: table-cell regex with escaped-pipe class variant [^\\|]', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`const cellRe = /\|\s*([^\|]+)\s*\|/;`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: parseMarkdownTable() seam call is NOT flagged (no regex literal)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const { parseMarkdownTable } = require('./markdown-table');
|
|
const result = parseMarkdownTable(sectionText);
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: escaped pipe alone (no negated-pipe cell class) is NOT flagged', () => {
|
|
// A bare delimiter probe like /^\|/ or /\|\|/ has an escaped pipe but no
|
|
// [^|] cell-capture class — not a table-row/cell scan, so it must stay
|
|
// conservative and not fire.
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const isPipeDelim = /^\|/;`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
{
|
|
code: String.raw`const orDelim = /a\|b/;`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: annotated table-regex with allow-adhoc-markdown is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const rowRe = /\|[^|]*\|/; // allow-adhoc-markdown: not a table scan, protocol-marker probe`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: table-regex in a non-src/*.cts file is not flagged (rule is inert there)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const rowRe = /\|[^|]*\|/;`,
|
|
filename: 'scripts/helper.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── TABLE-REGEX via new RegExp(<Literal-string | TemplateLiteral>) (#2143 Phase 4) ─
|
|
|
|
test('invalid: new RegExp(<string literal>) matching the table fingerprint', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// new RegExp('\|[^|]*\|') — doubled backslashes cook to a literal \|
|
|
code: String.raw`const rowRe = new RegExp('\\|[^|]*\\|');`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: new RegExp(<template literal>) whose STATIC quasis match the table fingerprint (dynamic segment ignored)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// new RegExp(`^(\|\s*${phase}\.?\s[^|]*(?:\|[^\n]*))$`) — the exact
|
|
// roadmap.cts/phase.cts tableRowPattern shape, dynamic ${phase} in the middle.
|
|
code: 'const tableRowPattern = new RegExp(`^(\\\\|\\\\s*${phase}\\\\.?\\\\s[^|]*(?:\\\\|[^\\\\n]*))$`, \'im\');',
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: new RegExp(`## Phase ${x}`) — dynamic heading pattern, static quasis are not table/section (non-table)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: 'const headingRe = new RegExp(`## Phase ${x}`, \'i\');',
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: new RegExp(someIdentifier) — pattern built elsewhere and referenced by variable is out of scope for this check', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const pattern = buildRowPattern();
|
|
const rowRe = new RegExp(pattern, 'im');
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── new RegExp(identifier) resolved via resolveVariableInit scope walk (#2245 recall-hole fix) ─
|
|
|
|
test('invalid: new RegExp(identifier) resolves a const-declared identifier whose pattern matches the table fingerprint', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// const tablePattern = '\|[^|]*\|' (doubled backslashes cook to a literal \|),
|
|
// then new RegExp(tablePattern) — the pattern is built one hop away via a
|
|
// const-declared identifier instead of inline, which used to escape the
|
|
// fingerprint check entirely (the recall hole this fix closes).
|
|
code: String.raw`
|
|
const tablePattern = '\\|[^|]*\\|';
|
|
const rowRe = new RegExp(tablePattern);
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: new RegExp(identifier) resolves a const-declared identifier whose pattern is NOT table-shaped', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const headingPattern = '## Phase';
|
|
const headingRe = new RegExp(headingPattern);
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: new RegExp(identifier) does NOT resolve a function-parameter identifier (documented boundary)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`
|
|
function buildRowRegex(tablePattern) {
|
|
return new RegExp(tablePattern);
|
|
}
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: annotated new RegExp(...) table-regex with allow-adhoc-markdown is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const rowRe = new RegExp('\\|[^|]*\\|'); // allow-adhoc-markdown: protocol-marker probe, not a table scan`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: new RegExp(...) table-regex in a non-src/*.cts file is not flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const rowRe = new RegExp('\\|[^|]*\\|');`,
|
|
filename: 'scripts/helper.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── ADHOC-REPLACE-MUTATION: .replace() on a roadmap/state receiver (#2143 Phase 4) ─
|
|
|
|
test('invalid: roadmapContent.replace(<inline table-fingerprint literal>, ...) trips both the CallExpression and Literal detectors', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`roadmapContent.replace(/\|[^|]*\|/, 'x');`,
|
|
filename: 'src/some-module.cts',
|
|
// CallExpression is the outer/enter-first node; its Literal argument
|
|
// (visited next, on descent) is a second, independent finding.
|
|
errors: [{ messageId: 'adhocReplaceMutation' }, { messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: stateContent.replace(<variable holding a table-fingerprint regex>, ...) resolves the variable via scope', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`
|
|
const rowRe = /\|[^|]*\|/;
|
|
stateContent.replace(rowRe, 'x');
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }, { messageId: 'adhocReplaceMutation' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: withSection(...) call is NOT a .replace() and is never flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: `
|
|
const { withSection } = require('./markdown-sectionizer');
|
|
const result = withSection(content, 'x', (body) => body);
|
|
`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: foo.replace(/x/, "y") — neither the receiver name nor the pattern match, not flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: `foo.replace(/x/, 'y');`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: state.replace(/x/, "y") — matching receiver name but non-matching pattern, not flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: `state.replace(/x/, 'y');`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: allow-adhoc-markdown suppresses an inline ad-hoc .replace() mutation', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`roadmapContent.replace(/\|[^|]*\|/, 'x'); // allow-adhoc-markdown: pre-seam write path`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: .replace() ad-hoc mutation in a non-src/*.cts file is not flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`roadmapContent.replace(/\|[^|]*\|/, 'x');`,
|
|
filename: 'scripts/helper.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── TABLE-REGEX widening: [^|\n] and escaped-pipe-plus-others classes (#2880) ──
|
|
|
|
test('invalid: content.replace(<inline [^|\\n] cell-class regex>) — the exact shape that evaded the rule before #2880', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
// /\|[^|\n]*\|/ — pipe-excluding cell class ALSO excludes newline; this
|
|
// is the src/state-document.cts shape that the sole-member-class check
|
|
// missed prior to the #2880 widening.
|
|
code: String.raw`content.replace(/\|[^|\n]*\|/, 'x');`,
|
|
filename: 'src/state-document.cts',
|
|
// CallExpression is the outer/enter-first node (adhocReplaceMutation);
|
|
// its Literal argument (visited next, on descent) is the second,
|
|
// independent tableRegex finding — same ordering as the established
|
|
// roadmapContent.replace(...) case above.
|
|
errors: [{ messageId: 'adhocReplaceMutation' }, { messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: factory function returning new RegExp(<template literal with [^|\\n] cell class>)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: 'function buildRowPattern() {\n return new RegExp(`\\\\|[^|\\\\n]*\\\\|`, \'im\');\n}',
|
|
filename: 'src/state-document.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: cell class with the pipe escaped alongside another excluded member [^\\|\\n]', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`const cellRe = /\|[^\|\n]*\|/;`,
|
|
filename: 'src/state-document.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: negated class with NO pipe at all is not a cell scan (e.g. /^[^\\n]*$/)', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`content.replace(/^[^\n]*$/, 'x');`,
|
|
filename: 'src/state-document.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: body.replace(...) — non-matching receiver name (bounded withSection callback) suppresses ONLY adhocReplaceMutation; the regex literal itself is still an independent tableRegex finding', () => {
|
|
// The ADHOC-REPLACE-MUTATION check is scoped to receivers matching
|
|
// /roadmap|state|reqContent|content/i — "body" (the withSection callback
|
|
// parameter name) does not match, so no adhocReplaceMutation fires here.
|
|
// But the standalone Literal visitor inspects EVERY regex literal in the
|
|
// file regardless of call-site context, so the pipe-excluding-class regex
|
|
// is still caught as a bare tableRegex finding either way.
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`body.replace(/\|[^|\n]*\|/, 'x');`,
|
|
filename: 'src/state-document.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: allow-adhoc-markdown suppresses the widened [^|\\n] shape', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`content.replace(/\|[^|\n]*\|/, 'x'); // allow-adhoc-markdown: reason`,
|
|
filename: 'src/state-document.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── TABLE-REGEX narrowing: negated class excluding pipe + something ELSE
|
|
// is a different (non-table) idiom, not flagged (#2880 FIX 4) ───────────
|
|
|
|
test('valid: [^\\s|] (pipe excluded alongside \\s, not a pure line-terminator class) is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const re = /[^\s|]+\|cmd/;`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: [^"|] (pipe excluded alongside a quote) is NOT flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code: String.raw`const re = /\|[^"|]*\|/;`,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: [^|\\r\\n] (pipe plus only line-terminator escapes) IS flagged', () => {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: String.raw`const rowRe = /\|[^|\r\n]*\|/;`,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('performance: a 256000-char adversarial regex-literal source does not hang the rule (ReDoS regression)', () => {
|
|
// The previous regex-based fingerprint, /\[\^[^\]]*\\?\|[^\]]*\]/, was
|
|
// quadratic on failure — an unclosed negated class of this size took
|
|
// ~23s. The single-pass scanner must stay linear. The adversarial text is
|
|
// embedded directly inside a single string-literal argument to
|
|
// `new RegExp(...)` (not built via `+` at the source-code level under
|
|
// test) so `getNewRegExpSource` actually resolves it and the scanner
|
|
// walks the full 256000-char unclosed negated class.
|
|
const bigPipeRun = '|'.repeat(256000);
|
|
const code = `const re = new RegExp('\\\\|[^${bigPipeRun}');`;
|
|
// The 256000-char input is the regression guard for the O(n^2) scan fixed
|
|
// in #2880: the pre-fix regex took ~23s on this input. There is deliberately
|
|
// no elapsed-time assertion (banned by local/no-elapsed-assertion and flaky
|
|
// by nature) — if the quadratic path is ever reintroduced this test stops
|
|
// completing, which surfaces as a suite timeout rather than a silent pass.
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [
|
|
{
|
|
code,
|
|
filename: 'src/some-module.cts',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── property test: negated-pipe-class scanner (hasQualifyingNegatedPipeClass) ──
|
|
test('property: single-pass negated-class scanner verdict matches an independent reference implementation', () => {
|
|
// hasQualifyingNegatedPipeClass is a closure private to the rule's
|
|
// `create(context)` — it cannot be called directly, so it is exercised
|
|
// through the public surface: `new RegExp(<string literal>)` feeds
|
|
// `arg.value` through UNCHANGED as the "effective regex source" (see
|
|
// getNewRegExpSource), so any generated string, however malformed as a
|
|
// real regex, reaches the scanner byte-for-byte via JSON.stringify(...).
|
|
// A guaranteed literal `\|` is prepended so isTableRegexSource's OTHER
|
|
// gate (`src.includes('\\|')`) is always satisfied — the property is then
|
|
// solely a probe of the negated-class scanner's own verdict, matching the
|
|
// instruction to test the scanner in isolation.
|
|
//
|
|
// Reference implementation (independent tokenizer, NOT a copy of the
|
|
// scanner under test): tokenize `src` once into {esc, text} units,
|
|
// tracking escapes; then walk the tokens with a MONOTONIC cursor: on
|
|
// finding a `[` char-token immediately followed by a `^` char-token,
|
|
// consume forward to the first unescaped `]` (or to the end if there is
|
|
// none) as a single committed unit, decide qualification for that unit,
|
|
// and resume scanning strictly AFTER whatever was consumed — a class
|
|
// candidate found INSIDE an already-consumed (opened) class body is
|
|
// never separately reconsidered. Qualifies iff the collected body
|
|
// contains a pipe (bare `|` or escaped `\|`) AND every other member is
|
|
// one of the escapes `\n`, `\r`, `\t`.
|
|
function referenceHasQualifyingNegatedPipeClass(src) {
|
|
const tokens = [];
|
|
let i = 0;
|
|
while (i < src.length) {
|
|
if (src[i] === '\\') {
|
|
const next = i + 1 < src.length ? src[i + 1] : '';
|
|
tokens.push({ esc: true, text: next });
|
|
i += 2;
|
|
}
|
|
else {
|
|
tokens.push({ esc: false, text: src[i] });
|
|
i += 1;
|
|
}
|
|
}
|
|
let t = 0;
|
|
while (t < tokens.length) {
|
|
const opensClass = !tokens[t].esc && tokens[t].text === '['
|
|
&& t + 1 < tokens.length && !tokens[t + 1].esc && tokens[t + 1].text === '^';
|
|
if (!opensClass) {
|
|
t += 1;
|
|
continue;
|
|
}
|
|
let u = t + 2;
|
|
const members = [];
|
|
let closed = false;
|
|
while (u < tokens.length) {
|
|
const tok = tokens[u];
|
|
if (!tok.esc && tok.text === ']') {
|
|
closed = true;
|
|
u += 1;
|
|
break;
|
|
}
|
|
members.push(tok);
|
|
u += 1;
|
|
}
|
|
if (closed) {
|
|
let hasPipe = false;
|
|
let isPure = true;
|
|
for (const m of members) {
|
|
if (!m.esc && m.text === '|') {
|
|
hasPipe = true;
|
|
continue;
|
|
}
|
|
if (m.esc && m.text === '|') {
|
|
hasPipe = true;
|
|
continue;
|
|
}
|
|
if (m.esc && (m.text === 'n' || m.text === 'r' || m.text === 't')) continue;
|
|
isPure = false;
|
|
}
|
|
if (hasPipe && isPure) return true;
|
|
}
|
|
// Monotonic advance: whether this candidate qualified, failed, or
|
|
// ran off the end unclosed, never re-enter the bytes just consumed.
|
|
t = closed ? u : tokens.length;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Composed of random characters PLUS randomly inserted `[^...]` classes
|
|
// with and without pipes (some closed, some not; some qualifying, some
|
|
// not) so both the "flagged" and "not flagged" verdicts are well
|
|
// exercised — a purely uniform character soup almost never assembles a
|
|
// well-formed `[^...|...]` class by chance.
|
|
const pipeMemberArb = fc.constantFrom('|', '\\|');
|
|
const pureFillerArb = fc.constantFrom('\\n', '\\r', '\\t');
|
|
const impureFillerArb = fc.constantFrom('a', 'Z', '1', '\\s', '\\d', '\\w', '\\\\', '-', ' ', '\\]', '\\[');
|
|
const classMemberArb = fc.oneof(pipeMemberArb, pureFillerArb, impureFillerArb);
|
|
const classBodyArb = fc.array(classMemberArb, { minLength: 1, maxLength: 3 }).map((members) => members.join(''));
|
|
const classChunkArb = fc
|
|
.record({ body: classBodyArb, closed: fc.boolean() })
|
|
.map(({ body, closed }) => '[^' + body + (closed ? ']' : ''));
|
|
const noiseCharArb = fc.constantFrom('[', ']', '^', 'x', 'y', '0', '9', ' ', '.', '-', '(', ')');
|
|
const escapeChunkArb = fc
|
|
.tuple(fc.constant('\\'), fc.constantFrom('n', 'r', 't', '|', 's', 'd', '\\', '[', ']', '^', 'a'))
|
|
.map(([bs, c]) => bs + c);
|
|
const chunkArb = fc.oneof(
|
|
{ weight: 5, arbitrary: classChunkArb },
|
|
{ weight: 2, arbitrary: escapeChunkArb },
|
|
{ weight: 2, arbitrary: noiseCharArb },
|
|
);
|
|
const srcArb = fc.array(chunkArb, { minLength: 0, maxLength: 5 }).map((chunks) => chunks.join(''));
|
|
|
|
fc.assert(
|
|
fc.property(srcArb, (fuzzed) => {
|
|
const src = '\\|' + fuzzed;
|
|
const expected = referenceHasQualifyingNegatedPipeClass(src);
|
|
const code = `const re = new RegExp(${JSON.stringify(src)});`;
|
|
if (expected) {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code,
|
|
filename: 'src/some-module.cts',
|
|
errors: [{ messageId: 'tableRegex' }],
|
|
},
|
|
],
|
|
});
|
|
}
|
|
else {
|
|
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
|
valid: [{ code, filename: 'src/some-module.cts' }],
|
|
invalid: [],
|
|
});
|
|
}
|
|
}),
|
|
{ numRuns: 200, seed: 2880 },
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── no-duplicate-fold-marker ────────────────────────────────────────────────
|
|
|
|
describe('no-duplicate-fold-marker rule', () => {
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
|
|
/** Build a source string whose line numbers are the array indices + 1. */
|
|
const src = (...lines) => lines.join('\n');
|
|
|
|
const FOLD_A_B1 = '__foldDescribe("folded:a (consolidation epic #1969 B1 #1970)", () => {});';
|
|
const FOLD_A_B5 = '__foldDescribe("folded:a (consolidation epic #1969 B5 #1975)", () => {});';
|
|
const FOLD_B_B1 = '__foldDescribe("folded:b (consolidation epic #1969 B1 #1970)", () => {});';
|
|
|
|
test('rule module exports a create function', () => {
|
|
assert.strictEqual(typeof noDuplicateFoldMarker.create, 'function');
|
|
});
|
|
|
|
// ── Row 1: the #3271 regression, asserted against the real tree ────────────
|
|
//
|
|
// The unit cases below prove the rule can fire. THIS proves the tree it
|
|
// guards is actually clean — it is the assertion that was red before the 25
|
|
// duplicated regions were deleted (18 in install.test.cjs, 5 in
|
|
// install-minimal-hooks.test.cjs, 2 in install-write-confinement.test.cjs).
|
|
//
|
|
// Driven through the real ESLint API over the production glob rather than a
|
|
// hand-rolled scan of file text: a readFileSync + .match() scan of a .cjs
|
|
// path is exactly the shape `local/no-source-grep` bans in tests/**.
|
|
test('regression #3271: the real tests/ tree has no duplicate fold markers', async () => {
|
|
const eslint = new ESLint({
|
|
cwd: REPO_ROOT,
|
|
overrideConfigFile: true,
|
|
overrideConfig: {
|
|
files: ['tests/**/*.cjs'],
|
|
plugins: { local: { rules: { 'no-duplicate-fold-marker': noDuplicateFoldMarker } } },
|
|
languageOptions: { ecmaVersion: 2022, sourceType: 'commonjs' },
|
|
rules: { 'local/no-duplicate-fold-marker': 'error' },
|
|
},
|
|
});
|
|
|
|
const results = await eslint.lintFiles(['tests/**/*.cjs']);
|
|
|
|
// Filter to THIS rule: an ad-hoc config also surfaces "rule not found" for
|
|
// inline eslint-disable directives naming rules it does not register.
|
|
const violations = results.flatMap((r) =>
|
|
r.messages
|
|
.filter((m) => m.ruleId === 'local/no-duplicate-fold-marker')
|
|
.map((m) => `${path.relative(REPO_ROOT, r.filePath)}:${m.line} ${m.message}`),
|
|
);
|
|
|
|
// Non-vacuous: if the glob silently matched nothing, the empty result below
|
|
// would be meaningless.
|
|
assert.ok(results.length > 100, `expected the tests/ glob to match many files, got ${results.length}`);
|
|
assert.deepStrictEqual(violations, [], `duplicate folded suites found:\n${violations.join('\n')}`);
|
|
});
|
|
|
|
test('the rule is registered at error for tests/**/*.cjs in the real config', async () => {
|
|
const eslint = new ESLint({ cwd: REPO_ROOT });
|
|
const config = await eslint.calculateConfigForFile(
|
|
path.join(REPO_ROOT, 'tests', 'install.test.cjs'),
|
|
);
|
|
assert.deepStrictEqual(config.rules['local/no-duplicate-fold-marker'], [2]);
|
|
});
|
|
|
|
// ── Occurrence-count boundary: 1 (clean) / 2 (one report) / 3 (two) ────────
|
|
|
|
test('valid: a single folded marker in a file', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [{ code: src(FOLD_A_B1), filename: 'tests/host.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('invalid: the same folded marker twice in one file', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: src(FOLD_A_B1, FOLD_A_B1),
|
|
filename: 'tests/host.test.cjs',
|
|
errors: [
|
|
{ messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 2 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: three occurrences report the 2nd and 3rd', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: src(FOLD_A_B1, FOLD_A_B1, FOLD_A_B1),
|
|
filename: 'tests/host.test.cjs',
|
|
errors: [
|
|
{ messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 2 },
|
|
{ messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 3 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: two distinct folded markers', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [{ code: src(FOLD_A_B1, FOLD_B_B1), filename: 'tests/host.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── Negative space (10-diagnosis.md) ──────────────────────────────────────
|
|
|
|
// #3271's own reproduction regex (`folded:[a-z0-9-]*`) stops at `.` and
|
|
// collides these two genuinely distinct suites, which coexist in
|
|
// tests/model-resolver.test.cjs. A guard written to that key would red the
|
|
// build on `next` forever.
|
|
test('valid: dot-suffixed marker is distinct from its prefix (model-resolver #3271 false positive)', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{
|
|
code: src(
|
|
'__foldDescribe("folded:feat-443-effort-fast-mode.integration (consolidation epic #1969 B8 #1977)", () => {});',
|
|
'__foldDescribe("folded:feat-443-effort-fast-mode (consolidation epic #1969 B8 #1977)", () => {});',
|
|
),
|
|
filename: 'tests/model-resolver.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// tests/review-default-reviewers-workflow.test.cjs reuses the fold alias for
|
|
// an ordinary describe block. Those carry no uniqueness obligation.
|
|
test('valid: __foldDescribe titles without a folded: prefix are ignored', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{
|
|
code: src(
|
|
"__foldDescribe('#1936: OpenCode reviewer empty-output hardening', () => {});",
|
|
"__foldDescribe('#1936: OpenCode reviewer empty-output hardening', () => {});",
|
|
),
|
|
filename: 'tests/host.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: a file with no fold markers', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [{ code: src('describe("ordinary", () => {});'), filename: 'tests/host.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: plain describe with a folded: title is not the fold convention', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{
|
|
code: src(
|
|
'describe("folded:a (consolidation epic #1969 B1 #1970)", () => {});',
|
|
'describe("folded:a (consolidation epic #1969 B1 #1970)", () => {});',
|
|
),
|
|
filename: 'tests/host.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// Documented non-goal, pinned so the behavior is deliberate rather than
|
|
// accidental: the rule keys on the callee identifier being literally
|
|
// __foldDescribe. Every one of the 365 fold sites calls it directly.
|
|
test('valid: a call through a further alias of the fold alias is not tracked', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{
|
|
code: src(
|
|
'const d = __foldDescribe;',
|
|
'd("folded:a (consolidation epic #1969 B1 #1970)", () => {});',
|
|
'd("folded:a (consolidation epic #1969 B1 #1970)", () => {});',
|
|
),
|
|
filename: 'tests/host.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: a member-expression call named __foldDescribe is not the fold alias', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{
|
|
code: src(
|
|
'helpers.__foldDescribe("folded:a (consolidation epic #1969 B1 #1970)", () => {});',
|
|
'helpers.__foldDescribe("folded:a (consolidation epic #1969 B1 #1970)", () => {});',
|
|
),
|
|
filename: 'tests/host.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// The same marker in two different HOST files is not intra-file duplication.
|
|
// RuleTester lints each entry as its own file, so this also proves the
|
|
// per-file state is rebuilt rather than shared across files.
|
|
test('valid: the same marker in two different files is not an intra-file duplicate', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{ code: src(FOLD_A_B1), filename: 'tests/host-one.test.cjs' },
|
|
{ code: src(FOLD_A_B1), filename: 'tests/host-two.test.cjs' },
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// ── Ordering / identity ───────────────────────────────────────────────────
|
|
|
|
test('invalid: interleaved duplicates each report against their own first occurrence', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: src(FOLD_A_B1, FOLD_B_B1, FOLD_A_B1, FOLD_B_B1),
|
|
filename: 'tests/host.test.cjs',
|
|
errors: [
|
|
{ messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 3 },
|
|
{ messageId: 'duplicateFoldMarker', data: { marker: 'b', firstLine: '2' }, line: 4 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// The batch label is provenance, not identity — a re-fold under a different
|
|
// batch must not evade the guard. This is the exact shape of #3271: #1975
|
|
// re-applied #1970's blocks.
|
|
test('invalid: a duplicate marker is reported even when the batch label differs', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: src(FOLD_A_B1, FOLD_A_B5),
|
|
filename: 'tests/host.test.cjs',
|
|
errors: [
|
|
{ messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 2 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// ── Title shapes that cannot be resolved statically ───────────────────────
|
|
|
|
test('invalid: substitution-free template-literal fold titles are resolved', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: src(
|
|
'__foldDescribe(`folded:a (consolidation epic #1969 B1 #1970)`, () => {});',
|
|
'__foldDescribe(`folded:a (consolidation epic #1969 B1 #1970)`, () => {});',
|
|
),
|
|
filename: 'tests/host.test.cjs',
|
|
errors: [
|
|
{ messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 2 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('valid: non-literal fold titles are skipped without throwing', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{
|
|
code: src(
|
|
'const name = "folded:a";',
|
|
'const x = "a";',
|
|
'__foldDescribe(name, () => {});',
|
|
'__foldDescribe(name, () => {});',
|
|
'__foldDescribe(`folded:${x} (epic)`, () => {});',
|
|
'__foldDescribe(`folded:${x} (epic)`, () => {});',
|
|
'__foldDescribe(42, () => {});',
|
|
'__foldDescribe(42, () => {});',
|
|
),
|
|
filename: 'tests/host.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: __foldDescribe with no arguments does not throw', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{ code: src('__foldDescribe();', '__foldDescribe();'), filename: 'tests/host.test.cjs' },
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: an empty marker after the folded: prefix is not tracked', () => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [
|
|
{
|
|
code: src(
|
|
'__foldDescribe("folded: (consolidation epic #1969 B1 #1970)", () => {});',
|
|
'__foldDescribe("folded: (consolidation epic #1969 B1 #1970)", () => {});',
|
|
),
|
|
filename: 'tests/host.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
// Property: marker identity is the whole whitespace-delimited token.
|
|
//
|
|
// This is the generative form of the #3271 correctness question. An
|
|
// implementation that keyed on the issue's `[a-z0-9-]*` slice would truncate
|
|
// at `.` and pass arm 1 while failing arm 2 on any pair like
|
|
// (`a.integration`, `a`) — which is exactly the tests/model-resolver.test.cjs
|
|
// false positive. The alphabet deliberately includes `.` and `_` so those
|
|
// pairs are generated, not hoped for.
|
|
//
|
|
// `fc` is already imported at the top of this file and used by the
|
|
// no-adhoc-markdown-parsing suite; this follows the same
|
|
// fc.property-driving-ruleTester shape.
|
|
test('property: a marker is identified by its whole token, so distinct markers never collide', () => {
|
|
const markerArb = fc
|
|
.array(fc.constantFrom('a', 'z', 'q', '0', '9', '-', '.', '_'), { minLength: 1, maxLength: 12 })
|
|
.map((chars) => chars.join(''));
|
|
|
|
const fold = (marker) =>
|
|
`__foldDescribe("folded:${marker} (consolidation epic #1969 B1 #1970)", () => {});`;
|
|
|
|
// Arm 1: the SAME marker twice is always reported exactly once, against
|
|
// the first occurrence.
|
|
fc.assert(
|
|
fc.property(markerArb, (marker) => {
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: src(fold(marker), fold(marker)),
|
|
filename: 'tests/host.test.cjs',
|
|
errors: [
|
|
{ messageId: 'duplicateFoldMarker', data: { marker, firstLine: '1' }, line: 2 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
}),
|
|
{ numRuns: 150, seed: 3271 },
|
|
);
|
|
|
|
// Arm 2: two DISTINCT markers never collide, however they differ.
|
|
fc.assert(
|
|
fc.property(markerArb, markerArb, (a, b) => {
|
|
fc.pre(a !== b);
|
|
ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, {
|
|
valid: [{ code: src(fold(a), fold(b)), filename: 'tests/host.test.cjs' }],
|
|
invalid: [],
|
|
});
|
|
}),
|
|
{ numRuns: 150, seed: 3271 },
|
|
);
|
|
});
|
|
});
|