Files
msd-core/docs/contributing/cross-platform-portability-rules.md
Tom Boucher 9d52043f50 feat(#1733): normalize-path-in-content production AST rule + fix Windows agent-skills content leak (Phase 5) (#1736)
* feat(#1733): normalize-path-in-content production AST rule (Phase 5)

ADR-1703 Phase 5 — the first production-code rule. local/normalize-path-in-content
(src/**/*.cts, @typescript-eslint/parser): flags a path-returning fn result
(path.basename excluded — returns a separator-less filename) interpolated into an
@-reference / config-dir markdown body without .replace(/\\/g,'/') normalization,
per RULESET.CONTENT-PATH-NORMALIZATION / DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.

Build-and-assess found the canonical defect site (computePathPrefix) already
compliant and only 1 src/ hit — a false positive (path.basename in a status
message) — eliminated by narrowing (exclude basename; require a real @-ref/
config-dir marker, not bare .md). 0 src/ violations: clean forward-prevention.

The out-of-band disable-ban now scans src/**/*.cts too (typescript-estree) so the
production rule also cannot be eslint-disabled. Registered (error) + PROTECTED_RULES;
CONTEXT.md predicates + how-to doc updated.

- RuleTester suite (26 cases)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#1733): add changeset for Windows agent-skills path-leak fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: harden mutation-matrix.cjs stdin read against EAGAIN on non-blocking pipe

scripts/mutation-matrix.cjs read piped stdin via readFileSync(process.stdin.fd).
On macOS libuv marks the stdin pipe fd non-blocking, so a synchronous read can
throw EAGAIN before the writer fills the pipe — intermittently, under heavy CI
shard load — aborting the script (status 2) and flaking mutation-matrix-ratchet.
Replace with readStdinSync(): an fs.readSync loop that retries on EAGAIN (1ms
synchronous Atomics.wait yield), stops on 0-byte/EOF, and rethrows other errors.
Deterministic regression test injects EAGAIN via an fs.readSync monkeypatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: re-run golden-install-parity on src/lib + installer changes (close drift guard)

golden-install-parity hashes every installed bin/lib/*.cjs per runtime, so it
must re-run whenever the built lib could change. ci-test-scope selected it for
neither src/** nor installer changes, so a source-only edit (e.g. #1691's
milestone.cts/roadmap.cts) recompiled bin/lib and silently drifted the golden
fixtures past the scoped lane. Add golden-install-parity.test.cjs to both the
'TS runtime sources' and 'installer and package layout' selection rules, with
behavioral regression tests for each.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: review-bot <review-bot@gsd>
2026-06-25 21:49:22 -04:00

14 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 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

(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.
  • 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 on RULESET.CONTENT-PATH-NORMALIZATION discipline (normalize at source) and code review. The one known indirect leak (src/init.cts cmdAgentSkills entry.ref building) 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 /…\.md or /…\.json. A bare .md or .json token 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.