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>
17 KiB
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, thetests/windows-test-parity-guard.test.cjsnamed-set ratchet (G1–G6), the// windows-portability-ok:comment convention, andscripts/lib/allowlist-ratchet.cjsusage 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:
scripts/lint-windows-test-portability.cjs— today a narrow regex tripwire for the chmod exec-bit +sh/bash -cshape, with awindows-portability-okopt-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 matchdeepStrictEqual, 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.mdstill records the path-literal lint as "enhancement TBD".tests/windows-test-parity-guard.test.cjs— a ratchet: a frozenKNOWN_OFFENDERSallowlist (G1–G6) that grandfathers existing violations and only blocks new ones. It institutionalizes the defects instead of removing them.// 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:
- 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 existingeslint .(invoked bylint:cithroughnpm run lint) — strictly more coverage than the CI-onlynode scripts/lint-windows-test-portability.cjsthey replace. - Hard-fail, no ratchet, no grandfathering. There is no
KNOWN_OFFENDERSallowlist. Every existing and currently-grandfathered violation is fixed, not registered. - Zero escape hatches. No per-line
eslint-disableis permitted for portability rules (enforced — see "Strictness" below). Legitimately platform-specific code must be structured so the rule recognizes it (e.g. guarded byprocess.platform !== 'win32'), not annotated around. - Single source of truth. Shared vocabulary (the
PATH_RETURNING_FNSset, mode-bit octals, non-portable exec names) lives in one moduleeslint-rules/lib/portability-vocab.cjs, consumed by every rule and guarded against drift by an AST completeness check. - Tested with
RuleTester. Each rule ships an ESLintRuleTestersuite ofvalid/invalidcases. BecauseRuleTesterfeeds 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 existinglocal/*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 hoistedconst isWindows = …consumed by a laterif (!isWindows), early-return guards, nestedifblocks, andnode:testskips (t.skip(), the{ skip }option / skip objects). The current regex lint is unsound here — it treats a bareconst 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-guardisRuleTester-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) parsessrc/runtime-homes.cts(and the relevantbin/install.jsexports) with@typescript-eslint/parser, walks the AST to collect exported functions that return a filesystem path, and asserts each is present inportability-vocab'sPATH_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'slocalplugin and are set toerror. No newlint:cistep; they ride the existingeslint .(whichlint:ciruns vianpm run lint). The production rules additionally require expanding theeslint.config.mjsfile globs to coverbin/install.jsand the build/install scripts — today the globs aresrc/**/*.cts,gsd-core/bin/**/*.cjs,scripts/**/*.cjs, andtests/**/*.test.cjs, so the top-levelbin/install.jsnamed byDEFECT.WINDOWS-FS-OPSis not yet linted; the glob expansion lands in the phase that shipsrequire-fs-op-fallback.
Strictness — enforcing zero escape hatches (Postel's Law)
Because there is no opt-out, two things must hold:
- Rules must be precise. Every rule recognizes legitimate platform-gating via
platform-guard.cjsand the canonical normalizer forms, so correctly-written platform-specific code is never flagged. A false positive is a rule bug, fixed in the rule. - The disable directive is itself banned for these rules. Note
reportUnusedDisableDirectivesis not sufficient — it only flags directives that suppress nothing; a developer could write// eslint-disable-next-line local/no-path-literal-in-asserton a genuinely-violating line and the directive would count as "used" and pass. The ban is enforced by a dedicated guard: a smalllocal/no-portability-disablemeta-rule (matchingProgramcomments) that errors on anyeslint-disable[-next-line|-line]directive referencing alocal/<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: truewas 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-eslintalready 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.cjsis 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 withportability-vocab.cjs/platform-guard.cjs/ theRuleTesterharness 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-guardratchet +allowlist-ratchetusage for these classes + sweep any residual// windows-portability-ok:comments; finalize theCONTEXT.mdDEFECT.WINDOWS-*predicate rewrite; the forward architecture guide ("how to add a portability rule"). (The regex scriptscripts/lint-windows-test-portability.cjswas retired earlier — in Phase 3 — as itsno-unguarded-nonportable-execreplacement 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-fallbacknarrowed to rename. The catalog row namedDEFECT.WINDOWS-FS-OPSfor "src/**/*.cts, build/install". The defect's own.fix-forwarddefines the cure as "catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry" — socopyFile/unlinkare the fallback primitives, not separate defect sites, and flagging them would flag the cure (unlinkalso has ~30 intentional best-effort cleanup sites that would be a FP minefield). v1 recognition is thereforefs.rename/fs.renameSynconly, with theRENAME_RETRY_ERRNOSretry loop as the recognized compliant shape;copyFile/unlinktransient-lock sub-classes are documented for a possible follow-up. The ADR-mandated glob expansion tobin/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
continuebackedge or areturn <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. Seeeslint-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-disableESLint meta-rule to ban inline disables of portability rules. What shipped istests/portability-rule-disable-ban.test.cjs— anode:testthat 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.jsscope. §"Architecture" said the drift guard parsessrc/runtime-homes.ctsand the relevantbin/install.jsexports. The shippedtests/portability-vocab-drift.test.cjsoriginally covered onlyruntime-homes.cts; the audit extended it tobin/install.jswith a SOUND shape only (a top-level function that directlyreturn path.*(...)must be registered; plus a curated two-way existence lock on the installer path helpers). The looser body-contains heuristic used forruntime-homes.ctsis 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
- Keep extending the regex lint. Rejected — the adversarial review proved it is structurally fragile; every extension adds parser surface and bugs.
- Keep the ratchet, just add rules. Rejected — grandfathering is the thing being removed; the maintainer's directive is rip-and-replace, not legacy preservation.
- Keep
// windows-portability-ok:as an escape hatch. Rejected — zero escape hatches chosen; precision viaplatform-guard.cjsreplaces the need for an opt-out. - 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.