chore(#4244): ESLint rules for the #4220 Windows dirname-walk / TMPDIR-triad bug class (#4246)

* 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:
Tom Boucher
2026-09-03 14:14:09 -04:00
committed by GitHub
parent 456136659d
commit 1fe85cd43e
12 changed files with 941 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
'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,
};
},
};

View File

@@ -0,0 +1,206 @@
'use strict';
/**
* require-full-tmpdir-triad
*
* 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.
*
* ## Why (DEFECT.WINDOWS-TEST-PORTABILITY — the #4220 masked child-env bug)
*
* Per Node's own `os.tmpdir()` docs: on Windows, only the `TEMP` and `TMP`
* environment variables are consulted (`TEMP` first) — `TMPDIR` is never
* read there at all. On every other platform, `TMPDIR` is checked first,
* then `TMP`, then `TEMP`. Code that redirects a child process's temp
* directory by setting only `TMPDIR` in that child's `env` therefore does
* nothing on Windows: the child inherits the parent's ambient `TEMP`/`TMP`
* and its own `os.tmpdir()` resolves to the wrong place — silently, with no
* error, so the redirect just doesn't take effect. This exact shape shipped
* in `tests/run-tests-temp-root.test.cjs`'s own regression test for #4020:
* `env: { ...process.env, TMPDIR: outer }` on a `runNode(...)` child-process
* helper call, masked because Windows CI died in the unrelated #4020
* dirname-walk hang before ever reaching this test (see #4220).
*
* ## What this enforces
*
* Two independent shapes are covered:
*
* 1. Direct assignment: `process.env.TMPDIR = X` (or bracket form) in a
* file, without a `process.env.TEMP = …` AND a `process.env.TMP = …`
* assignment also present anywhere in that file (`Program:exit`
* collection, same pattern as `require-userprofile-with-home.cjs`).
* 2. Object-literal env override: an object literal with a `TMPDIR`
* property, passed as the `env` option to a child-process-spawning call
* (`child_process.spawn`/`spawnSync`/`exec`/`execSync`/`execFile`/
* `execFileSync`/`fork`, or a bare-named local helper that forwards to
* one, e.g. this repo's `runNode(...)` test helper) — flagged unless
* that SAME object literal also carries `TEMP` and `TMP` properties.
*
* Fix: set all three — `TMPDIR`, `TEMP`, and `TMP` — to the same value.
*
* ## 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 legitimately
* POSIX-only TMPDIR override must be structured so the rule never sees it
* (e.g. behind a `process.platform !== 'win32'` guard that also sets
* TEMP/TMP for the Windows branch), not annotated around. 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 ENV_CHILD_PROCESS_METHODS = new Set([
'spawn',
'spawnSync',
'exec',
'execSync',
'execFile',
'execFileSync',
'fork',
]);
// Local helpers in this repo that wrap a child-process call and forward an
// `env` option through unchanged — recognized by bare call name.
const ENV_LOCAL_HELPER_NAMES = new Set(['runNode']);
const DIAGNOSTIC = 'missingTempTmp';
/** @type {import('eslint').Rule.RuleModule} */
const rule = {
meta: {
type: 'problem',
docs: {
description:
'Require process.env.TEMP and process.env.TMP to be set alongside any TMPDIR override (Windows portability)',
category: 'Portability',
},
schema: [],
messages: {
[DIAGNOSTIC]:
'Setting TMPDIR without TEMP and TMP is not portable (DEFECT.WINDOWS-TEST-PORTABILITY): ' +
"Node's os.tmpdir() never reads TMPDIR on Windows (only TEMP, then TMP) — this override " +
'silently does nothing there. Set TEMP and TMP to the same value alongside TMPDIR.',
},
},
create(context) {
// ── Shape 1: process.env.TMPDIR = … direct assignment ──────────────────
/** Collected TMPDIR assignment nodes (process.env.TMPDIR = / process.env['TMPDIR'] =). */
const tmpdirAssignments = [];
let tempAssigned = false;
let tmpAssigned = false;
function isProcessEnvAssignment(lhs, key) {
if (!lhs || lhs.type !== 'MemberExpression') return false;
const obj = lhs.object;
if (!obj || obj.type !== 'MemberExpression') return false;
if (
obj.computed ||
obj.object.type !== 'Identifier' ||
obj.object.name !== 'process' ||
obj.property.type !== 'Identifier' ||
obj.property.name !== 'env'
) {
return false;
}
if (!lhs.computed) {
return lhs.property.type === 'Identifier' && lhs.property.name === key;
}
return lhs.property.type === 'Literal' && lhs.property.value === key;
}
// ── Shape 2: object literal with a TMPDIR property passed as `env` ─────
function isSpawnLikeCallee(callee) {
// child_process.spawn(...) / cp.spawnSync(...) / require('child_process').exec(...)
if (
callee.type === 'MemberExpression' &&
!callee.computed &&
callee.property.type === 'Identifier' &&
ENV_CHILD_PROCESS_METHODS.has(callee.property.name)
) {
return true;
}
// Bare identifier: either a destructured child_process method
// (`const { spawnSync } = require('child_process'); spawnSync(...)`)
// or a local helper known to wrap one (`runNode(...)`). Matched by
// name only, same lightweight convention as this repo's other
// eslint-rules/*.cjs (e.g. no-hardcoded-tmp.cjs's isFsMethodCall) —
// no import/require data-flow tracing.
if (
callee.type === 'Identifier' &&
(ENV_LOCAL_HELPER_NAMES.has(callee.name) || ENV_CHILD_PROCESS_METHODS.has(callee.name))
) {
return true;
}
return false;
}
function objectHasKey(objExpr, key) {
return objExpr.properties.some((p) => {
if (p.type !== 'Property') return false;
if (!p.computed) {
return (
(p.key.type === 'Identifier' && p.key.name === key) ||
(p.key.type === 'Literal' && p.key.value === key)
);
}
return p.key.type === 'Literal' && p.key.value === key;
});
}
function checkCallExpression(node) {
if (!isSpawnLikeCallee(node.callee)) return;
for (const arg of node.arguments) {
// opts is either the object literal directly, or nested in a later
// positional options argument — only the literal shape is checked;
// an options identifier passed by reference is out of scope (the
// AST cannot see its shape here).
if (arg.type !== 'ObjectExpression') continue;
const envProp = arg.properties.find(
(p) =>
p.type === 'Property' &&
!p.computed &&
((p.key.type === 'Identifier' && p.key.name === 'env') ||
(p.key.type === 'Literal' && p.key.value === 'env')),
);
if (!envProp || envProp.value.type !== 'ObjectExpression') continue;
const envObj = envProp.value;
if (!objectHasKey(envObj, 'TMPDIR')) continue;
if (objectHasKey(envObj, 'TEMP') && objectHasKey(envObj, 'TMP')) continue;
context.report({ node: envObj, messageId: DIAGNOSTIC });
}
}
return {
AssignmentExpression(node) {
if (isProcessEnvAssignment(node.left, 'TMPDIR')) {
tmpdirAssignments.push(node);
}
if (isProcessEnvAssignment(node.left, 'TEMP')) tempAssigned = true;
if (isProcessEnvAssignment(node.left, 'TMP')) tmpAssigned = true;
},
CallExpression(node) {
checkCallExpression(node);
},
'Program:exit'() {
if (tmpdirAssignments.length === 0) return;
if (tempAssigned && tmpAssigned) return;
for (const node of tmpdirAssignments) {
context.report({ node, messageId: DIAGNOSTIC });
}
},
};
},
};
module.exports = rule;