* fix(#4244): repoint TEMP/TMP alongside TMPDIR and fix the sweepProtectSet fixed-point walk Repo-wide sweep (ahead of adding lint rules for these exact bug classes) found both incident patterns still live and unfixed on `next`: - scripts/run-tests.cjs's sweepProtectSet walk stopped on `cur !== runTempRoot && cur.length > 1` — a POSIX-only sentinel. win32 dirname('D:\') is a fixed point (length 3, never satisfies `> 1`... wait, it does satisfy length>1), so a selected file living outside runTempRoot (the common case) spins the walk forever on Windows. Extracted a pure, exported computeSweepProtectSet helper that terminates on dirname(cur) === cur instead, with in-process RuleTester-style coverage for both win32 and posix paths. - tests/run-tests-temp-root.test.cjs's own #4020 regression test set only TMPDIR on its runNode(...) child env. Node's os.tmpdir() never reads TMPDIR on Windows (only TEMP, then TMP), so the redirect silently no-oped there — masked because Windows CI died in the dirname-walk hang above before ever reaching this test. - tests/config-schema.property.test.cjs's fallow config-set test had the same TMPDIR-only pattern, direct process.env assignment this time, restored in its own finally block. Origin: #4220 and its shared root cause #4020. * feat(#4244): require-full-tmpdir-triad and no-unbounded-dirname-walk ESLint rules Two custom local ESLint rules catch the #4220 / #4020 Windows CI hang bug class at author time, joining the ADR-1703 DEFECT.WINDOWS-TEST-PORTABILITY catalog. Neither eslint-plugin-unicorn nor eslint-plugin-n has a rule for either shape. - local/require-full-tmpdir-triad: flags a TMPDIR environment override (direct process.env.TMPDIR assignment, or a TMPDIR property in a spawn-like call's env: object literal) not accompanied by TEMP and TMP in the same scope. Node's os.tmpdir() never reads TMPDIR on Windows. Registered on tests/**/*.cjs, matching the require-userprofile-with-home precedent. - local/no-unbounded-dirname-walk: flags a while/do-while loop reassigning from dirname() with no fixed-point termination guard (dirname(cur) !== cur, or path.parse(cur).root). path.dirname() is a no-op at the platform root, but the value differs by platform (win32 'D:\' is length 3, posix '/' is length 1), so a POSIX-shaped length/equality bound never fires on Windows. Registered on BOTH tests/**/*.cjs and scripts/**/*.cjs — the real #4020 bug lived in scripts/run-tests.cjs, not tests/. Both rules join the zero-escape-hatch discipline already established for this catalog (no bespoke comment marker; PROTECTED_RULES in tests/portability-rule-disable-ban.test.cjs independently bans eslint-disable of either). ADR-1703 and its two companion contributing docs get an amendment documenting the mechanism, code examples, and the repo-wide sweep (three live instances found and fixed in the prior commit; no others found). CI test-scope selection updated so an edit to either rule or to scripts/run-tests.cjs re-runs the right suites. * fix(#4244): no-unbounded-dirname-walk must analyze a single-condition loop test too checkWhile bailed out early unless node.test was a LogicalExpression, so a single-condition loop -- while (cur !== root) { cur = dirname(cur); } -- was silently skipped and never reported. That is the EXACT minimal shape of the original #4020/#4220 bug, and it is literally the shape used by this rule's own shipped RuleTester fixtures (the "equality-only bound" invalid cases), which were failing (0 errors reported, 1 expected) until this fix -- confirmed by running RuleTester directly against both fixtures, not just via a passing test-runner exit code. The conjunct-collection helper already handled a non-LogicalExpression test correctly (it pushes a single node as the sole conjunct); only the early-return gate needed to stop requiring a compound && / || test. Verified: RuleTester run directly against both previously-broken fixtures plus two new sanity cases (a guarded single-condition loop stays valid; an unrelated single-condition loop stays silent), and a fresh `npx eslint .` across the whole repo remains clean (no other single-condition dirname-walk shape exists in the tree). * fix(#4244): require-full-tmpdir-triad must recognize a destructured child_process call isSpawnLikeCallee only recognized a MemberExpression callee (child_process.spawnSync(...)) or a bare identifier in ENV_LOCAL_HELPER_NAMES (runNode). A destructured import called bare -- const { spawnSync } = require('child_process'); spawnSync(...) -- has an Identifier callee named "spawnSync", which matched neither branch, so the whole env-literal check was skipped. gsd-test caught this: both "invalid: child_process.spawnSync with TMPDIR-only env" cases in tests/require-full-tmpdir-triad.rule.test.cjs were failing (0 errors reported, 1 expected). Widened the bare-identifier branch to also match any of the known ENV_CHILD_PROCESS_METHODS names, matched by name only -- the same lightweight convention this repo's other eslint-rules/*.cjs use (e.g. no-hardcoded-tmp.cjs's isFsMethodCall), not full import data-flow tracing. Verified: RuleTester run directly against all 11 cases in tests/require-full-tmpdir-triad.rule.test.cjs (not just the two that were failing), all pass; a fresh npx eslint . and npm run lint:ci across the whole repo remain clean. * fix(#4244): correct a stale escape-hatch reference in a test comment The comment on the "length comparison against another expression's length" case referenced a "// allow-dirname-walk marker" that doesn't exist -- the rule has zero comment-based escape hatches by design (ADR-1703), and an earlier draft's marker mechanism was removed before this branch's first commit. Spec-axis review caught the stale reference. No behavior change; comment-only. * chore(#4244): backfill changeset PR number (pr:0 -> pr:4246) --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -1097,10 +1097,16 @@ describe('feat-3210: workflow and config contracts', () => {
|
||||
|
||||
test('config-set accepts code_quality.fallow keys', () => {
|
||||
const originalTmpDir = process.env.TMPDIR;
|
||||
const originalTemp = process.env.TEMP;
|
||||
const originalTmp = process.env.TMP;
|
||||
// L2: fail loudly if no writable tmp dir is found (was silent skip)
|
||||
const writableTmp = getWritableTmp(); // N2: use shared helper
|
||||
assert.ok(writableTmp, 'no writable tmp directory found'); // L2: explicit fail-loud assertion
|
||||
// #4220 class: os.tmpdir() never reads TMPDIR on Windows (only TEMP, then
|
||||
// TMP), so redirecting only TMPDIR silently no-ops there — set all three.
|
||||
process.env.TMPDIR = writableTmp;
|
||||
process.env.TEMP = writableTmp;
|
||||
process.env.TMP = writableTmp;
|
||||
const tmpDir = createTempProject('gsd-fallow-config-');
|
||||
try {
|
||||
const cases = [
|
||||
@@ -1117,6 +1123,10 @@ describe('feat-3210: workflow and config contracts', () => {
|
||||
cleanup(tmpDir);
|
||||
if (originalTmpDir === undefined) delete process.env.TMPDIR;
|
||||
else process.env.TMPDIR = originalTmpDir;
|
||||
if (originalTemp === undefined) delete process.env.TEMP;
|
||||
else process.env.TEMP = originalTemp;
|
||||
if (originalTmp === undefined) delete process.env.TMP;
|
||||
else process.env.TMP = originalTmp;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
207
tests/no-unbounded-dirname-walk.rule.test.cjs
Normal file
207
tests/no-unbounded-dirname-walk.rule.test.cjs
Normal file
@@ -0,0 +1,207 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-unbounded-dirname-walk.rule.test.cjs
|
||||
*
|
||||
* RuleTester unit tests for the local/no-unbounded-dirname-walk ESLint rule.
|
||||
*
|
||||
* Rule: flag a while/do-while loop that reassigns its condition variable
|
||||
* from path.dirname() without a fixed-point termination guard
|
||||
* (DEFECT.WINDOWS-TEST-PORTABILITY, the #4020 / #4220 Windows CI hang:
|
||||
* path.win32.dirname() is a no-op at the drive root, so a length- or
|
||||
* equality-only bound spins forever there).
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { RuleTester } = require('eslint');
|
||||
|
||||
const rule = require('../eslint-rules/no-unbounded-dirname-walk.cjs');
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
});
|
||||
|
||||
describe('no-unbounded-dirname-walk rule module', () => {
|
||||
test('exports a create function and the unboundedWalk message', () => {
|
||||
assert.strictEqual(typeof rule.create, 'function');
|
||||
assert.strictEqual(rule.meta.type, 'problem');
|
||||
assert.ok(rule.meta.messages.unboundedWalk, 'unboundedWalk message must exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('no-unbounded-dirname-walk: invalid — no fixed-point guard', () => {
|
||||
test('invalid: the real #4020/#4220 shape — length-bounded walk against a POSIX-only sentinel', () => {
|
||||
ruleTester.run('no-unbounded-dirname-walk', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
// The exact shipped shape: on Windows the repo is on D:\, the target
|
||||
// root on C:\ — `cur !== runTempRoot` holds forever and win32
|
||||
// dirname('D:\\') is a fixed point, so this spins at 100% CPU.
|
||||
code: `
|
||||
const { dirname } = require('path');
|
||||
const protectSet = new Set();
|
||||
let cur = f;
|
||||
while (cur && cur !== runTempRoot && cur.length > 1) {
|
||||
protectSet.add(cur);
|
||||
cur = dirname(cur);
|
||||
}
|
||||
`,
|
||||
errors: [{ messageId: 'unboundedWalk' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: destructured dirname with equality-only bound', () => {
|
||||
ruleTester.run('no-unbounded-dirname-walk', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
const { dirname } = require('path');
|
||||
let cur = file;
|
||||
while (cur !== root) {
|
||||
cur = dirname(cur);
|
||||
}
|
||||
`,
|
||||
errors: [{ messageId: 'unboundedWalk' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: path.dirname member form with equality-only bound', () => {
|
||||
ruleTester.run('no-unbounded-dirname-walk', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
const path = require('path');
|
||||
let cur = file;
|
||||
while (cur !== root) {
|
||||
cur = path.dirname(cur);
|
||||
}
|
||||
`,
|
||||
errors: [{ messageId: 'unboundedWalk' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: do-while form with no fixed-point guard', () => {
|
||||
ruleTester.run('no-unbounded-dirname-walk', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
const path = require('path');
|
||||
let cur = file;
|
||||
do {
|
||||
cur = path.dirname(cur);
|
||||
} while (cur !== root && cur.length > 1);
|
||||
`,
|
||||
errors: [{ messageId: 'unboundedWalk' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('no-unbounded-dirname-walk: valid — fixed-point guard present', () => {
|
||||
test('valid: the real fixed shape — dirname(cur) !== cur added to the condition', () => {
|
||||
ruleTester.run('no-unbounded-dirname-walk', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
const { dirname } = require('path');
|
||||
const protectSet = new Set();
|
||||
let cur = f;
|
||||
while (cur && cur !== runTempRoot && dirname(cur) !== cur) {
|
||||
protectSet.add(cur);
|
||||
cur = dirname(cur);
|
||||
}
|
||||
`,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: walk bounded via path.parse(cur).root', () => {
|
||||
ruleTester.run('no-unbounded-dirname-walk', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
const path = require('path');
|
||||
let cur = file;
|
||||
while (cur && cur !== path.parse(cur).root) {
|
||||
cur = path.dirname(cur);
|
||||
}
|
||||
`,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: a length-only bound is still unsafe in principle, but the fixed-point conjunct present here silences it', () => {
|
||||
ruleTester.run('no-unbounded-dirname-walk', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
const { dirname } = require('path');
|
||||
let cur = file;
|
||||
while (cur && cur.length > 1 && dirname(cur) !== cur) {
|
||||
cur = dirname(cur);
|
||||
}
|
||||
`,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: not a dirname walk at all — a linked-list traversal must stay silent', () => {
|
||||
ruleTester.run('no-unbounded-dirname-walk', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
let cur = list.head;
|
||||
while (cur && cur.length > 1) { cur = cur.next; }
|
||||
`,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: a length comparison against another expression\'s length is still unguarded — no fixed-point conjunct present', () => {
|
||||
// Confirms the rule does not special-case a dynamic (non-literal) length
|
||||
// bound as an implicit guard: only an explicit fixed-point conjunct
|
||||
// silences it. This rule has NO comment-marker escape hatch (ADR-1703
|
||||
// zero-escape-hatch) — a loop shaped like this needs an added
|
||||
// `dirname(cur) !== cur` (or `path.parse(cur).root`) conjunct; there is
|
||||
// no annotation-based way to silence it instead.
|
||||
ruleTester.run('no-unbounded-dirname-walk', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
let cursor = path.dirname(path.resolve(target));
|
||||
const stop = path.resolve(root);
|
||||
while (cursor.length >= stop.length && cursor.startsWith(stop)) {
|
||||
if (check(cursor)) return true;
|
||||
cursor = path.dirname(cursor);
|
||||
}
|
||||
`,
|
||||
errors: [{ messageId: 'unboundedWalk' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,9 @@ const PROTECTED_RULES = [
|
||||
// ADR-1703 Phase 6 rule (issue #1740) — applies to src/**/*.cts AND the build/install
|
||||
// surface (bin/install.js, scripts/build-hooks.js) brought under lint by the glob expansion
|
||||
'require-fs-op-fallback',
|
||||
// #4244 hardening (origin #4020 / #4220 Windows CI hang) — applies to tests/**/*.test.cjs
|
||||
'require-full-tmpdir-triad',
|
||||
'no-unbounded-dirname-walk',
|
||||
];
|
||||
|
||||
// ── Detect disable directives via the comment text ───────────────────────────
|
||||
|
||||
214
tests/require-full-tmpdir-triad.rule.test.cjs
Normal file
214
tests/require-full-tmpdir-triad.rule.test.cjs
Normal file
@@ -0,0 +1,214 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* require-full-tmpdir-triad.rule.test.cjs
|
||||
*
|
||||
* RuleTester unit tests for the local/require-full-tmpdir-triad ESLint rule.
|
||||
*
|
||||
* Rule: flag a TMPDIR environment override — direct process.env.TMPDIR
|
||||
* assignment, or a TMPDIR property inside a child-process env: object
|
||||
* literal — that is not accompanied by TEMP and TMP in the same scope
|
||||
* (DEFECT.WINDOWS-TEST-PORTABILITY, the #4220 masked child-env bug: Node's
|
||||
* os.tmpdir() never reads TMPDIR on Windows).
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { RuleTester } = require('eslint');
|
||||
|
||||
const rule = require('../eslint-rules/require-full-tmpdir-triad.cjs');
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
});
|
||||
|
||||
// ─── module shape ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('require-full-tmpdir-triad rule module', () => {
|
||||
test('exports meta and create', () => {
|
||||
assert.strictEqual(typeof rule.meta, 'object');
|
||||
assert.strictEqual(typeof rule.create, 'function');
|
||||
assert.strictEqual(rule.meta.type, 'problem');
|
||||
assert.ok(rule.meta.messages.missingTempTmp, 'missingTempTmp message must exist');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Shape 1: direct process.env.TMPDIR assignment ────────────────────────────
|
||||
|
||||
describe('require-full-tmpdir-triad: direct assignment shape', () => {
|
||||
test('invalid: process.env.TMPDIR = X with no TEMP/TMP anywhere', () => {
|
||||
ruleTester.run('require-full-tmpdir-triad', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `process.env.TMPDIR = outer;`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'missingTempTmp' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: process.env["TMPDIR"] = X with only TEMP set (TMP missing)', () => {
|
||||
ruleTester.run('require-full-tmpdir-triad', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
process.env['TMPDIR'] = outer;
|
||||
process.env.TEMP = outer;
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'missingTempTmp' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: process.env.TMPDIR assigned AND TEMP AND TMP also assigned', () => {
|
||||
ruleTester.run('require-full-tmpdir-triad', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
process.env.TMPDIR = outer;
|
||||
process.env.TEMP = outer;
|
||||
process.env.TMP = outer;
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: no TMPDIR assignment at all', () => {
|
||||
ruleTester.run('require-full-tmpdir-triad', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const t = process.env.TMPDIR;`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: multiple TMPDIR assignments — all reported when TEMP/TMP absent', () => {
|
||||
ruleTester.run('require-full-tmpdir-triad', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
process.env.TMPDIR = a;
|
||||
process.env.TMPDIR = b;
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [
|
||||
{ messageId: 'missingTempTmp' },
|
||||
{ messageId: 'missingTempTmp' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Shape 2: object-literal env override passed to a spawn-like call ────────
|
||||
|
||||
describe('require-full-tmpdir-triad: child-process env object-literal shape', () => {
|
||||
test('invalid: the real #4220 shape — runNode(..., { env: { ...process.env, TMPDIR: outer } })', () => {
|
||||
ruleTester.run('require-full-tmpdir-triad', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const r = runNode(['-e', probe], { timeoutMs: 30000, env: { ...process.env, TMPDIR: outer } });`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'missingTempTmp' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: child_process.spawnSync with TMPDIR-only env', () => {
|
||||
ruleTester.run('require-full-tmpdir-triad', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
const { spawnSync } = require('child_process');
|
||||
spawnSync('node', ['-e', 'x'], { env: { ...process.env, TMPDIR: outer } });
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'missingTempTmp' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: execFileSync with TMPDIR-only env', () => {
|
||||
ruleTester.run('require-full-tmpdir-triad', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
const cp = require('child_process');
|
||||
cp.execFileSync('node', [], { env: { TMPDIR: outer } });
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'missingTempTmp' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: the real fixed shape — TMPDIR, TEMP, and TMP all in the same env literal', () => {
|
||||
ruleTester.run('require-full-tmpdir-triad', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const r = runNode(['-e', probe], { timeoutMs: 30000, env: { ...process.env, TMPDIR: outer, TEMP: outer, TMP: outer } });`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: env object with no TMPDIR key at all', () => {
|
||||
ruleTester.run('require-full-tmpdir-triad', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `runNode(['-e', probe], { env: { ...process.env, FOO: 'bar' } });`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: a spawn-like call with no env option at all', () => {
|
||||
ruleTester.run('require-full-tmpdir-triad', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `require('child_process').spawnSync('node', ['-v']);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: an unrelated call with an object literal containing TMPDIR is not a spawn — stays silent', () => {
|
||||
ruleTester.run('require-full-tmpdir-triad', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `buildConfig({ env: { TMPDIR: outer } });`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user