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

@@ -78,6 +78,8 @@ Replace all three with a single coherent mechanism: **AST-based ESLint rules in
| `require-fs-op-fallback` | `DEFECT.WINDOWS-FS-OPS` | `src/**/*.cts`, build/install |
| `no-private-binary-resolution` | `DEFECT.WINDOWS-PRIVATE-BINARY-RESOLUTION` | `src/**/*.cts`, `gsd-core/bin/**`, `scripts/**`, `hooks/**` |
| `no-exact-case-env-access` | `DEFECT.WINDOWS-EXACT-CASE-ENV-ACCESS` | `src/**/*.cts`, `gsd-core/bin/**`, `scripts/**`, `hooks/**` |
| `require-full-tmpdir-triad` | `DEFECT.WINDOWS-TEST-PORTABILITY` (#4220) | tests |
| `no-unbounded-dirname-walk` | `DEFECT.WINDOWS-TEST-PORTABILITY` (#4020 / #4220) | tests, scripts |
**Amendment (2026-08-18, epic #3411 Phase 3 / #3619).** `no-private-binary-resolution` is the
first catalog entry added after the original seven, and it extends this architecture to a
@@ -136,6 +138,54 @@ One real pre-existing violation of the tightened rule was found and fixed in the
`src/shell-command-projection.cts` specifically so this rule's remediation message ("route
through `envGet`") names a real, callable helper.
**Amendment (2026-09-03, #4244).** Two rules add author-time coverage for the bug class behind
two real, hard-evidence Windows CI incidents this week: #4020 (`scripts/run-tests.cjs`'s
`sweepProtectSet` ancestor walk hung every scoped Windows CI lane) and its follow-on #4220 (the
regression test written for #4020's own fix masked a second bug — see below). Per Node's own docs,
`os.tmpdir()` on Windows reads only `TEMP` then `TMP`; `TMPDIR` is never consulted there at all
(on every other platform, `TMPDIR` is checked first). Per empirical verification this session,
`path.dirname()` is a fixed point at the platform root on both OSes, but the fixed-point VALUE
differs: `path.posix.dirname('/') === '/'` (length 1) vs. `path.win32.dirname('C:\\') === 'C:\\'`
(length 3) — so a root check written as a POSIX-shaped length heuristic (`cur.length > 1`) never
fires on Windows.
- `require-full-tmpdir-triad` flags a `TMPDIR` environment override — `process.env.TMPDIR = …`,
or a `TMPDIR` property in an object literal passed as a spawn-like call's `env:` option — that
is not accompanied by `TEMP` and `TMP` in the same scope. Anti-pattern: `runNode(['-e', probe],
{ env: { ...process.env, TMPDIR: outer } })` — on Windows the child inherits the parent's
ambient `TEMP`/`TMP` and its `os.tmpdir()` silently resolves to the wrong place. Fix: set all
three to the same value. This is the exact shape #4220 found already shipped in
`tests/run-tests-temp-root.test.cjs`'s own #4020 regression test, masked because Windows died in
the unrelated dirname-walk hang before ever reaching it. The same #4244 sweep additionally found
and fixed one more live instance in `tests/config-schema.property.test.cjs`'s
`config-set accepts code_quality.fallow keys` test (direct `process.env.TMPDIR = writableTmp`
assignment with no TEMP/TMP counterpart).
- `no-unbounded-dirname-walk` flags a `while`/`do-while` loop that reassigns its condition
variable from `dirname()` (bare, `path.`, `.posix.`/`.win32.`) without a fixed-point termination
guard (`dirname(cur) !== cur`, or `path.parse(cur).root`) in the loop condition. Anti-pattern:
`while (cur && cur !== root && cur.length > 1) cur = dirname(cur);` — on a Windows runner where
`cur` can never equal `root` (e.g. repo on `D:\`, temp root on `C:\`), the walk reaches the
drive root and spins there at 100% CPU forever, since `cur.length` stays 3 (`> 1`) at the fixed
point. Fix: add the `dirname(cur) !== cur` conjunct. The same #4244 sweep found this exact,
still-unfixed shape live in `scripts/run-tests.cjs`'s `sweepProtectSet` block (the original
#4020 site) and fixed it in the same change by extracting a pure `computeSweepProtectSet`
helper with the fixed-point check, mirroring the shape of the (at-authoring-time separately
in-flight, not yet merged) #4220 fix.
Both rules join the catalog's **zero-escape-hatch** discipline (rule 3 above): neither carries a
bespoke `// allow-*` comment marker, and both are added to `tests/portability-rule-disable-ban.test.cjs`'s
`PROTECTED_RULES` list so an `eslint-disable` naming them is independently banned outside ESLint
too. `no-unbounded-dirname-walk` is registered on **both** `tests/**/*.cjs` and `scripts/**/*.cjs`
(the narrower `scripts/**/*.cjs`-only block, alongside `no-private-binary-resolution`) — the
production surface registration is load-bearing, since the real #4020 bug lived in `scripts/`, not
`tests/`. `require-full-tmpdir-triad` follows the established test-portability convention
(`no-hardcoded-tmp`, `require-userprofile-with-home`) and is registered on `tests/**/*.cjs` only,
matching both real incident sites.
A repo-wide sweep for other instances of either pattern (beyond the incident sites above) found
none: `require-full-tmpdir-triad` and `no-unbounded-dirname-walk` both ran clean against the rest
of the tree once the three live sites were fixed.
**Taxonomy coverage.** This catalog addresses every `DEFECT.WINDOWS-*` class plus
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE` in `CONTEXT.md`, to the extent each is *statically*
detectable. `DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE` has two parts: (a) the CRLF / literal-`\n`

View File

@@ -136,6 +136,8 @@ Run the full engineering directive (rubber-duck → software laws → architectu
| `require-userprofile-with-home` | `WINDOWS-TEST-PORTABILITY` (G6) | tests | 4 |
| `normalize-path-in-content` | `WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT` | `src/**/*.cts` | 5 |
| `require-fs-op-fallback` | `WINDOWS-FS-OPS` | `src/**/*.cts`, `bin/install.js`, `scripts/build-hooks.js` | 6 |
| `require-full-tmpdir-triad` | `WINDOWS-TEST-PORTABILITY` (#4220) | tests | #4244 |
| `no-unbounded-dirname-walk` | `WINDOWS-TEST-PORTABILITY` (#4020/#4220) | tests, `scripts/**/*.cjs` | #4244 |
`DEFECT.WINDOWS-ARGV-OVERFLOW` is deliberately **not** in this catalog: argv length is a runtime
property (the args-array size is not statically knowable), so no AST rule can soundly detect it.

View File

@@ -29,6 +29,8 @@ running outside ESLint, fails the build if you try). Legitimately platform-speci
| `local/require-userprofile-with-home` | A `process.env.HOME = <x>` assignment in a test file with no corresponding `process.env.USERPROFILE` **assignment** — Windows uses `USERPROFILE` as the home directory environment variable, not `HOME`. | `tests/**/*.test.cjs` |
| `local/normalize-path-in-content` | A path-returning fn result (excluding `path.basename`, which returns a separator-less filename) interpolated **directly** into content without `.replace(/\\/g,'/')` normalization — backslash paths leak into generated content on Windows (`RULESET.CONTENT-PATH-NORMALIZATION`). Two content shapes are detected: (a) the template/string contains an `@`-reference marker (`@~/`, `@$`, `@/`), `$HOME`, or `~/`; (b) the quasi immediately following the interpolation starts with `/…\.md` or `/…\.json`. **Indirect data-flow** (path stored in a variable/field then interpolated) is not detected — normalize at source. Fix: `String(resolvedTarget).replace(/\\/g, '/')`. | `src/**/*.cts` |
| `local/require-fs-op-fallback` | An unguarded `fs.rename` / `fs.renameSync` (the atomic-publish primitive) that is NOT inside a `try`/`catch` whose handler references a transient errno (`'EPERM'`/`'EBUSY'`/`'EACCES'`, or a `*RETRY_ERRNOS` set) AND is NOT behind a Windows platform guard — on Windows a concurrent reader / antivirus scanner can transiently hold the target open and throw. A `catch (e) {}` that silently swallows, or a catch that cleans-up-and-rethrows without an errno check, does **not** satisfy the rule. `fs.copyFile` / `fs.unlink` are deliberately **not** flagged (they are the *fallback primitives* named by the defect's own fix-forward, and `unlink` has many intentional best-effort cleanup sites). | `src/**/*.cts`, `bin/install.js`, `scripts/build-hooks.js` |
| `local/require-full-tmpdir-triad` | A `process.env.TMPDIR = …` assignment (direct, or as a property in an object literal passed as the `env:` option to `spawn`/`spawnSync`/`exec`/`execSync`/`execFile`/`execFileSync`/`fork`, or this repo's `runNode(...)` test helper) that is not accompanied by `TEMP` and `TMP` in the same scope — `os.tmpdir()` never reads `TMPDIR` on Windows (only `TEMP`, then `TMP`), so a TMPDIR-only redirect silently no-ops there. | `tests/**/*.test.cjs` |
| `local/no-unbounded-dirname-walk` | A `while`/`do-while` loop that reassigns its condition variable from `dirname(...)` (bare, `path.`, `.posix.`/`.win32.`) with no fixed-point conjunct (`dirname(cur) !== cur`, or `path.parse(cur).root`) in the loop test — `path.dirname()` is a no-op at the platform root, but on win32 that fixed-point value (`'D:\\'`, length 3) fails a POSIX-shaped length or equality check that would have caught a POSIX root (`'/'`, length 1), so the walk spins forever there. | `tests/**/*.test.cjs`, `scripts/**/*.cjs` |
(See ADR-1703's catalog and [epic #1702](https://github.com/open-gsd/gsd-core/issues/1702) for the full phase history.)
@@ -280,6 +282,57 @@ if (process.platform !== 'win32') {
> `RENAME_RETRY_ERRNOS` loop is compliant because the helper's own `renameSync` is recognized; a
> bare `fs.renameSync(...)` call is what gets flagged.
## How-to — fix a `require-full-tmpdir-triad` violation
Per Node's own docs, `os.tmpdir()` on Windows consults `TEMP` then `TMP` — it never reads
`TMPDIR` there. On every other platform `TMPDIR` is checked first. A child-process `env:` override
that redirects only `TMPDIR` therefore does nothing on Windows: the child inherits the parent's
ambient `TEMP`/`TMP` and its `os.tmpdir()` resolves to the wrong directory, silently.
```js
// ❌ flagged — no-op on Windows
const r = runNode(['-e', probe], { env: { ...process.env, TMPDIR: outer } });
// ✅ set all three to the same value
const r = runNode(['-e', probe], {
env: { ...process.env, TMPDIR: outer, TEMP: outer, TMP: outer },
});
```
The same applies to a direct `process.env.TMPDIR = …` assignment — set `process.env.TEMP` and
`process.env.TMP` alongside it (and restore all three in the teardown), mirroring the
`require-userprofile-with-home` HOME/USERPROFILE convention above.
## How-to — fix a `no-unbounded-dirname-walk` violation
`path.dirname()` is a fixed point at the filesystem root on both platforms, but the fixed-point
*value* differs: `path.posix.dirname('/') === '/'` (length 1), while
`path.win32.dirname('C:\\') === 'C:\\'` (length 3). A walk that terminates on a POSIX-shaped
sentinel — a hardcoded length threshold or an equality check against a target that the walk may
never reach — spins forever at 100% CPU on a Windows drive root, since the string simply stops
changing while the sentinel condition never fires.
```js
// ❌ flagged — never terminates on Windows when cur can't reach root
let cur = file;
while (cur && cur !== root && cur.length > 1) {
protectSet.add(cur);
cur = dirname(cur);
}
// ✅ add the fixed-point conjunct — terminates on POSIX, win32 drive roots, and UNC roots alike
let cur = file;
while (cur && cur !== root && dirname(cur) !== cur) {
protectSet.add(cur);
cur = dirname(cur);
}
```
`path.parse(cur).root` is the other recognized portable sentinel: `while (cur !== path.parse(cur).root)`.
Whichever form you use, prefer breaking out of the loop the moment `dirname(cur) === cur` (as
`scripts/run-tests.cjs`'s `computeSweepProtectSet` does) over relying purely on the condition, so
the loop body never re-adds the fixed point.
## How-to — add a new path resolver
When you add a function that returns a filesystem path (e.g. in `src/runtime-homes.cts`), add its