ADR-1703 Phase 7 / epic #1702 closeout. The mechanical teardown is already complete across phases 1-6 (regex scanner retired P3, ratchet deleted P4, allowlist portability-usage gone, windows-portability-ok comments swept, every DEFECT.WINDOWS-* predicate rewritten per-phase). This phase delivers the two remaining ADR-mandated closeout items: - docs/contributing/adding-a-portability-rule.md: the forward architecture recipe (Diátaxis Explanation) — the five seams (rule / portability-vocab / platform-guard / disable-ban / ci-test-scope), the zero-escape-hatch contract, the shipped-rule catalog, and the step-by-step checklist for adding a new local/* portability rule. - docs/adr/1703: Status Proposed -> Accepted (all seven phases shipped); a Phase 7 as-built note recording the two deviations from the Phase 0 catalog — Phase 6's rename-only scope decision (#1740) and the codex-review precision tightening on require-fs-op-fallback. - docs/contributing/cross-platform-portability-rules.md: cross-link to the new guide from the rule reference. No code, no test, no runtime change. Epic #1702 Phase 6 box checked; Phase 7 box + epic closure to follow at PR merge. Closes #1744 Co-authored-by: review-bot <review-bot@gsd>
17 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 |
(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 — 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.