Files
msd-core/docs/adr/1703-portability-enforcement-architecture.md
Tom Boucher 4525bc6f4d fix(#1749): close epic #1702 audit gaps — drift-guard bin/install.js, ci-test-scope wiring, ADR divergence (#1751)
Post-merge coverage-audit follow-ups to epic #1702 (found by an independent
gpt-5.5/high audit + adr-phase-coverage cross-reference). None are CRITICAL —
the 9-rule enforcement shipped and works; these close completeness/integrity
gaps between ADR-1703's promises and the as-built reality.

1. drift-guard bin/install.js scope (ADR-1703 L114-119): the drift guard
   covered src/runtime-homes.cts only; the ADR named bin/install.js too. Phase
   6's glob expansion made bin/install.js a covered surface. Extended
   tests/portability-vocab-drift.test.cjs with TWO sound checks: (a) any
   bin/install.js top-level function that directly returns path.*() must be in
   PATH_RETURNING_FNS (tight, 0 FP — the body-contains heuristic is unsound
   here, ~33 FPs); (b) a curated two-way existence lock on the installer path
   helpers (catches a rename making a vocab entry stale; keeps the curation in
   sync with PATH_RETURNING_FNS). The residual new-resolver boundary (temp-var
   shape) is documented.

2. ci-test-scope wiring (the Phase 6 portability selection rule was
   ineffective): eslint-rules/ was not in the product-code prefix list, so an
   eslint-rules-only change set code_changed=false and CLEARED the matched
   tests (reproduced: targeted_tests=[]). Added eslint-rules/ to the prefix
   list and the P1-P4 RuleTester suites to the selection rule (it previously
   listed only P5/P6). Verified: code_changed=true, 11 tests selected.

3. ADR-1703 acceptance note amended to record the two further as-built
   divergences: the disable-ban shipped as an out-of-band test (not the
   specified local/no-portability-disable meta-rule — the test runs outside
   ESLint so it cannot be self-disabled, at least as strong); and the
   drift-guard bin/install.js scope resolution above.

Epic #1702 all eight phase boxes now checked. No runtime change; no-changelog
(contributor tooling + docs).

Closes #1749

Co-authored-by: review-bot <review-bot@gsd>
2026-06-26 08:12:40 -04:00

17 KiB
Raw Blame History

ADR-1703: Cross-platform portability enforcement as AST ESLint rules

  • Status: Accepted
  • Date: 2026-06-25 (Phase 0); Accepted 2026-06-26 (Phase 7 closeout — all phases shipped)
  • Issue: #1703 — Phase 0 of epic #1702
  • Supersedes: the regex-based scripts/lint-windows-test-portability.cjs, the tests/windows-test-parity-guard.test.cjs named-set ratchet (G1–G6), the // windows-portability-ok: comment convention, and scripts/lib/allowlist-ratchet.cjs usage for portability classes.

Context

GSD must run correctly when installed and run on Windows (backslash paths, C:\, cmd/PowerShell, no /bin/sh, DOS file modes, \r\n), not just macOS/Linux. CONTEXT.md documents a DEFECT.WINDOWS-* taxonomy of failure shapes that recur and ship to the windows-latest CI lane undetected because the local gsd-test gate is Mac/Linux only.

