Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
29 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
MSD 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 msd-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 |
no-private-binary-resolution |
DEFECT.WINDOWS-PRIVATE-BINARY-RESOLUTION |
src/**/*.cts, msd-core/bin/**, scripts/**, hooks/** |
no-exact-case-env-access |
DEFECT.WINDOWS-EXACT-CASE-ENV-ACCESS |
src/**/*.cts, msd-core/bin/**, scripts/**, hooks/** |
require-full-tmpdir-triad |
DEFECT.WINDOWS-TEST-PORTABILITY (#4220) |
tests |
no-unbounded-dirname-walk |
DEFECT.WINDOWS-TEST-PORTABILITY (#4020 / #4220) |
tests, scripts |
no-rendered-text-length-assert |
ADR-456 §(c) typed-surface mandate (not a DEFECT.WINDOWS-* class — see #4590 amendment below) |
tests |
Amendment (2026-08-18, epic #3411 Phase 3 / #3619). no-private-binary-resolution is the
first catalog entry added after the original seven, and it extends this architecture to a
production Windows-runtime class rather than a test-portability one. It flags the two
unambiguous signals of re-implementing Windows binary resolution outside the platform seam:
reading PATHEXT (in any casing, from any object), and a hardcoded list containing two or more of
.exe/.cmd/.bat/.com. Both are exactly the shapes the four resolvers that epic #3411 deleted
actually had.
It deliberately does not flag a bare-name spawn, which is what #3411's own text asked for:
~30 such call sites exist and none is a defect (git, gh, npm ship native .exe on Windows),
so under rules 2 and 3 above a literal rule would be unsuppressable and would force rewriting
correct code. It also does not flag a PATH scan, which is used for legitimate membership checks
(bin/install.js) and cannot be soundly distinguished from a resolution scan. An unsound rule in a
zero-escape-hatch architecture is worse than no rule.
Two scoping consequences worth recording, because both were arrived at rather than assumed:
eslint-rules/**is outside this rule's surface —lib/portability-vocab.cjsowns the extension set per rule 4 and would otherwise flag itself. This is expressed by not linting that tree, never by an exemption, so rule 3 holds.- The seam exemption is path-suffix-anchored, matching
src/shell-command-projection.ctsor any path ending in/src/shell-command-projection.cts— not a substring match. The rule's own configured surface issrc/**/*.cts,msd-core/bin/**/*.cjs,scripts/**/*.cjs, andhooks/**/*.js;tests/**is deliberately outside that surface, because test setup legitimately assignsprocess.env.PATHEXT(tests/fallow-runner.test.cjs's P3 case does this). So the suffix-vs-substring distinction is proven only by case I9 of the rule'sRuleTestersuite, which feeds the rule a synthetic filename directly — not by real-world linting oftests/shell-command-projection-dispatch.test.cjs, which this rule never scans.
The rule started green with nothing suppressed and nothing grandfathered — Phases 1 and 2 had already removed every private resolver, which is what made a strict ratchet possible at all.
Amendment (2026-08-27, epic #3411 Phase 4 / #3624). no-exact-case-env-access extends the
architecture to a second production-runtime class: PR #3621 (epic #3411 Phase 1) shipped
resolveExecutableBinary reading env['PATH'] where env could be a plain object ({ ...process.env, ...opts.env }, which loses process.env's case-insensitive Proxy) — a Windows
CI-only failure caught and fixed by adding envGet(env, name) inside the seam. This rule
generalizes that fix into a ratchet: it flags a read of any casing of PATH, PATHEXT,
ComSpec, USERPROFILE, TEMP, TMP, or APPDATA (the vocabulary's
WINDOWS_CASE_VARYING_ENV_VARS) off any receiver that is not literally process.env — dot or
bracket notation, or destructuring — using the same seam exemption anchoring as
no-private-binary-resolution.
Matching had to be narrower than "any property access whose name matches the vocabulary,
case-insensitively": a first pass produced 113 false positives, because ordinary lowercase
property access (config.path, artifact['path']) collides with the vocab entry PATH under
case-insensitive comparison. The shipped rule additionally requires the receiver to be
"env-shaped" — literally <expr>.env / <expr>['env'] or a bare identifier named env (any
casing) — for both notations and for destructuring alike, which is what distinguishes
opts.env['PATH'] (flagged) from artifact['path'] (not flagged) without def-use/scope tracing.
One real pre-existing violation of the tightened rule was found and fixed in the same PR:
src/runtime-hooks-surface.cts's normalizeNodePath read env.APPDATA off a runtime union
((opts && opts.env) || process.env) that may be a plain object — migrated to envGet(env, 'APPDATA'). envGet (formerly the seam-private _envGet) is now exported from
src/shell-command-projection.cts specifically so this rule's remediation message ("route
through envGet") names a real, callable helper.
Amendment (2026-09-03, #4244). Two rules add author-time coverage for the bug class behind
two real, hard-evidence Windows CI incidents this week: #4020 (scripts/run-tests.cjs's
sweepProtectSet ancestor walk hung every scoped Windows CI lane) and its follow-on #4220 (the
regression test written for #4020's own fix masked a second bug — see below). Per Node's own docs,
os.tmpdir() on Windows reads only TEMP then TMP; TMPDIR is never consulted there at all
(on every other platform, TMPDIR is checked first). Per empirical verification this session,
path.dirname() is a fixed point at the platform root on both OSes, but the fixed-point VALUE
differs: path.posix.dirname('/') === '/' (length 1) vs. path.win32.dirname('C:\\') === 'C:\\'
(length 3) — so a root check written as a POSIX-shaped length heuristic (cur.length > 1) never
fires on Windows.
require-full-tmpdir-triadflags aTMPDIRenvironment override —process.env.TMPDIR = …, or aTMPDIRproperty in an object literal passed as a spawn-like call'senv:option — that is not accompanied byTEMPandTMPin the same scope. Anti-pattern:runNode(['-e', probe], { env: { ...process.env, TMPDIR: outer } })— on Windows the child inherits the parent's ambientTEMP/TMPand itsos.tmpdir()silently resolves to the wrong place. Fix: set all three to the same value. This is the exact shape #4220 found already shipped intests/run-tests-temp-root.test.cjs's own #4020 regression test, masked because Windows died in the unrelated dirname-walk hang before ever reaching it. The same #4244 sweep additionally found and fixed one more live instance intests/config-schema.property.test.cjs'sconfig-set accepts code_quality.fallow keystest (directprocess.env.TMPDIR = writableTmpassignment with no TEMP/TMP counterpart).no-unbounded-dirname-walkflags awhile/do-whileloop that reassigns its condition variable fromdirname()(bare,path.,.posix./.win32.) without a fixed-point termination guard (dirname(cur) !== cur, orpath.parse(cur).root) in the loop condition. Anti-pattern:while (cur && cur !== root && cur.length > 1) cur = dirname(cur);— on a Windows runner wherecurcan never equalroot(e.g. repo onD:\, temp root onC:\), the walk reaches the drive root and spins there at 100% CPU forever, sincecur.lengthstays 3 (> 1) at the fixed point. Fix: add thedirname(cur) !== curconjunct. The same #4244 sweep found this exact, still-unfixed shape live inscripts/run-tests.cjs'ssweepProtectSetblock (the original #4020 site) and fixed it in the same change by extracting a purecomputeSweepProtectSethelper with the fixed-point check, mirroring the shape of the (at-authoring-time separately in-flight, not yet merged) #4220 fix.
Both rules join the catalog's zero-escape-hatch discipline (rule 3 above): neither carries a
bespoke // allow-* comment marker, and both are added to tests/portability-rule-disable-ban.test.cjs's
PROTECTED_RULES list so an eslint-disable naming them is independently banned outside ESLint
too. no-unbounded-dirname-walk is registered on both tests/**/*.cjs and scripts/**/*.cjs
(the narrower scripts/**/*.cjs-only block, alongside no-private-binary-resolution) — the
production surface registration is load-bearing, since the real #4020 bug lived in scripts/, not
tests/. require-full-tmpdir-triad follows the established test-portability convention
(no-hardcoded-tmp, require-userprofile-with-home) and is registered on tests/**/*.cjs only,
matching both real incident sites.
A repo-wide sweep for other instances of either pattern (beyond the incident sites above) found
none: require-full-tmpdir-triad and no-unbounded-dirname-walk both ran clean against the rest
of the tree once the three live sites were fixed.
Amendment (2026-09-09, epic #4589 Phase 1 / #4590). no-rendered-text-length-assert extends
this catalog's zero-escape-hatch discipline to a bug class outside the DEFECT.WINDOWS-*
taxonomy: a test assertion whose pass/fail depends on the length or substring content of a
TEMPLATE LITERAL that INTERPOLATES an OS-derived path-returning expression (os.tmpdir(),
os.homedir(), or a PATH_RETURNING_FNS resolver) alongside other rendered content. Because
macOS's default tmpdir prefix (/private/var/folders/…) is longer than Linux's, such an assertion
can pass on one runner and fail on another — the root cause behind #4421's incident
(git show 4e75b836e9, tests/state-todos-render.test.cjs), which had already been fixed by
pinning the assertion to a typed field (json.todos[0].needs) per ADR-456 §(c) before this rule
existed to catch a recurrence. The rule catches the same DEFECT CLASS written directly in a test
file — an inline template literal that itself embeds a path-returning expression and is then
length/substring-probed — not the literal cross-file production-render-function incident shape
itself (a call whose return value happens to embed one of its own arguments); detecting the latter
would require tracing into the callee's own function body, which is out of scope for a
single-file AST rule. A bare path-returning expression probed directly, with no surrounding
template literal (e.g. p.endsWith('.md'), resolved.startsWith(root), dir.length > 0), is
never flagged: it is a direct, deterministic check on the path value itself, not an assertion
about other content that happens to share a rendered string with a variable-length path.
Reaching this sound scope took two successive repo-wide sweeps. The first targeted an initial
broader design that traced one hop into a resolved call's own arguments to approximate the
cross-file production-render-function shape; that design proved unsound, producing dozens of
false positives on ordinary fs.readFileSync(path.join(...)) + assert.match patterns (correct
code, not instances of the defect), because a call's return value cannot be soundly assumed to
embed one of its own arguments just because that argument is a path. The call-argument tracing was
removed in favor of a one-hop receiver model that flagged ANY direct path-returning call as taint,
with or without a template literal. The second sweep, run against that narrower rule, found the
one-hop-receiver model was itself still too broad: it produced 45 false positives across tests/
of exactly one shape — a bare path value probed for a structural property of its own (suffix,
prefix, or non-emptiness), e.g. .endsWith('.md') file-extension checks, .startsWith(root)
path-confinement checks, and .length > 0 non-emptiness checks — none of which are instances of
the #4421 OS-tmpdir-length hazard. Bare-direct-path-call matching was removed, restricting the
rule to fire ONLY when the asserted-on value is a template literal interpolating a path-returning
expression. The known miss (the literal cross-file-render-function shape, a receiver-side
two-hop chain, or a path value arriving as a function parameter) is disclosed in the rule's own
header rather than attempted unsoundly.
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,msd-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.