* 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:
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user