Enforcement accreted as three incompatible, hand-rolled mechanisms:

  1. scripts/lint-windows-test-portability.cjs — today a narrow regex tripwire for the chmod exec-bit + sh/bash -c shape, with a windows-portability-ok opt-out matched against the whole source. This epic was seeded (#1694) by an attempt to extend this script to the path-literal-in-assert shape; adversarial review of that extension found a regex that silently could not match deepStrictEqual, loose normalizer recognition (false negatives), and hand-rolled balanced-paren-splitting fragility — so the extension was abandoned in favour of this redesign. That abortive attempt is the concrete demonstration that growing the regex path is the wrong direction (Kernighan's Law, Greenspun's Tenth Rule); CONTEXT.md still records the path-literal lint as "enhancement TBD".
  2. tests/windows-test-parity-guard.test.cjs — a ratchet: a frozen KNOWN_OFFENDERS allowlist (G1–G6) that grandfathers existing violations and only blocks new ones. It institutionalizes the defects instead of removing them.
  3. // windows-portability-ok: — a bespoke comment opt-out matched by a whole-source regex, coarse enough that a single occurrence anywhere in a file can disable that file's check.

This is three parsers, two escape conventions, and a permanent grandfather list — to do a job that a linter does natively.

Decision

Replace all three with a single coherent mechanism: AST-based ESLint rules in the existing local/* plugin (eslint-rules/, registered in eslint.config.mjs; ESLint v9 flat config, RuleTester available from require('eslint')). They use the parsers already in the stack: Espree (ESLint's default, sourceType: 'commonjs') for the test-file .cjs rules, and @typescript-eslint/parser (already configured for src/**/*.cts) for the two production .cts rules. Specifically:

  1. AST, not regex. Each portability check is an ESLint rule that matches real syntax nodes (CallExpression, MemberExpression, Literal, TemplateLiteral), not text. Rules run in-editor and in CI via the existing eslint . (invoked by lint:ci through npm run lint) — strictly more coverage than the CI-only node scripts/lint-windows-test-portability.cjs they replace.
  2. Hard-fail, no ratchet, no grandfathering. There is no KNOWN_OFFENDERS allowlist. Every existing and currently-grandfathered violation is fixed, not registered.
  3. Zero escape hatches. No per-line eslint-disable is permitted for portability rules (enforced — see "Strictness" below). Legitimately platform-specific code must be structured so the rule recognizes it (e.g. guarded by process.platform !== 'win32'), not annotated around.
  4. Single source of truth. Shared vocabulary (the PATH_RETURNING_FNS set, mode-bit octals, non-portable exec names) lives in one module eslint-rules/lib/portability-vocab.cjs, consumed by every rule and guarded against drift by an AST completeness check.
  5. Tested with RuleTester. Each rule ships an ESLint RuleTester suite of valid/ invalid cases. Because RuleTester feeds fixtures to the rule directly (it does not scan the test file), the self-flagging problem that forced the whole-file opt-out simply does not exist — the opt-out hack is deleted, not reimplemented.

Rule catalog (maps 1:1 to DEFECT.WINDOWS-*)

Rule (local/…) DEFECT (greppable in CONTEXT.md) Surface
no-path-literal-in-assert DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT tests
no-posix-mode-bit-assert DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT tests
no-unguarded-nonportable-exec DEFECT.WINDOWS-TEST-PORTABILITY (chmod+sh -c) + DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE tests
no-crlf-fragile-split DEFECT.WINDOWS-TEST-PORTABILITY (G1/G2/G3) + DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE tests
no-hardcoded-tmp DEFECT.WINDOWS-TEST-PORTABILITY (G4) tests
no-bare-npm-exec DEFECT.WINDOWS-TEST-PORTABILITY (G5) tests
require-userprofile-with-home DEFECT.WINDOWS-TEST-PORTABILITY (G6) tests
normalize-path-in-content DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT (RULESET.CONTENT-PATH-NORMALIZATION) src/**/*.cts
require-fs-op-fallback DEFECT.WINDOWS-FS-OPS src/**/*.cts, build/install

Taxonomy coverage. This catalog addresses every DEFECT.WINDOWS-* class plus DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE in CONTEXT.md, to the extent each is statically detectable. DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE has two parts: (a) the CRLF / literal-\n fence-match shape — covered by no-crlf-fragile-split; and (b) feeding a Windows os.tmpdir() path into a Git Bash glob / bash -c — covered jointly by no-hardcoded-tmp (steer tmp usage) and no-unguarded-nonportable-exec (require a platform guard on bash -c). The residual runtime Git-Bash path-translation behavior is not fully statically decidable; the rules catch the source shapes that produce it, not the runtime outcome. DEFECT.WINDOWS-ARGV-OVERFLOW is deliberately not in this catalog: it is a runtime argv-length property (the args-array size is not statically knowable — e.g. execFileSync('node', [...N runtime paths])), so no AST rule can soundly detect it. Phase 3 evaluated a no-oversized-test-argv heuristic and dropped it as unsound (it could only catch a contrived literal .repeat(N) command string, never the canonical array overflow). The class is addressed at the source: the production run-tests.cjs argv chunking under RUN_TESTS_MAX_CMDLINE_CHARS, with its anchor tests/run-tests-harness.test.cjs.

