Skip to content

fix(scripts): report an unmet prerequisite instead of dying with a module stack - #11824

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-11557-gate-prerequisite-diagnostics
Aug 24, 2026
Merged

fix(scripts): report an unmet prerequisite instead of dying with a module stack#11824
os-steve merged 2 commits into
mainfrom
claude/issue-11557-gate-prerequisite-diagnostics

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

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_modules absent, proven — every one of typescript, yaml, semver,
eslint, github-slugger answered ERR_MODULE_NOT_FOUND to a direct probe).

Running all 97 root check:* gates there:

resultcount
passed (dependency-free by design)62
died with a raw ERR_MODULE_NOT_FOUND stack29
refused with a real diagnosis already5
timeout (unrelated, check:stall-guard)1

By missing package: typescript 20 · yaml 4 · github-slugger 2 · eslint 2 ·
semver 1. Plus packages/lint's two package-scoped gates and nine gates CI
invokes 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_FOUND code, and they have different
remedies. They are kept apart rather than collapsed:

preconditionremedy
package has no directory anywhere on the resolution pathpnpm install
@objectstack/* package present, entry point not on diskbuild that package
package present but its declared entry is missingbroken/partial install
package resolved, then its body threwrethrown untouched — not ours to diagnose

"Detecting it is cheap and reliable" — the trap is real, and it bit. The
first implementation reported @objectstack/lint as "not installed — run
pnpm 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 by
self-reference through the enclosing package.json, not through
node_modules — so a node_modules-only walk finds nothing and concludes
"absent". That is precisely the confident-wrong-diagnosis failure this card is
about, one level down. findPackageDir now models self-reference (name match
and an exports field, as node requires), and the self-test pins both
directions.

Why the guard sits at the import

ERR_MODULE_NOT_FOUND is thrown while node links the module graph, which
completes 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's reportPrerequisiteNotMet
— 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 header
states the rule this obeys: the frame is shared, the claim about what went
unmeasured stays with the gate. WORKSPACE_SCOPE and workspaceBuildFix are
imported from that module rather than restated.

Nothing about what any gate measures changed — only how an unmet precondition is
reported. check:i18n, check:i18n-coverage and check:type-check-debt keep
their own messages, verified unchanged.

Non-vacuity — both readings, per gate

Refusal on a proven-unmet tree. All 29 gates print PREREQUISITE NOT MET and
exit non-zero; zero raw stacks remain, and every one of the 29 that previously
died is covered (cross-checked by name, 0 uncovered).

check-ci-filter-parity: PREREQUISITE NOT MET — the dependency `yaml` is not installed
This gate imports `yaml`, and no `node_modules/yaml` exists on the
resolution path from /home/user/objectstack-issue-11557/scripts.
Fix: pnpm install
Nothing was measured: this gate exited before running a single check, so this
result says NOTHING about what it gates. It is NOT a finding, and it is not
evidence that anything in the tree is wrong.

And the second precondition class, on a tree with deps installed and the closure
unbuilt (packages/formula/dist proven absent):

check-doc-formula-expressions: PREREQUISITE NOT MET — the workspace package `@objectstack/formula` is not built
/home/user/objectstack-issue-11557/packages/lint/node_modules/@objectstack/formula/dist/index.mjs
Installing does not build it. This gate imports the package's COMPILED output,
so the source being present proves nothing.
Fix: pnpm exec turbo run build --filter=@objectstack/formula

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:

✓ check-ci-filter-parity --self-test: 39 assertions — both coverage limbs and the extglob bound, …
✓ ts-parse self-test: 28 cases pass (every measured wreck refuses and names its file, …)

matching the card's "39 assertions pass / 28 cases pass" exactly. The two
packages/lint gates and the seven remaining CI-invoked gates were run
individually 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-test36 cases, and it runs
    on 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.
  • Derived gate union at 4fdf84b82 via node 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 in
    full, no narrowing declared.
  • pnpm check:nul-bytes: OK, 6590 files.

skip-changeset: nothing published changes — packages/lint ships only
["dist","README.md","CHANGELOG.md"], and every other changed file is under
scripts/.


Generated by Claude Code

…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
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 24, 2026
@os-steve
os-steve marked this pull request as ready for review August 24, 2026 18:55
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT. Verified by content on origin/main @ 7e8393262. This is the largest correction to a card's premise this shift.

⭐ 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 check:* gates on a genuinely fresh worktree rather than reasoning about which ones import what.

62 passed · 29 raw ERR_MODULE_NOT_FOUND stack · 5 already refused properly · 1 unrelated timeout
missing: typescript 20 · yaml 4 · github-slugger 2 · eslint 2 · semver 1

And the census itself would have missed the card's own first example.check-ci-filter-parity is invoked directly from .github/workflows/lint.yml:2017-2018 and by zero root scripts — I confirmed both. So a population derived from package.json scripts is blind to nine gates, including the one the card opens with. Finding that required noticing that the obvious enumeration and the real population are different sets.

⭐ The insight that would have sunk a naive implementation

ERR_MODULE_NOT_FOUND is thrown during module LINKING, which completes before any module body runs, so a preflight imported at the top of a gate never executes

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 direction

1. 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, @objectstack/* present-but-unbuilt, present-but-incomplete install, and resolved-then-threw (rethrown untouched). Four distinct remedies; collapsing them would have told people to run pnpm install for a build problem.

2. ⭐ Detection is not trivially reliable — and your own first implementation proved it. It reported @objectstack/lint as "not installed, run pnpm install" on a tree where it was installed and merely unbuilt, because packages/lint's own gates import it by name and node resolves that by self-reference through the enclosing package.json, not through node_modules. I verified the preconditions: packages/lint/package.json declares name: @objectstack/lintand an exports field (both of which node requires for self-reference), and check-doc-formula-expressions.mjs:165 / check-doc-security-posture.mjs:127 do import by name.

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 findPackageDir, and pinned both directions. This is the second time today a dev's detector misdiagnosed and the dev found it rather than shipping it.

Non-vacuity, per gate, both directions

Unmet tree — precondition proven, not assumed (node_modules absent, then each of typescript/yaml/semver/eslint/github-slugger probed directly): 29 print PREREQUISITE NOT MET, zero raw stacks remain, and a by-name cross-check against the original 29 shows 0 uncovered. That last check is what turns "29 fixed" into "the same 29".

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:

Nothing was measured: this gate exited before running a single check, so this result says NOTHING about what it gates.

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 reinvented

I ⛔'d designing a third spelling. You imported WORKSPACE_SCOPE (cli-build-prerequisite.mjs:76) and workspaceBuildFix (:79) rather than restating them, and took the wording from reportPrerequisiteNotMet (check-i18n-coverage.mjs:1008). And nothing about what any gate measures changed — check:i18n, check:i18n-coverage and check:type-check-debt keep their own messages, verified unchanged.

Repo-wide pnpm lint run in full, no narrowing declared, on a 40-file diff. 59/59 derived gates green.

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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] two derived gates die with a raw ERR_MODULE_NOT_FOUND stack trace in a fresh worktree instead of reporting an unmet prerequisite

2 participants

@os-steve@claude