Files
msd-core/tests/require-full-tmpdir-triad.rule.test.cjs
Tom Boucher 1fe85cd43e 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>
2026-09-03 14:14:09 -04:00

215 lines
6.4 KiB
JavaScript

'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: [],
});
});
});