Files
msd-core/docs/contributing/cross-platform-portability-rules.md

12 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
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/exec/execSync 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 will not be found without a shell. tests/**/*.test.cjs
local/require-userprofile-with-home A process.env.HOME = <x> assignment in a test file with no corresponding process.env.USERPROFILE reference anywhere in the file — Windows uses USERPROFILE as the home directory environment variable, not HOME. tests/**/*.test.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:test test(name, { skip: isWindows ? … : false }, fn) option object is NOT recognized as a platform guard. To scope a POSIX-only assertion use an if (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 — 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/os are 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 its String(...) 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.