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