* 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:
206
eslint-rules/require-full-tmpdir-triad.cjs
Normal file
206
eslint-rules/require-full-tmpdir-triad.cjs
Normal 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;
|
||||
Reference in New Issue
Block a user