Uh oh!
There was an error while loading. Please reload this page.
fix(scripts): report an unmet prerequisite instead of dying with a module stack - #11824
Conversation
…dule stack A fresh per-task worktree has no `node_modules` until `pnpm install` runs. The gates in `scripts/` that import `typescript`, `yaml`, `semver`, `eslint` or `github-slugger` used to answer that with a node-internals `ERR_MODULE_NOT_FOUND` stack trace, which reads exactly like a real finding: same exit 1, no statement that nothing was measured. Twenty-nine root gates did this, measured on one tree. The failure is thrown while node LINKS the module graph, which completes before any module body runs, so a preflight imported at the top of a gate never executes. The failing imports are therefore deferred behind a thunk the gate hands to a shared helper, which is the one shape that puts a catchable boundary around them. `scripts/import-prerequisite.mjs` classifies four failures that arrive wearing the same error code and keeps them apart, because they have different remedies: a package with no directory (`pnpm install`), a `@objectstack/*` package present but never built (build it — including via node's self-reference resolution, which a node_modules-only walk misses and would misreport as uninstalled), a partial install, and a package that resolved and then threw — the last rethrown untouched so its stack survives. Wording follows `check-i18n-coverage.mjs`'s `reportPrerequisiteNotMet`: what is unmet, why, the command that clears it, and that nothing was measured. Following `cli-build-prerequisite.mjs`, the frame is shared and the claim about what went unmeasured stays with the gate. No gate's measurement changes; only how an unmet precondition is reported. Every affected gate still produces its normal verdict on a built tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
os-steve
commented
Aug 24, 2026
ACCEPT. Verified by content on ⭐ The card said two. You measured twenty-nine.I asked you to re-measure and warned the number might be wider. It is an order of magnitude wider, and you got there the only way that could have been trusted: running all 97 root ⭐ And the census itself would have missed the card's own first example. ⭐ The insight that would have sunk a naive implementation
That is exactly right and it is not obvious. The natural fix — import a preflight helper first and call it — cannot work, because the failing import is resolved before any statement in the file runs. Deferring the failing imports to thunks written in the caller (so resolution stays relative to the importing module, across two trees with different closures) is the shape that actually works. A dev who had not understood the linking phase would have shipped something that passed on a built tree and did nothing on the tree it exists for. Both Zone 2 assumptions falsified, both in the useful direction1. The failure is not one shape. I said "may be two different preconditions… do not collapse them if they are genuinely different." You found four sharing the same error code — absent package, 2. ⭐ Detection is not trivially reliable — and your own first implementation proved it. It reported As you put it: that is this card's own defect one level down — a diagnostic that confidently reports the wrong cause. You caught it in your own work, modelled self-reference properly in Non-vacuity, per gate, both directionsUnmet tree — precondition proven, not assumed ( Built tree — all 29 exit 0 and zero print a prerequisite message. Without that control, "it now reports a prerequisite" is consistent with a gate that reports one always. ⭐ And the message wording is the best thing in the diff:
That sentence is the whole card. Seven devs today read a stack trace as a verdict; this one tells them it is not one. The convention was reused, not reinventedI ⛔'d designing a third spelling. You imported Repo-wide Flipping to ready. Arming once every check is green — 40 files is the diff I will re-check most carefully before arming. Generated by Claude Code |
One conflict, in `scripts/check-override-consistency.mjs`: main's #11816 line added `import { workspacePackages } from './workspace-enumerator.mjs'` next to the `semver` and `yaml` imports this branch converted into deferred, guarded loads. Resolved by keeping BOTH — the new enumerator import stays a static relative import (it links without `node_modules`, and the module it names imports only node builtins plus `./js-comment-mask.mjs`, so it needs no guard), and the two third-party loads keep their prerequisite guard. `scripts/check-step-collectors.mjs` auto-merged: #11815's widened collector recognition and this branch's import guard touch different regions of the file, and both are present. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11557
The population is 29 root gates, not two
The card names two. I re-measured before acting, on a genuinely fresh worktree
(
node_modulesabsent, proven — every one oftypescript,yaml,semver,eslint,github-sluggeransweredERR_MODULE_NOT_FOUNDto a direct probe).Running all 97 root
check:*gates there:ERR_MODULE_NOT_FOUNDstackcheck:stall-guard)By missing package:
typescript20 ·yaml4 ·github-slugger2 ·eslint2 ·semver1. Pluspackages/lint's two package-scoped gates and nine gates CIinvokes directly rather than through a root npm script (
check-ci-filter-parity,this card's own first example, is one of those — it is not a root
check:*script).40 files changed in total.
Zone 2 — both assumptions falsified, in the useful direction
"The failure is always the same shape" — it is not. Four distinct failures
arrive wearing the same
ERR_MODULE_NOT_FOUNDcode, and they have differentremedies. They are kept apart rather than collapsed:
pnpm install@objectstack/*package present, entry point not on disk"Detecting it is cheap and reliable" — the trap is real, and it bit. The
first implementation reported
@objectstack/lintas "not installed — runpnpm install" on a tree where it was installed and merely unbuilt. Cause:packages/lint's own gates import the package by name, which node resolves byself-reference through the enclosing
package.json, not throughnode_modules— so anode_modules-only walk finds nothing and concludes"absent". That is precisely the confident-wrong-diagnosis failure this card is
about, one level down.
findPackageDirnow models self-reference (name matchand an
exportsfield, as node requires), and the self-test pins bothdirections.
Why the guard sits at the import
ERR_MODULE_NOT_FOUNDis thrown while node links the module graph, whichcompletes for the whole graph before any module body runs. A preflight imported
at the top of a gate — the obvious design — never executes; the link fails first
and the stack trace is unchanged. So the failing imports become deferred thunks
the gate hands to the shared helper. The thunk is written in the caller
deliberately: resolution is relative to the importing module, and these gates
live in two trees with different installed closures.
Not a third spelling
Wording and order follow
check-i18n-coverage.mjs'sreportPrerequisiteNotMet— what is unmet, why, the command that clears it, and the load-bearing half, that
nothing was measured. Structure follows
cli-build-prerequisite.mjs, whose headerstates the rule this obeys: the frame is shared, the claim about what went
unmeasured stays with the gate.
WORKSPACE_SCOPEandworkspaceBuildFixareimported from that module rather than restated.
Nothing about what any gate measures changed — only how an unmet precondition is
reported.
check:i18n,check:i18n-coverageandcheck:type-check-debtkeeptheir own messages, verified unchanged.
Non-vacuity — both readings, per gate
Refusal on a proven-unmet tree. All 29 gates print
PREREQUISITE NOT METandexit non-zero; zero raw stacks remain, and every one of the 29 that previously
died is covered (cross-checked by name, 0 uncovered).
And the second precondition class, on a tree with deps installed and the closure
unbuilt (
packages/formula/distproven absent):The control — normal verdict on a built tree. All 29 exit 0, and zero
print a prerequisite message. Quoting the card's own two examples:
matching the card's "39 assertions pass / 28 cases pass" exactly. The two
packages/lintgates and the seven remaining CI-invoked gates were runindividually and are green too, so every one of the 40 changed files has a
built-tree reading on the record.
Verification
node scripts/import-prerequisite.mjs --self-test— 36 cases, and it runson an uninstalled tree (node builtins only). Pins all four classifications as
distinct, self-reference in both directions, transitive misses naming the
package rather than the local module, and the rethrow.
4fdf84b82vianode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(40 paths, 59 gates): 59/59 green.pnpm lint(repo-wide,eslint . --no-inline-config): green, 79s — run infull, no narrowing declared.
pnpm check:nul-bytes: OK, 6590 files.skip-changeset: nothing published changes —packages/lintships only["dist","README.md","CHANGELOG.md"], and every other changed file is underscripts/.Generated by Claude Code