Architecture

  • eslint-rules/<rule>.cjs — one file per rule, matching the existing local/* rule style. Each exports { meta, create }.
  • eslint-rules/lib/portability-vocab.cjs — the single source of truth: PATH_RETURNING_FNS, mode-bit octal predicates, non-portable command names, normalizer-call recognizers.
  • eslint-rules/lib/platform-guard.cjs — shared AST helper answering "is this node control-dependent on a Windows platform condition?" (a dominator check, not a textual mention). It MUST recognize the guard shapes that actually occur in the suite: process.platform !== 'win32' / === 'win32' (negated), os.platform(), a hoisted const isWindows = … consumed by a later if (!isWindows), early-return guards, nested if blocks, and node:test skips (t.skip(), the { skip } option / skip objects). The current regex lint is unsound here — it treats a bare const isWindows = … as "guarded" without requiring the dangerous call to be inside the branch; the AST helper fixes that by checking control dependence. This is the precision backbone that makes zero-escape-hatch viable (Postel's Law mitigation), and its correctness is the epic's primary risk: with no opt-out, an unrecognized legitimate shape is a CI-blocking false positive. Mitigation — platform-guard is RuleTester-tested against guard shapes harvested from the existing suite, and an unrecognized legitimate shape is fixed by teaching the helper, never by adding an opt-out.
  • Drift guard — a plain unit test (not RuleTester, which only feeds code strings to a rule and cannot read files or enumerate exports) parses src/runtime-homes.cts (and the relevant bin/install.js exports) with @typescript-eslint/parser, walks the AST to collect exported functions that return a filesystem path, and asserts each is present in portability-vocab's PATH_RETURNING_FNS (or an explicit, reason-bearing ignore set). A new resolver that isn't registered fails CI.
  • Wiring — rules register in eslint.config.mjs's local plugin and are set to error. No new lint:ci step; they ride the existing eslint . (which lint:ci runs via npm run lint). The production rules additionally require expanding the eslint.config.mjs file globs to cover bin/install.js and the build/install scripts — today the globs are src/**/*.cts, gsd-core/bin/**/*.cjs, scripts/**/*.cjs, and tests/**/*.test.cjs, so the top-level bin/install.js named by DEFECT.WINDOWS-FS-OPS is not yet linted; the glob expansion lands in the phase that ships require-fs-op-fallback.

