6.1 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.
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 |
(More rules land per the epic — see ADR-1703's catalog and epic #1702.)
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.
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 — 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.