* 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>
20 KiB
Cross-platform portability lint rules
GSD must run on Windows as well as macOS/Linux. A family of AST-based ESLint rules (the
local/* plugin) enforces the DEFECT.WINDOWS-* portability classes documented in
CONTEXT.md at write-time (in your editor) and in CI, so a
Windows-only defect is caught before it ships — not after it reaches the windows-latest CI
lane. The architecture and rationale are in ADR-1703;
this page is the practical reference + how-to.
Adding a new rule? See
adding-a-portability-rule.md— the five seams (rule / vocab / platform-guard / disable-ban / ci-scope), the zero-escape-hatch contract, and the step-by-step recipe.
These rules are hard-fail with zero escape hatches: there is no // windows-portability-ok:
comment and no eslint-disable for them (a tests/portability-rule-disable-ban.test.cjs check,
running outside ESLint, fails the build if you try). Legitimately platform-specific code must be
structured so the rule recognizes it (see "Platform guards" below) — not annotated around.
Reference — the rules
| Rule | Flags | Surface |
|---|---|---|
local/no-path-literal-in-assert |
An assert.equal/strictEqual/deepEqual/deepStrictEqual or expect(...).toBe/toEqual/toStrictEqual where one operand is a path-returning function call and the other is a hardcoded /-string literal not normalized to POSIX. |
tests/**/*.test.cjs |
local/no-posix-mode-bit-assert |
An equality assertion comparing a file .mode (e.g. statSync(p).mode & 0o777) to an octal literal — Windows reports 0o666/0o444, never the requested mode. |
tests/**/*.test.cjs |
local/no-unguarded-nonportable-exec |
A file that both sets a chmod exec-bit (chmod/chmodSync with 0oNNN & 0o111 !== 0) and invokes sh/bash with a -c flag (execFileSync/spawnSync/spawn/exec/execSync) without a Windows platform guard — Windows Git Bash ignores the exec bit for extension-less PATH-executed scripts. |
tests/**/*.test.cjs |
local/no-crlf-fragile-split |
A .split('\n') or .split("\n") call on readFileSync content, or a regex literal containing a bare \n used against readFileSync content — Windows git-autocrlf yields \r\n line endings so a literal \n split or regex will mismatch. |
tests/**/*.test.cjs |
local/no-hardcoded-tmp |
A hardcoded /tmp/ string passed as the first argument to an fs.* function or path.join — /tmp does not exist on Windows. Use os.tmpdir() instead. |
tests/**/*.test.cjs |
local/no-bare-npm-exec |
An execFileSync/spawnSync/spawn call with "npm" as the command and no { shell: true } option (or a platform-guarded equivalent) — npm is a .cmd batch wrapper on Windows and is not found without a shell. (execSync/exec already run via a shell, so they are not flagged.) |
tests/**/*.test.cjs |
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 for the full phase history.)
The set of path-returning functions is single-sourced in
eslint-rules/lib/portability-vocab.cjs as
PATH_RETURNING_FNS (Node's path.*/os.homedir/os.tmpdir plus the project resolvers such as
getGlobalConfigDir, resolveAgentDir, computePathPrefix, …). A drift-guard test
(tests/portability-vocab-drift.test.cjs) parses src/runtime-homes.cts and fails CI if a new
path resolver is added but not registered in that list.
How-to — fix a no-path-literal-in-assert violation
Why it fails on Windows: path.join('a','b') returns a/b on POSIX but a\b on Windows, so
assert.equal(path.join('a','b'), '/a/b') passes on your Mac/Linux machine and the docker gate,
then fails only on the windows-latest lane.
Fix: normalize the ACTUAL operand to POSIX before comparing — this is idempotent on POSIX (a no-op when there are no backslashes) and reveals a malformed return rather than masking it:
// ❌ flagged
assert.strictEqual(getGlobalConfigDir('claude'), '/custom/claude');
// ✅ compliant
assert.strictEqual(String(getGlobalConfigDir('claude')).replace(/\\/g, '/'), '/custom/claude');
Do not instead wrap the expected literal in path.join(...) to match the platform
separator — that passes everywhere but masks a wrong backslash-on-POSIX return (both sides wrong
together). Recognized normalizers: .replace(/\\/g,'/'), .replace(/[\\/]/g,'/'),
.replaceAll('\\','/'), .replaceAll(path.sep,'/'), .split(path.sep).join('/'),
toPosixPath(...).
How-to — fix a no-posix-mode-bit-assert violation
Windows does not honor POSIX file modes — fs.statSync(p).mode reads back 0o666 (writable) or
0o444 (readonly), never the 0o644/0o755 you wrote. A mode-bit assertion is therefore a
POSIX-only precondition. Gate it behind a platform check and keep the real behavioral assertion
running on every OS (do not delete it — scope it):
// ❌ flagged
assert.strictEqual(fs.statSync(p).mode & 0o777, 0o644);
// ✅ scope the POSIX-only precondition; keep the behavioral assertion cross-platform
if (process.platform !== 'win32') {
assert.strictEqual(fs.statSync(p).mode & 0o777, 0o644);
}
assert.match(hookCommand, /^node /); // behavioral assertion — runs everywhere
Prefer asserting the behavior (command shape, runnability) over the raw mode bit where you can.
How-to — fix a no-unguarded-nonportable-exec violation
Why it fails on Windows: Windows Git Bash (msys2) does not honour Node's chmod exec bit for
extension-less scripts that are invoked by searching PATH. A test that makes a fixture executable
with chmodSync(p, 0o755) and then runs it with execFileSync('bash', ['-c', '...']) passes on
macOS/Linux but fails only on the windows-latest CI lane (DEFECT.WINDOWS-TEST-PORTABILITY).
Fix option A: gate the sh/bash -c invocation behind a platform check
// ❌ flagged
fs.chmodSync(fixture, 0o755);
execFileSync('bash', ['-c', './fixture run']);
// ✅ platform-guarded
fs.chmodSync(fixture, 0o755);
if (process.platform !== 'win32') {
execFileSync('bash', ['-c', './fixture run']);
}
Fix option B: invoke the script with an explicit interpreter (no -c flag)
// ✅ passes the script path directly — exec bit not needed
execFileSync('sh', [fixturePath]);
Platform guards (the only "escape" — by structure, not annotation)
If an assertion is genuinely POSIX-only, gate it behind a Windows platform check the rule recognizes — it then won't flag the guarded code. Recognized shapes:
if (process.platform !== 'win32') {
assert.equal(path.join(a, b), '/a/b'); // guarded → not flagged
}
if (process.platform === 'win32') return; // early-return guard
assert.equal(path.join(a, b), '/a/b'); // → not flagged
const isWindows = process.platform === 'win32'; // hoisted boolean (any name, binding-resolved)
if (!isWindows) assert.equal(path.join(a, b), '/a/b'); // → not flagged
The guard is recognized by control-dependence (it must actually dominate the assertion), is
binding-aware (a reassigned or false-initialized variable is not trusted), and handles
os.platform() and node:test skip returns. See
eslint-rules/lib/platform-guard.cjs.
Note: the
node:testtest(name, { skip: isWindows ? … : false }, fn)option object is NOT recognized as a platform guard. To scope a POSIX-only assertion use anif (process.platform !== 'win32')guard (or early-return) inside the callback.
How-to — fix a no-crlf-fragile-split violation
Windows git-autocrlf=true (the default on Windows) rewrites \n to \r\n in checked-out files.
A test that reads a file with readFileSync and then splits on '\n' (or uses a regex with a bare
\n) will silently miscalculate line counts on Windows.
Fix: use /\r?\n/ everywhere you split or match lines in file content:
// ❌ flagged
const lines = fs.readFileSync(p, 'utf8').split('\n');
assert.match(content, /^---\n/m);
assert.match(content, /```bash\n/);
// ✅ CRLF-safe
const lines = fs.readFileSync(p, 'utf8').split(/\r?\n/);
assert.match(content, /^---\r?\n/m);
assert.match(content, /```bash\r?\n/);
The /\r?\n/ form is a no-op on POSIX (matches only \n) and correct on Windows (matches \r\n).
How-to — fix a no-hardcoded-tmp violation
/tmp does not exist on Windows. Use os.tmpdir() to get the platform-appropriate temp directory:
// ❌ flagged
const dir = path.join('/tmp/my-test-dir', 'sub');
env.MY_VAR = '/tmp/custom-dir';
// ✅ portable
const dir = path.join(os.tmpdir(), 'my-test-dir', 'sub');
const customDir = path.join(os.tmpdir(), 'custom-dir');
env.MY_VAR = customDir;
When the same /tmp/... value is used both as a fixture env var and in an assertion, update both
sides consistently so they still match:
// ❌ fragile — assertion tied to /tmp/ literal
const customDir = path.join(os.tmpdir(), 'custom-dir');
env.MY_VAR = customDir;
assert.strictEqual(String(fn()).replace(/\\/g, '/'), '/tmp/custom-dir'); // ← still wrong
// ✅ assertion uses the same derived constant
assert.strictEqual(String(fn()).replace(/\\/g, '/'), customDir.replace(/\\/g, '/'));
How-to — fix a no-bare-npm-exec violation
On Windows, npm is installed as npm.cmd (a CMD batch script). Without { shell: true },
execFileSync('npm', ...) fails because the OS cannot find an executable named npm (no .cmd
extension). Add shell: true or gate the call behind a platform check:
// ❌ flagged
execFileSync('npm', ['ci'], { cwd: dir });
// ✅ shell: true — works on all platforms
execFileSync('npm', ['ci'], { cwd: dir, shell: true });
// ✅ platform-guarded alternative
execFileSync('npm', ['ci'], { cwd: dir, shell: process.platform === 'win32' });
How-to — fix a require-userprofile-with-home violation
Windows uses USERPROFILE as the home directory environment variable, not HOME. Whenever a test
sets process.env.HOME, it must also set process.env.USERPROFILE to the same value (so that
code under test that calls os.homedir() or reads process.env.USERPROFILE gets the isolated
directory on Windows too). Mirror the teardown as well:
// ❌ flagged — Windows code-under-test reads USERPROFILE, not HOME
const origHome = process.env.HOME;
process.env.HOME = isolatedDir;
// …
process.env.HOME = origHome; // restore
// ✅ set and restore both
const origHome = process.env.HOME;
const origUserProfile = process.env.USERPROFILE;
process.env.HOME = isolatedDir;
process.env.USERPROFILE = isolatedDir;
// …
if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome;
if (origUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = origUserProfile;
How-to — fix a require-fs-op-fallback violation
Why it fails on Windows: fs.renameSync(tmp, target) (the atomic-publish primitive) uses Windows
MoveFileEx with MOVEFILE_REPLACE_EXISTING, which throws EPERM/EBUSY/EACCES when an
antivirus scanner, indexer, or concurrent reader transiently holds the target open. On macOS/Linux
rename(2) atomically replaces regardless of open handles, so the bare call passes everywhere
except the windows-latest CI lane (DEFECT.WINDOWS-FS-OPS).
Fix option A (preferred for production): route through retryRenameSync — the shared drop-in
from shell-command-projection.cjs that retries the transient errnos a bounded number of times
before rethrowing. It is idempotent on POSIX (the transient errnos do not occur there):
import { retryRenameSync } from './shell-command-projection.cjs';
// ❌ flagged — EPERM/EBUSY propagates unhandled on Windows
fs.renameSync(tmpPath, target);
// ✅ drop-in — retries transient locks, throws on persistent failure
retryRenameSync(tmpPath, target);
Fix option B: inline the RENAME_RETRY_ERRNOS loop (the convention already used by
capability-ledger, capability-consent, and shell-command-projection's own atomicRenameWithRetry):
const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
for (let attempt = 1; attempt <= 3; attempt++) {
try {
fs.renameSync(tmpPath, target);
break;
} catch (err) {
if (attempt < 3 && RENAME_RETRY_ERRNOS.has(err.code)) { backoff(); continue; }
throw err;
}
}
Fix option C: gate behind a platform check when the rename is genuinely POSIX-only:
// ✅ platform-guarded — not flagged
if (process.platform !== 'win32') {
fs.renameSync(tmpPath, target);
}
copyFile/unlinkare not flagged. Per the defect's own fix-forward, they are the fallback primitives ("catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry"), not separate defect sites. A retry delegated to a helper that itself wrapsrenameSyncin theRENAME_RETRY_ERRNOSloop is compliant because the helper's ownrenameSyncis recognized; a barefs.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.
// ❌ 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.
// ❌ 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
name to PATH_RETURNING_FNS in eslint-rules/lib/portability-vocab.cjs. The drift-guard test
will fail until you do.
Known boundaries
The rule matches by spelling and inspects the direct operand (or a String(<pathcall>) wrapper):
- It assumes
path/osare the standard modules and the resolver names are the project's — a local variable that shadows one of those names in a test file is out of scope. - Deeper wrapping (e.g.
realpathSync(path.join(...)),.toLowerCase()on a path) is not inspected; assert against the path call directly or itsString(...)wrap. - For a genuine explicit-dir pass-through assertion (a resolver that returns its input
verbatim), the
String(...).replace(/\\/g,'/')remedy is a harmless no-op. - The rule catches a path-returning call interpolated directly into
${ }. It does NOT track indirect data-flow — a path stored in a variable or object field, then interpolated (e.g.${globalSkillDir}/SKILL.md→@${entry.ref}). Indirect content-path-leaks rely onRULESET.CONTENT-PATH-NORMALIZATIONdiscipline (normalize at source) and code review. The one known indirect leak (src/init.ctscmdAgentSkillsentry.refbuilding) is fixed by normalizing at the content-emit site:- @${String(entry.ref).replace(/\\/g, '/')}. - Content detection shape (b) fires when the quasi immediately following the interpolation
starts with
/…\.mdor/…\.json. A bare.mdor.jsontoken in the middle of prose (e.g.: see README.md) does NOT qualify — the quasi must start with the forward slash. Config-dir substrings (/.claude,/commands,/skills, etc.) are deliberately NOT content markers — they caused false positives on log/error/diagnostic strings mentioning config dirs.