Strictness — enforcing zero escape hatches (Postel's Law)

Because there is no opt-out, two things must hold:

  1. Rules must be precise. Every rule recognizes legitimate platform-gating via platform-guard.cjs and the canonical normalizer forms, so correctly-written platform-specific code is never flagged. A false positive is a rule bug, fixed in the rule.
  2. The disable directive is itself banned for these rules. Note reportUnusedDisableDirectives is not sufficient — it only flags directives that suppress nothing; a developer could write // eslint-disable-next-line local/no-path-literal-in-assert on a genuinely-violating line and the directive would count as "used" and pass. The ban is enforced by a dedicated guard: a small local/no-portability-disable meta-rule (matching Program comments) that errors on any eslint-disable[-next-line|-line] directive referencing a local/<portability-rule>. This is precise (only the portability rules are protected; every other rule keeps its normal inline-disable affordance), self-contained (no new dependency), and is itself unit-tested. linterOptions.noInlineConfig: true was rejected as the mechanism because it would ban all inline disables repo-wide, not just the portability rules.

Applied software laws (engineering directive, Step 2.2)

  • Kernighan's Law / Greenspun's Tenth — motivate the whole change: stop parsing a language with regex; use the real parser.
  • Choose Boring Technology — ESLint + typescript-eslint already present; no new tech.
  • Gall's Law — the migration is incremental: each phase adds one rule, fixes its violations, and removes only that class's hack. The old mechanisms keep running until their replacement lands. Full teardown is the last phase, not the first.
  • Postel's Law — zero escape hatches raises the precision bar; platform-guard.cjs is the required mitigation so the strict rules never reject legitimate code.
  • Hyrum's Law — removing // windows-portability-ok: breaks existing uses; every current occurrence is migrated (code restructured or the underlying violation fixed) in the phase that retires it. The vocab + rule semantics are documented here as the new contract.

Consequences

Positive: one mechanism; in-editor feedback; debuggable, unit-tested rules; no grandfather list; no bespoke comment parser; a documented, extensible architecture.

Cost / risk: fixing every grandfathered violation across the suite is a large, real diff (~15+ offender files for G1–G6 alone, plus the path-literal/mode-bit sets). Mitigated by phasing (one rule at a time, each independently reviewed and shipped) and by the rules being error from the moment they land so no new debt accrues.

Migration is phased (Gall's Law):

  • Phase 0 ADR (this) — the design record.
  • Phase 1–3 no-path-literal-in-assert, no-posix-mode-bit-assert, no-unguarded-nonportable-exec, each landing with portability-vocab.cjs / platform-guard.cjs / the RuleTester harness as they are first needed.
  • Phase 4 the G1–G6 rules + fix all grandfathered offenders + delete the ratchet test.
  • Phase 5–6 production normalize-path-in-content, require-fs-op-fallback.
  • Phase 7 teardown: delete the windows-test-parity-guard ratchet + allowlist-ratchet usage for these classes + sweep any residual // windows-portability-ok: comments; finalize the CONTEXT.md DEFECT.WINDOWS-* predicate rewrite; the forward architecture guide ("how to add a portability rule"). (The regex script scripts/lint-windows-test-portability.cjs was retired earlier — in Phase 3 — as its no-unguarded-nonportable-exec replacement landed.)

Each implementation phase runs the full engineering directive (rubber-duck → laws → architecture → qa-test-architect → strict TDD via RuleTester → codex adversarial → Diátaxis → rebase+PR) and is its own approved child issue + PR under epic #1702.

Phase 7 — as-built / acceptance (2026-06-26)

All seven phases shipped; the architecture is exercised in production and accepted. Two as-built deviations from the Phase 0 catalog, both within this ADR's precision discipline:

  • Phase 6 scope — require-fs-op-fallback narrowed to rename. The catalog row named DEFECT.WINDOWS-FS-OPS for "src/**/*.cts, build/install". The defect's own .fix-forward defines the cure as "catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry" — so copyFile/unlink are the fallback primitives, not separate defect sites, and flagging them would flag the cure (unlink also has ~30 intentional best-effort cleanup sites that would be a FP minefield). v1 recognition is therefore fs.rename/fs.renameSync only, with the RENAME_RETRY_ERRNOS retry loop as the recognized compliant shape; copyFile/unlink transient-lock sub-classes are documented for a possible follow-up. The ADR-mandated glob expansion to bin/install.js + scripts/build-hooks.js (L124-126) landed as specified. Documented on #1740.

  • Phase 6 precision tightening (codex review). The rule's compliance shape was tightened after an adversarial gpt-5.5 review: a catch must BOTH reference a transient errno AND carry a retry signal (a loop continue backedge or a return <call> delegation — NOT a bare rethrow), and only the nearest catching try/catch counts (an outer errno-catch is unreachable once an inner catch intercepts). This enforces the defect's "never silently swallow" + cure-is-retry clauses honestly. See eslint-rules/require-fs-op-fallback.cjs.

The forward "how to add a portability rule" recipe delivered by this phase lives at docs/contributing/adding-a-portability-rule.md.

Two further as-built deviations from the Phase 0 text, reconciled in the post-merge coverage audit (#1749):

  • Disable-ban mechanism — meta-rule → out-of-band test. §"Strictness" specified a local/no-portability-disable ESLint meta-rule to ban inline disables of portability rules. What shipped is tests/portability-rule-disable-ban.test.cjs — a node:test that scans files for disable directives outside ESLint, so it cannot itself be eslint-disabled (an advantage over an in-process meta-rule, which the ADR noted as the motivating risk). The substitution is at least as strong; recorded here so the ADR's written mechanism matches the as-built one.

  • Drift-guard bin/install.js scope. §"Architecture" said the drift guard parses src/runtime-homes.cts and the relevant bin/install.js exports. The shipped tests/portability-vocab-drift.test.cjs originally covered only runtime-homes.cts; the audit extended it to bin/install.js with a SOUND shape only (a top-level function that directly return path.*(...) must be registered; plus a curated two-way existence lock on the installer path helpers). The looser body-contains heuristic used for runtime-homes.cts is unsound for the generated 12k-line installer (~33 false positives), so a new installer resolver that builds a path via a temp variable relies on review — documented as a boundary in the test. The active resolver module (runtime-homes.cts) remains fully drift-guarded by the looser heuristic.

Alternatives considered

  1. Keep extending the regex lint. Rejected — the adversarial review proved it is structurally fragile; every extension adds parser surface and bugs.
  2. Keep the ratchet, just add rules. Rejected — grandfathering is the thing being removed; the maintainer's directive is rip-and-replace, not legacy preservation.
  3. Keep // windows-portability-ok: as an escape hatch. Rejected — zero escape hatches chosen; precision via platform-guard.cjs replaces the need for an opt-out.
  4. A standalone custom AST tool (not ESLint). Rejected — Greenspun/Choose-Boring: ESLint is the boring, in-stack, in-editor linter; building a parallel tool repeats the original mistake.