* 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>
171 lines
7.3 KiB
JavaScript
171 lines
7.3 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* no-unbounded-dirname-walk
|
|
*
|
|
* Flag an upward filesystem walk whose loop condition reassigns from
|
|
* path.dirname() but has no fixed-point termination guard.
|
|
*
|
|
* ## Why (DEFECT.WINDOWS-TEST-PORTABILITY — the #4020 / #4220 Windows CI hang)
|
|
*
|
|
* `path.dirname()` is a NO-OP at the platform root, but the no-op VALUE
|
|
* differs by platform: `path.posix.dirname('/') === '/'` (length 1), while
|
|
* `path.win32.dirname('C:\\') === 'C:\\'` (length 3, NOT length 1). An
|
|
* upward walk like
|
|
*
|
|
* while (cur && cur !== someRoot && cur.length > 1) cur = dirname(cur);
|
|
*
|
|
* terminates on POSIX by accident — the walk reaches '/', whose length is 1
|
|
* — but NEVER on Windows when the walk cannot equal `someRoot` (e.g. the
|
|
* repo checkout on `D:\a\...` and a temp root on `C:\Users\...`): `cur !==
|
|
* someRoot` holds forever, `dirname(cur)` reaches the drive root and stays
|
|
* there, and a length check against a POSIX-shaped "root is length 1"
|
|
* assumption never fires. The loop spins at 100% CPU with zero output until
|
|
* something external kills it. That exact shape hung every scoped Windows
|
|
* CI lane for a day — `scripts/run-tests.cjs`'s `sweepProtectSet` walk
|
|
* (#4020, re-surfaced against a fresh Windows regression as #4220).
|
|
*
|
|
* ## What this enforces
|
|
*
|
|
* Any loop that reassigns its condition variable from a `dirname(...)` call
|
|
* must ALSO test that the walk has reached a fixed point — the portable
|
|
* root check — i.e. the condition set must include a comparison between the
|
|
* variable and its own dirname:
|
|
*
|
|
* while (cur && cur !== root && dirname(cur) !== cur) { ... }
|
|
*
|
|
* or compare against `path.parse(cur).root`, the other portable form. A
|
|
* length-only or equality-only bound, with no fixed-point conjunct, is
|
|
* reported as `unboundedWalk`.
|
|
*
|
|
* ## Recognized call shapes
|
|
*
|
|
* - Bare `dirname(...)` from a destructure (`const { dirname } = require('path')`)
|
|
* or an aliased one (`const { dirname: dir } = ...`).
|
|
* - Chained `path.dirname(...)` / `require('path').dirname(...)`.
|
|
* - `path.posix.dirname` / `path.win32.dirname` member chains.
|
|
*
|
|
* ## Zero escape hatches (ADR-1703)
|
|
*
|
|
* This rule joins the ADR-1703 `DEFECT.WINDOWS-TEST-PORTABILITY` catalog,
|
|
* which deliberately carries no comment-based opt-out: a walk that is
|
|
* bounded by some other genuinely portable mechanism (a hard iteration cap,
|
|
* a dynamic non-literal boundary) must be structured so the rule recognizes
|
|
* it — add the `dirname(cur) !== cur` (or `path.parse(cur).root`) conjunct
|
|
* alongside the other bound, which costs nothing at runtime and is what the
|
|
* shipped #4020/#4220 fix itself does. A false positive is a rule bug, fixed
|
|
* in the rule — see `tests/portability-rule-disable-ban.test.cjs`, which
|
|
* independently bans `eslint-disable` of this rule too.
|
|
*/
|
|
|
|
const DIAGNOSTIC = 'unboundedWalk';
|
|
|
|
const DIRNAME_RE = /^(?:dirname)$/;
|
|
|
|
/** Property chain tail of a member-ish callee: path.dirname -> 'dirname'. */
|
|
function propertyName(node) {
|
|
return node && node.type === 'MemberExpression' && !node.computed
|
|
? node.property.name
|
|
: null;
|
|
}
|
|
|
|
function isDirnameCall(expr) {
|
|
if (!expr || expr.type !== 'CallExpression') return false;
|
|
const callee = expr.callee;
|
|
if (callee.type === 'Identifier') return DIRNAME_RE.test(callee.name);
|
|
// path.dirname / path.posix.dirname / require('path').dirname
|
|
return DIRNAME_RE.test(String(propertyName(callee) ?? ''));
|
|
}
|
|
|
|
module.exports = {
|
|
meta: {
|
|
type: 'problem',
|
|
docs: {
|
|
description: 'an upward dirname() walk must carry a fixed-point termination guard',
|
|
category: 'Portability',
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
[DIAGNOSTIC]:
|
|
'This dirname() walk has no fixed-point termination guard (DEFECT.WINDOWS-TEST-PORTABILITY): ' +
|
|
"path.dirname() is a no-op at the platform root (win32 dirname('D:\\\\') === 'D:\\\\'), so on " +
|
|
'Windows a length- or equality-bounded walk over a path that never equals its target root ' +
|
|
'spins forever at 100% CPU. Add `dirname(cur) !== cur` (or compare against ' +
|
|
'path.parse(cur).root) to the loop condition. (#4020 / #4220 Windows CI hang)',
|
|
},
|
|
},
|
|
|
|
create(context) {
|
|
function checkWhile(node) {
|
|
const test = node.test;
|
|
// NOTE: test may be a single BinaryExpression (`while (cur !== root)`),
|
|
// not only a compound LogicalExpression (`while (cur !== root && …)`)
|
|
// — the minimal #4020/#4220 shape is a SINGLE unguarded condition, so
|
|
// this must not require LogicalExpression up front. collect() below
|
|
// already handles a non-LogicalExpression test correctly (pushes it as
|
|
// the sole conjunct); only this early gate needs to admit that shape.
|
|
if (!test) return;
|
|
|
|
// The reassignment: cur = dirname(cur) somewhere in the body (or the
|
|
// update clause of a for-loop shape routed through the same check).
|
|
let reassignsFromDirname = false;
|
|
const bodyStatements = node.body && node.body.type === 'BlockStatement'
|
|
? node.body.body
|
|
: node.body
|
|
? [node.body]
|
|
: [];
|
|
for (const stmt of bodyStatements) {
|
|
for (const child of [stmt, stmt.expression]) {
|
|
if (
|
|
child && child.type === 'AssignmentExpression' && child.operator === '=' &&
|
|
child.left.type === 'Identifier' && isDirnameCall(child.right) &&
|
|
child.right.arguments[0] && child.right.arguments[0].type === 'Identifier' &&
|
|
child.right.arguments[0].name === child.left.name
|
|
) {
|
|
reassignsFromDirname = true;
|
|
}
|
|
}
|
|
}
|
|
if (!reassignsFromDirname) return;
|
|
|
|
// The guard: some conjunct compares the walked variable to its own
|
|
// dirname, or to path.parse(<var>).root.
|
|
let hasFixedPointGuard = false;
|
|
const conjuncts = [];
|
|
(function collect(e) {
|
|
if (e && e.type === 'LogicalExpression') { collect(e.left); collect(e.right); }
|
|
else if (e) conjuncts.push(e);
|
|
})(test);
|
|
for (const c of conjuncts) {
|
|
if (c.type !== 'BinaryExpression' || !['!==', '!=', '===', '=='].includes(c.operator)) continue;
|
|
// dirname(cur) <op> cur, or cur <op> dirname(cur)
|
|
if ((isDirnameCall(c.left) && c.right.type === 'Identifier' &&
|
|
isDirnameCall(c.right) === false && c.left.arguments[0] &&
|
|
c.left.arguments[0].name === c.right.name) ||
|
|
(isDirnameCall(c.right) && c.left.type === 'Identifier' &&
|
|
c.right.arguments[0] && c.right.arguments[0].name === c.left.name)) {
|
|
hasFixedPointGuard = true;
|
|
}
|
|
// path.parse(cur).root <op> cur — the other portable root sentinel.
|
|
const isParseRoot = (n, other) =>
|
|
n && n.type === 'MemberExpression' && !n.computed && n.property.name === 'root' &&
|
|
n.object && n.object.type === 'CallExpression' &&
|
|
/parse/.test(String(n.object.callee.property?.name ?? n.object.callee.name ?? '')) &&
|
|
n.object.arguments[0] && n.object.arguments[0].type === 'Identifier' &&
|
|
other && other.type === 'Identifier' &&
|
|
n.object.arguments[0].name === other.name;
|
|
if (isParseRoot(c.left, c.right) || isParseRoot(c.right, c.left)) {
|
|
hasFixedPointGuard = true;
|
|
}
|
|
}
|
|
if (hasFixedPointGuard) return;
|
|
context.report({ node: test, messageId: DIAGNOSTIC });
|
|
}
|
|
|
|
return {
|
|
WhileStatement: checkWhile,
|
|
DoWhileStatement: checkWhile,
|
|
};
|
|
},
|
|
};
|