Skip to content

ci(tooling): gate the import graph of every gate that runs before pnpm install - #6159

Merged
yinlianghui-tw merged 2 commits into
mainfrom
claude/issue-6148-pre-install-import-graph
Aug 24, 2026
Merged

ci(tooling): gate the import graph of every gate that runs before pnpm install#6159
yinlianghui-tw merged 2 commits into
mainfrom
claude/issue-6148-pre-install-import-graph

Conversation

@yinlianghui-tw

@yinlianghui-twyinlianghui-tw commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6148

⚠️Re-measured at the merged head 3b2979f1f, after merging origin/main (b08b7eba9) in — never rebased, never force-pushed. The chain ahead cleared while this sat: #6145 merged 21:45, #6156 merged 22:24, and #6144 landed a new pre-install gate in between. Every number below is the post-merge reading; the pre-merge ones are kept only where the delta is the evidence. Working tree clean, exit codes captured before any pipe. Angle-bracket placeholders are written out in words throughout — GitHub's body sanitizer eats short bracketed fragments silently, which is the same class of hazard as the one this gate closes.

What landed

One gate, not seven copies of a test.

  • scripts/check-pre-install-import-graph.mjsderives the pre-install step list from .github/workflows/ on every run, then walks each named script's whole static import graph and requires every non-relative leaf to be a node builtin.
  • .github/workflows/pre-install-import-graph.yml — unfiltered, install-free, subscribes merge_group. Appears as Pre-Install Import Graph Check.
  • scripts/__tests__/check-pre-install-import-graph.test.ts — 33 cases.
  • Registrations: scripts/dependabot-merge-gate.mjsREQUIRED_CONTEXTS, the check:pre-install-import-graph alias, MUST_SUBSCRIBE_MERGE_GROUP, and a section plus inventory row in content/docs/guide/ci-cd-pipeline.md.

Seven files, 1244 insertions, 0 deletions against main after the merge. Nothing in scripts/__tests__/check-doc-component-types.test.ts and none of the 29 scripts PR #6145 owned were touched — the merge conflicted only where #6144 added its own adjacent registration lines, and both sides were kept.

scripts/check-doc-component-types.mjs's own needs no install case stays as a second, cheaper witness. Two witnesses on one property is not duplication here: one is a unit test on one file, one is a repo-wide gate derived from the workflows.

⭐ The merge is itself the derivation's best evidence

#6144 added doc-fence-languages.yml, a new pre-install gate, while this branch sat. The population picked it up with zero code change on this branch:

pre-merge (5d91aa2e3): 13 step(s), 13 distinct script(s)
post-merge (3b2979f1f): 14 step(s), 14 distinct script(s)
+ doc-fence-languages.yml : doc-fence-languages (step 2)
-> scripts/check-doc-fence-languages.mjs

A hard-coded list of eight would have merged clean and silently not covered it. That is the failure the card predicted, arriving inside the same afternoon.

The gate's own output, reconciled against the card

✅ check-pre-install-import-graph: OK — 14 pre-install step(s) in 13 job(s) run 14
scripts/ gate(s); 16 module(s) walked, every non-relative leaf a node builtin.

node scripts/check-pre-install-import-graph.mjs --list, exit 0:

workflow : jobstepscriptin the card?
changeset-guard.yml : no-major1check-changeset-no-major.mjsyes
changeset-presence.yml : changeset-presence2check-changeset-presence.mjsyes
ci.yml : changeset-check1check-changeset-fixed.mjsyes
ci.yml : type-check5check-type-check-coverage.mjsyes
control-bytes.yml : control-bytes2check-control-bytes.mjsyes
doc-component-types.yml : doc-component-types2check-doc-component-types.mjsyes
docs-links.yml : docs-links2check-doc-links.mjsyes
skills-paths.yml : skills-paths2check-skills-paths.mjsyes
lint.yml : lint6check-entry-guard.mjsnamed separately
lint.yml : lint5check-lint-coverage.mjsno
dependabot-auto-merge.yml : dependabot7dependabot-merge-gate.mjsno
half-state-patrol.yml : patrol2pm/check-half-states.mjsno
doc-fence-languages.yml : doc-fence-languages2check-doc-fence-languages.mjsarrived with #6144
pre-install-import-graph.yml : pre-install-import-graph2check-pre-install-import-graph.mjsnew here

