Files
msd-core/docs/contributing/adding-a-portability-rule.md
Tom Boucher 1fe85cd43e chore(#4244): ESLint rules for the #4220 Windows dirname-walk / TMPDIR-triad bug class (#4246)
* 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>
2026-09-03 14:14:09 -04:00

153 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Adding a cross-platform portability lint rule
GSD must run correctly on Windows as well as macOS/Linux. The `DEFECT.WINDOWS-*`
taxonomy in [`CONTEXT.md`](../../CONTEXT.md) names the recurring failure shapes; a family of
AST-based ESLint rules (the `local/*` plugin) enforces them **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.
This page is the **forward recipe**: how to add a new rule when you identify a portability
defect class that isn't yet mechanically enforced. The architecture and rationale live in
[ADR-1703](../adr/1703-portability-enforcement-architecture.md); the per-rule reference and
fix how-tos live in [`cross-platform-portability-rules.md`](./cross-platform-portability-rules.md).
> **Diátaxis note:** this is an *Explanation* — it describes the architecture and the reasoning
> behind the seams, so the recipe at the end makes sense. For "how do I fix a violation I got",
> see the per-rule how-tos in the reference page.
## Why AST rules, not regex
The original enforcement was a regex scanner with a hand-rolled balanced-paren parser, a frozen
`KNOWN_OFFENDERS` ratchet, and a bespoke `// windows-portability-ok:` comment opt-out. Adversarial
review of an attempt to *extend* the regex found it silently could not match `deepStrictEqual`,
had loose normalizer recognition, and hand-rolled paren-splitting fragility (Kernighan's Law /
Greenspun's Tenth — parsing a language with regex). The rip-and-replace decision: **one mechanism,
AST-based ESLint rules** using the parsers already in the stack, hard-fail with zero escape
hatches, no ratchet/grandfathering. Full rationale: ADR-1703 "Alternatives considered".
## The five seams (and where each lives)
Every portability rule composes the same five seams. Adding a rule means touching each one.
### 1. The rule — `eslint-rules/<rule-name>.cjs`
One file per rule, exporting `{ meta, create }`. Matches real syntax nodes (`CallExpression`,
`MemberExpression`, `Literal`, `TemplateLiteral`, `BinaryExpression`, `TryStatement`, …), **not**
text. Each rule runs in-editor *and* in CI via the existing `eslint .` (invoked by `lint:ci`).
The two shapes that recur:
- **Test-side rules** (surface `tests/**/*.test.cjs`) — flag a non-portable *assertion* or *test
fixture* shape (path-literal-in-assert, posix-mode-bit-assert, unguarded exec, CRLF split,
hardcoded `/tmp`, bare npm, HOME-without-USERPROFILE).
- **Production rules** (surface `src/**/*.cts`, `bin/install.js`, `scripts/build-hooks.js`) — flag
a non-portable *production* shape (path-leak-in-content, unguarded fs-rename).
### 2. The shared vocabulary — `eslint-rules/lib/portability-vocab.cjs`
The single source of truth for path-related portability: `PATH_RETURNING_FNS` (Node builtins +
project resolvers), the POSIX-normalizer recognizers (`.replace(/\\/g,'/')`, `toPosixPath`, …),
and string-unwrap helpers. A new path resolver added to `src/runtime-homes.cts` MUST be registered
here — the drift-guard test (`tests/portability-vocab-drift.test.cjs`) parses that source and
**fails CI if a path-returning export is missing** from `PATH_RETURNING_FNS`.
### 3. The platform guard — `eslint-rules/lib/platform-guard.cjs`
The precision backbone. `isWindowsExcludedNode(node, sourceCode)` answers "is this node
control-dependent on a Windows platform condition?" via a **dominator check, not a textual mention**:
`if (process.platform !== 'win32') { … }`, early-return guards (`if (process.platform === 'win32') return;`),
`os.platform()`, and hoisted binding-aware booleans (`const isWindows = …` consumed by
`if (!isWindows)`, with reassignment detection). This is what makes **zero escape hatches** viable:
legitimately POSIX-only code is *structured* behind a recognized guard, never annotated around
(Postel's Law mitigation). If a legitimate shape isn't recognized, **teach the helper** — never add
an opt-out.
### 4. The disable ban — `tests/portability-rule-disable-ban.test.cjs`
Because there is no opt-out, an `eslint-disable` of a portability rule would silently bypass it.
This test runs **outside ESLint** (so it cannot itself be eslint-disabled) and fails the build on
any `eslint-disable[-next-line|-line]` that names a protected portability rule, or any blanket
disable. **Every new rule MUST be appended to `PROTECTED_RULES`** here, and if the rule covers a
new surface (e.g. `bin/install.js`), that surface MUST be added to `collectTestFiles()`.
### 5. CI test selection — `scripts/ci-test-scope.cjs`
The `portability lint rules (ADR-1703)` rule selects the rule suites + disable-ban when
`eslint-rules/`, `eslint.config.mjs`, or a covered production surface changes. Add new test files
to its `tests:` list.
## The zero-escape-hatch contract
Two things must hold, and they are the epic's primary risk:
1. **Rules must be precise.** Every rule recognizes legitimate platform-gating via
`platform-guard.cjs` and the canonical compliant shapes, so correctly-written platform-specific
code is never flagged. A false positive is a rule bug, fixed in the rule — never by adding an
opt-out.
2. **Recognition must mean the cure, not just the symptom.** When a rule's compliance shape is "the
catch handles the transient errno", the handler must actually *retry/fallback* (a loop `continue`
backedge or a `return <call>` delegation), not merely *reference* the errno and rethrow. The
`require-fs-op-fallback` rule encodes this (codex-review-tightened): the defect's cure is retry,
not just recognition.
An unrecognized legitimate shape is fixed by teaching the helper/rule, never by annotation. This is
the discipline that keeps the rules honest as the codebase grows.
## Recipe — add a new `local/*` portability rule
Run the full engineering directive (rubber-duck → software laws → architecture → qa-test-architect
→ strict TDD via `RuleTester` → adversarial review → Diátaxis → rebase+PR). Concretely:
1. **Classify the defect.** Confirm it's a real `DEFECT.WINDOWS-*` shape (or a new class worth a
predicate in `CONTEXT.md`). Decide the *sound* statically-detectable scope — narrow or document
rather than ship FP-prone (Phase 5/6 each narrowed scope and documented the boundary).
2. **Write the rule** — `eslint-rules/<rule-name>.cjs` (`{ meta, create }`, `type: 'problem'`,
message cites the `DEFECT.*` predicate). Reuse `portability-vocab.cjs` / `platform-guard.cjs`.
3. **TDD via `RuleTester`** — `tests/<rule-name>.rule.test.cjs`. Cover: the violation shape(s),
every recognized compliant shape (platform guard, normalizer, retry signal, …), and the
anti-patterns that must NOT satisfy compliance (silent-swallow catch, rethrow-only, unrelated
errno). Use both espree (`.cjs`) and `@typescript-eslint/parser` (`.cts`) where the rule spans
both. Tests are written FIRST and must fail before the rule exists, then pass.
4. **Register + scope** — in `eslint.config.mjs`: add the rule to the `local` plugin's `rules` map
and enable at `'error'` in the matching file-glob block. If the rule covers a new surface (e.g.
`bin/install.js`), add a config block for it — apply ONLY the portability rules to generated
code, not the full recommended set.
5. **Disable ban** — append the rule name to `PROTECTED_RULES` in
`tests/portability-rule-disable-ban.test.cjs`; add any new surface to `collectTestFiles()`.
6. **CI selection** — add the new test file to the `portability lint rules (ADR-1703)` rule in
`scripts/ci-test-scope.cjs`.
7. **Fix every violation** — no ratchet, no grandfathering. Every existing + grandfathered offender
is fixed in the same phase (route through a shared helper, add a guard, or normalize).
8. **Docs** — add the rule to the reference table + a fix how-to in
`cross-platform-portability-rules.md`; rewrite the `DEFECT.*` predicate's `detect=`/`fix-forward=`
in `CONTEXT.md` to point at the rule; record known boundaries honestly.
9. **Verify** — `npm run lint:ci` green; the touched modules' tests green; the `windows-latest` CI
lane is the only true Windows signal.
## Catalog (shipped)
| Rule | DEFECT | Surface | Phase |
|---|---|---|---|
| `no-path-literal-in-assert` | `WINDOWS-PATH-LITERAL-IN-ASSERT` | tests | 1 |
| `no-posix-mode-bit-assert` | `WINDOWS-POSIX-MODE-BIT-ASSERT` | tests | 2 |
| `no-unguarded-nonportable-exec` | `WINDOWS-TEST-PORTABILITY` (chmod+`sh -c`) | tests | 3 |
| `no-crlf-fragile-split` | `WINDOWS-TEST-PORTABILITY` (G1–G3) | tests | 4 |
| `no-hardcoded-tmp` | `WINDOWS-TEST-PORTABILITY` (G4) | tests | 4 |
| `no-bare-npm-exec` | `WINDOWS-TEST-PORTABILITY` (G5) | tests | 4 |
| `require-userprofile-with-home` | `WINDOWS-TEST-PORTABILITY` (G6) | tests | 4 |
| `normalize-path-in-content` | `WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT` | `src/**/*.cts` | 5 |
| `require-fs-op-fallback` | `WINDOWS-FS-OPS` | `src/**/*.cts`, `bin/install.js`, `scripts/build-hooks.js` | 6 |
| `require-full-tmpdir-triad` | `WINDOWS-TEST-PORTABILITY` (#4220) | tests | #4244 |
| `no-unbounded-dirname-walk` | `WINDOWS-TEST-PORTABILITY` (#4020/#4220) | tests, `scripts/**/*.cjs` | #4244 |
`DEFECT.WINDOWS-ARGV-OVERFLOW` is deliberately **not** in this catalog: argv length is a runtime
property (the args-array size is not statically knowable), so no AST rule can soundly detect it.
It is addressed at the source (`run-tests.cjs` chunking under `RUN_TESTS_MAX_CMDLINE_CHARS`).
## Teardown (complete)
The legacy machinery this architecture replaced is fully retired: the regex scanner
`scripts/lint-windows-test-portability.cjs` (Phase 3), the `tests/windows-test-parity-guard.test.cjs`
ratchet (Phase 4), `allowlist-ratchet.cjs` usage for portability classes (the module remains for
unrelated size-budget lints), and the `// windows-portability-ok:` comment convention (swept — zero
remain). Every `DEFECT.WINDOWS-*` predicate in `CONTEXT.md` now points at its enforcing rule.