Found while doing #6092's PR 2 (the entry-guard sweep). Not fixed there — it is a gate-shaped gap, not part of that conversion.
What was measured
Parsing every file in .github/workflows/ and, per job, comparing each step's index against the first pnpm install step in the same job, 8 steps invoke a scripts/ gate before any install:
| workflow : job | step | script |
|---|
changeset-guard.yml : no-major | 1 | check-changeset-no-major.mjs |
changeset-presence.yml : changeset-presence | 2 | check-changeset-presence.mjs |
ci.yml : changeset-check | 1 | check-changeset-fixed.mjs |
ci.yml : type-check | 5 | check-type-check-coverage.mjs |
control-bytes.yml : control-bytes | 2 | check-control-bytes.mjs |
doc-component-types.yml : doc-component-types | 2 | check-doc-component-types.mjs |
docs-links.yml : docs-links | 2 | check-doc-links.mjs |
skills-paths.yml : skills-paths | 2 | check-skills-paths.mjs |
(lint.yml runs check-entry-guard.mjs pre-install too, per #6133; it is pinned by scripts/__tests__/entry-guard-wiring.test.ts, which asserts the ordering but not the import graph.)
Running pre-install is deliberate and good — it is what lets these gates run unfiltered on every PR shape. The property it depends on is that each one's whole static import graph is node builtins plus repo-relative modules, with nothing needing node_modules.
Exactly one of the 8 has a test pinning that property: scripts/__tests__/check-doc-component-types.test.ts's needs no install case. The other 7 hold the property today by accident of what they happen to import.
Why it matters, and how it fails
A violation is invisible everywhere it could be caught cheaply:
- not a type error —
tsc -p tsconfig.scripts.json is happy with a package import; - not a lint error — the package is a real dependency of the repo;
- not a local failure — locally
node_modules exists, so the script runs fine; - not a test failure — nothing asserts it for those 7.
It surfaces only as ERR_MODULE_NOT_FOUND in a CI job, on whichever PR happens to touch the file — and for the four gates above whose whole point is running on shapes that skip installs, that is a gate that stops running rather than a gate that fails loudly.
The single pinned one proved the class is live: #6092's PR 2 changed check-doc-component-types.mjs and that test went red immediately, on a change that was in fact still install-free. The same change to any of the other 7 would have produced nothing.
Sharper than that: the pin has to follow the graph
The existing assertion read the gate's own import lines and required each to start with node:. That is too narrow in one direction (a relative import of a builtins-only local module is fine) and too weak in the other (it cannot see a package pulled in one hop away). #6092's PR 2 replaced it with a walk of the whole static import graph requiring every leaf to be a builtin, and demonstrated the difference: adding import ts from 'typescript' to scripts/invoked-as.mjs — one hop away — reddens the graph walk and would have been invisible to the old form.
Suggested shape
One gate rather than seven copies of a test. Derive the pre-install step list from the workflows themselves (the parse above is about 25 lines), then walk each named script's static import graph and require every non-relative leaf to be a node builtin. Deriving the list rather than hard-coding it is the part that matters: a hard-coded list rots the first time someone moves a step above pnpm install, which is exactly the edit that needs catching.
A runtime alternative exists and was used as the ad-hoc check in #6092's PR 2 — load each script under a resolver hook that throws on any specifier needing node_modules — but it executes module top level, so the static walk is the better gate.
Refs: #6092 (where this was measured) · #6133 (the pre-install wiring that made the class matter).
Generated by Claude Code
Found while doing #6092's PR 2 (the entry-guard sweep). Not fixed there — it is a gate-shaped gap, not part of that conversion.
What was measured
Parsing every file in
.github/workflows/and, per job, comparing each step's index against the firstpnpm installstep in the same job, 8 steps invoke ascripts/gate before any install:changeset-guard.yml:no-majorcheck-changeset-no-major.mjschangeset-presence.yml:changeset-presencecheck-changeset-presence.mjsci.yml:changeset-checkcheck-changeset-fixed.mjsci.yml:type-checkcheck-type-check-coverage.mjscontrol-bytes.yml:control-bytescheck-control-bytes.mjsdoc-component-types.yml:doc-component-typescheck-doc-component-types.mjsdocs-links.yml:docs-linkscheck-doc-links.mjsskills-paths.yml:skills-pathscheck-skills-paths.mjs(
lint.ymlrunscheck-entry-guard.mjspre-install too, per #6133; it is pinned byscripts/__tests__/entry-guard-wiring.test.ts, which asserts the ordering but not the import graph.)Running pre-install is deliberate and good — it is what lets these gates run unfiltered on every PR shape. The property it depends on is that each one's whole static import graph is node builtins plus repo-relative modules, with nothing needing
node_modules.Exactly one of the 8 has a test pinning that property:
scripts/__tests__/check-doc-component-types.test.ts'sneeds no installcase. The other 7 hold the property today by accident of what they happen to import.Why it matters, and how it fails
A violation is invisible everywhere it could be caught cheaply:
tsc -p tsconfig.scripts.jsonis happy with a package import;node_modulesexists, so the script runs fine;It surfaces only as
ERR_MODULE_NOT_FOUNDin a CI job, on whichever PR happens to touch the file — and for the four gates above whose whole point is running on shapes that skip installs, that is a gate that stops running rather than a gate that fails loudly.The single pinned one proved the class is live: #6092's PR 2 changed
check-doc-component-types.mjsand that test went red immediately, on a change that was in fact still install-free. The same change to any of the other 7 would have produced nothing.Sharper than that: the pin has to follow the graph
The existing assertion read the gate's own import lines and required each to start with
node:. That is too narrow in one direction (a relative import of a builtins-only local module is fine) and too weak in the other (it cannot see a package pulled in one hop away). #6092's PR 2 replaced it with a walk of the whole static import graph requiring every leaf to be a builtin, and demonstrated the difference: addingimport ts from 'typescript'toscripts/invoked-as.mjs— one hop away — reddens the graph walk and would have been invisible to the old form.Suggested shape
One gate rather than seven copies of a test. Derive the pre-install step list from the workflows themselves (the parse above is about 25 lines), then walk each named script's static import graph and require every non-relative leaf to be a node builtin. Deriving the list rather than hard-coding it is the part that matters: a hard-coded list rots the first time someone moves a step above
pnpm install, which is exactly the edit that needs catching.A runtime alternative exists and was used as the ad-hoc check in #6092's PR 2 — load each script under a resolver hook that throws on any specifier needing
node_modules— but it executes module top level, so the static walk is the better gate.Refs: #6092 (where this was measured) · #6133 (the pre-install wiring that made the class matter).
Generated by Claude Code