All 8 of the card's rows reproduce exactly — same workflow, same job, same step index. The reconciliation is that the derivation finds three more the card's parse missed, and none of them arrived after the card was filed:

  • scripts/check-lint-coverage.mjs — in lint.yml since 9e725e06a (2026-08-23). The card bracketed lint.yml as a known case and named only one of its two pre-install script steps.
  • scripts/pm/check-half-states.mjs — since 97114f83b (2026-08-24 11:04). Not one of the 29 files Port check-entry-guard.mjs and convert the 29 hand-typed entry guards to isEntrypoint (nine distinct spellings, 28 with no realpath leg) #6092's PR 2 converted (it already used isEntrypoint), so a parse scoped to converted scripts could not see it.
  • scripts/dependabot-merge-gate.mjs — since 9e725e06a. This one is the interesting miss, it is the silent direction, and it is reproducible. That job never installs, but step 4 runs git config merge.pnpm-merge.driver "pnpm install --no-frozen-lockfile". A boundary parse that looks for the string pnpm install anywhere in a step puts the install at step 4 and drops step 7 out of the population — the SHRINKING direction, which costs coverage silently rather than raising a false red. INSTALL_RE is therefore anchored at a command position (start of line, or just after ;, &&, ||, |, (), so a package manager named inside an argument is not an install. The mirror case is anchored on the tool: pnpm exec playwright install chromium in ci.yml's e2e job installs a browser, not the workspace.

Three of twelve missed by a careful hand-run parse is itself the argument for the gate.

⭐ Non-vacuity: the one-hop control, re-run at the merged head — and the number grew exactly as predicted

The card hands the exact control. import ts from 'typescript' added to scripts/invoked-as.mjs — one hop away from a gate, invisible to any own-imports-only check. Fix committed first; mutation confirmed on disk by anchored grep -c before any result was read; restored by trap … EXIT INT TERM using git checkout HEAD -- followed by the path.

occurrences of the mutation text BEFORE: 0
occurrences of the mutation text AFTER: 1
occurrences of the anchor line AFTER (must still be 1): 1
scripts/invoked-as.mjs | 1 +

Pre-merge this reddened 3 chains. Post-merge it reddens 13, because #6145's sweep routed every converted script through invoked-as.mjs — the first round's report predicted this in as many words, and the prediction is now measured rather than asserted:

❌ check-pre-install-import-graph: 13 pre-install import-graph finding(s) across 14 gate(s)
• scripts/check-changeset-fixed.mjs -> scripts/invoked-as.mjs -> typescript
• scripts/check-changeset-no-major.mjs -> scripts/invoked-as.mjs -> typescript
• scripts/check-changeset-presence.mjs -> scripts/invoked-as.mjs -> typescript
• scripts/check-control-bytes.mjs -> scripts/invoked-as.mjs -> typescript
• scripts/check-doc-component-types.mjs -> scripts/invoked-as.mjs -> typescript
• scripts/check-doc-fence-languages.mjs -> scripts/invoked-as.mjs -> typescript
• scripts/check-doc-links.mjs -> scripts/invoked-as.mjs -> typescript
• scripts/check-entry-guard.mjs -> scripts/invoked-as.mjs -> typescript
• scripts/check-pre-install-import-graph.mjs -> scripts/invoked-as.mjs -> typescript
• scripts/check-skills-paths.mjs -> scripts/invoked-as.mjs -> typescript
• scripts/check-type-check-coverage.mjs -> scripts/invoked-as.mjs -> typescript
• scripts/dependabot-merge-gate.mjs -> scripts/invoked-as.mjs -> typescript
• scripts/pm/check-half-states.mjs -> scripts/invoked-as.mjs -> typescript

(each chain is printed on its own indented line under the offending script; collapsed to one line per row here for width). The pin test goes with it:

× finds no pre-install gate reaching a package
AssertionError: a script this repository runs before `pnpm install` needs
`node_modules` to load: expected [ …(13) ] to deeply equal []
Tests 1 failed | 32 passed (33)

13 of 14, and the one exception is explained rather than shrugged at:scripts/check-lint-coverage.mjs imports only bare builtins (from "fs", "path", "url") and carries no entry guard at all, so it never reaches invoked-as.mjs. That is consistent with check:entry-guard's own verdict — "no entry guard outside the baseline" — and is not a gap in the sweep. Restored by trap; tree clean, gate green again.

⭐ The derivation is shown to be a derivation, re-run at the merged head

A gate reporting "14 steps" while reading a constant is the defect it exists to prevent. Both legs moved a real step in .github/workflows/lint.yml, mutation confirmed on disk by line-number readback plus git diff --stat, restore under trap … EXIT INT TERM.

SHRINKVerify lint coverage moved BELOW Install dependencies (line 158 to 214; install 213 to 210):

14 step(s), 14 distinct script(s). -> 13 step(s), 13 distinct script(s).
script that left the population: scripts/check-lint-coverage.mjs

and the step index of the neighbour followed the move, which a stored list could not do:

base : lint.yml : lint (step 6) -> scripts/check-entry-guard.mjs
shrunk : lint.yml : lint (step 5) -> scripts/check-entry-guard.mjs

GROWCross-repo closer outcome contract moved ABOVE Install dependencies (line 259 to 213):

14 step(s) -> 15 step(s), and the row that appeared:
lint.yml : lint (step 8) -> scripts/check-cross-repo-closer-outcome.mjs

⭐ And the grow leg is a real positive control, not just a bigger number: that script imports yaml, a genuine package, so the gate went red on the arrangement rather than merely counting it —

❌ check-pre-install-import-graph: 1 pre-install import-graph finding(s) across 15 gate(s)
• scripts/check-cross-repo-closer-outcome.mjs reaches the package `yaml`:
scripts/check-cross-repo-closer-outcome.mjs -> yaml

That is exactly the ERR_MODULE_NOT_FOUND the card describes, caught at the moment the workflow is edited rather than in a CI job on some later pull request. Both legs restored; population back to 14, verdict back to green.

⚠️ The gate satisfies its own rule, by construction

pre-install-import-graph.yml runs it before any install, in a job that never installs, so the gate is in its own derived population and walks its own graph on every run. Visible in the table above, asserted by the test (is in its own population), and demonstrated by the one-hop ablation, which named check-pre-install-import-graph.mjs among the reddened chains. Its graph is node:fs, node:module, node:path, node:url, plus ./invoked-as.mjs and ./js-comment-mask.mjs, whose own graphs are builtins only.

Its entry guard is isEntrypoint(import.meta.url) from scripts/invoked-as.mjs — never a hand-typed one. ⚠️ Both shrink-only baselines are empty on main now (#6145 took KNOWN_HAND_TYPED_GUARDS from 29 to 0; #6156 took KNOWN_IMPORT_UNSAFE from 1 to 0), and this branch puts nothing back into either:

✓ check:entry-guard: 43 scripts/ file(s) — no entry guard outside the baseline; 0 file(s) still
hand-type one (0 occurrence(s), ⛔ SHRINK-ONLY, objectui#6092); 38 export bindings, 38 of them
inert on import (0 known-unsafe, ⛔ SHRINK-ONLY).

42 files to 43 and 37 export bindings to 38 — this file, counted, guarded, and inert on import — with both baselines still at zero. The pre-merge reading in the first round of this PR (29 file(s) … 54 occurrence(s), 1 known-unsafe) is stale and superseded by the line above.

Two design decisions, stated because they are the whole gate

Derived, never listed. A hard-coded list of eight breaks silently the first time someone moves a step above pnpm install, which is the edit that needs catching. #6135 landed the same lesson an hour before this card was filed; #6144 supplied the live proof above, within the day.

Static walk, not a runtime resolver hook. The hook was the right ad-hoc instrument for a one-off measurement and is the wrong one for a per-PR gate: it executes module top level, and these files spawn git, read the whole tree and call process.exit. Running fourteen gates to decide whether they could run is a far larger blast radius than the question deserves.

Two further choices, each with a case in this tree:

  • The rule is "builtin", not "spelled node:".check-changeset-fixed.mjs, check-type-check-coverage.mjs and check-lint-coverage.mjs really do write from "fs". That is install-free; requiring the prefix would be a style rule wearing a gate's clothes. The old assertion was too narrow in that direction and too weak in the other.
  • Comment- and literal-aware, via scripts/js-comment-mask.mjs.check-entry-guard.mjs carries 'require("fs").writeFileSync(…)' inside a corpus string, and this gate's own self-test spells a typescript import inside a fixture. The import KEYWORD's offset is tested against the masks and the specifier is read from the untouched source, so quotes survive — blanking literals outright would have destroyed the specifier being read.

⚠️ One registration the dispatch order's out-of-scope line did not anticipate

The order put content/docs/** out of scope. scripts/__tests__/ci-cd-pipeline-doc.test.ts requires every workflow in .github/workflows/ to have its own heading in content/docs/guide/ci-cd-pipeline.md, with DOCUMENTATION_EXEMPT deliberately empty — so a new workflow with no section is a red suite, not a style preference. The page therefore gets a section and an inventory row. It is a registration the repository mechanically demands, in the same family as the Dependabot classification, and not documentation work: nothing else under content/docs/** is touched. Flagging it rather than doing it quietly, so a reviewer does not read it as scope creep. (The merge conflict here was purely positional — #6144's Fence Languages section landed at the same insertion point; both sections are kept, main's first.)

Verification, each quoting its own verdict line

All at merged head 3b2979f1f. Exit codes captured before any pipe.

node scripts/check-pre-install-import-graph.mjs --self-test exit=0
✓ check-pre-install-import-graph self-test: 16 cases pass (derivation boundary, block
scalars, one-hop package, masked prose, cycles).
node scripts/check-pre-install-import-graph.mjs exit=0
✅ check-pre-install-import-graph: OK — 14 pre-install step(s) in 13 job(s) run 14 …
node scripts/check-entry-guard.mjs exit=0
✓ check:entry-guard: 43 scripts/ file(s) … 0 file(s) still hand-type one (0 occurrence(s) …
38 export bindings, 38 of them inert on import (0 known-unsafe …
node scripts/check-control-bytes.mjs exit=0
✅ check-control-bytes: OK (scanned 5104 tracked text file(s); skipped 85 binary).
node scripts/check-lint-coverage.mjs exit=0
✅ lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
node scripts/check-type-check-coverage.mjs exit=0
✅ type-check coverage: 45/46 via `type-check`, 0 via their own build, 0 known-broken …
node scripts/check-doc-links.mjs exit=0
Links are valid across 15 scan roots.
node scripts/check-doc-component-types.mjs exit=0
✅ Every documented component type is registered.
node scripts/check-doc-fence-languages.mjs exit=0
✅ check:doc-fences — every TypeScript block in 223 document(s) is fenced ts/tsx/typescript …
node scripts/check-skills-paths.mjs exit=0
✅ check-skills-paths: OK (93/94 stated path(s) resolve across 18 guide file(s); 1 baselined).
node scripts/check-changeset-presence.mjs exit=0
✅ No source of a released package changed in this range, so no changeset is owed.
node scripts/check-changeset-fixed.mjs exit=0
✅ All workspace packages are in the changeset fixed group.
node scripts/check-changeset-no-major.mjs exit=0
✅ No changeset declares a `major` bump.
pnpm type-check:scripts exit=0 # tsc -p tsconfig.scripts.json, no diagnostics
npx vitest run scripts/__tests__/ --maxWorkers=2 exit=0 # from the repo ROOT, never package-scoped
Test Files 71 passed (71) · Tests 1947 passed (1947)
pnpm lint:root exit=0 # the FULL root scan covering scripts/
✖ 28 problems (0 errors, 28 warnings) # all pre-existing — see below

No changeset is owed and none was added — the presence gate's own verdict line says so above, over the merged head's range. This diff touches no published package source. objectui has no skip-changeset label mechanism, so nothing was labelled either.

No lint narrowing was needed.pnpm lint:root ran the full population in seconds. Its 28 warnings are all pre-existing: eslint --format json over the four changed lintable files reports exactly four file objects, each errorCount=0 warningCount=0, so none of the 28 is in this diff.

Vitest narrowing, declared. The root run was scoped to scripts/__tests__/ — the whole of the suite that reads any file in this diff. The package and app suites were not run: this diff contains no package source, no shared config and no shared fixture, and its only root package.json edit is one added check: alias. CI runs the four shards regardless.

Control bytes:grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over all seven changed files returns no matches (exit 1), and check-control-bytes.mjs is green above.

Filed while here, not fixed here:#6160MUST_SUBSCRIBE_MERGE_GROUP names 6 workflows while the unfiltered blocking set is larger. Post-merge that gap is three, not two: doc-component-types.yml, doc-snippet-types.yml and now #6144's doc-fence-languages.yml all subscribe merge_group on disk with nothing asserting they keep doing so.

Not done, and not owed by this seat: CI convergence. The PM verifies the real gate jobs.


Generated by Claude Code

…m install
Derives the pre-install step list from .github/workflows/ and walks each named
script's whole static import graph, requiring every non-relative leaf to be a
node builtin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe
…-install-import-graph
# Conflicts:
#	content/docs/guide/ci-cd-pipeline.md
#	scripts/dependabot-merge-gate.mjs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@yinlianghui-tw@claude