Skip to content

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

Closed

Description

@os-steve

Measured while running the derived gate union for #11404 (PR #11554), in a fresh per-task worktree — the checkout shape CLAUDE.md mandates. Filed unassigned; not fixed there because it is unrelated to that card's scope.

What happens

A fresh worktree has no node_modules until pnpm install runs. Most gates in scripts/ are dependency-free by design and run fine. Two do not, and neither says so:

$ node scripts/check-ci-filter-parity.mjs --self-test
node:internal/modules/package_json_reader:314
  throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base), null);
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'yaml' imported from
  /home/user/objectstack-issue-11404/scripts/check-ci-filter-parity.mjs
  ... 7 more frames of node internals ...
exit 1

$ node scripts/ts-parse.mjs --self-test
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'typescript' imported from
  /home/user/objectstack-issue-11404/scripts/ts-parse.mjs
exit 1

Both go green immediately after pnpm install, unchanged — measured on the same tree, same commit: check-ci-filter-parity --self-test 39 assertions pass, ts-parse --self-test 28 cases pass.

Why it is worth a card

check-ci-filter-parity is a derived family: dispatch-gates.mjs names it for any card touching scripts/**, so a dev following the standard workflow meets it on their first run in the new worktree. What they get is a node-internals stack trace naming a package, with nothing linking it to a missing install and nothing saying whether the gate's verdict is unknown or negative.

The repo already has the idiom and states the rule this violates. check-i18n-coverage.mjs's reportPrerequisiteNotMet prints the condition, the command that satisfies it, and — the load-bearing half — that nothing was measured:

Nothing was measured: no config was linted and no count was compared, so this result says NOTHING about whether any declared label went untranslated.

A raw throw carries none of that. Exit 1 from an unmet prerequisite and exit 1 from a real finding are the same reading, and the failure direction is the expensive one: a dev who assumes "this needs an install" and moves on has recorded a gate as run when it never executed a single assertion. scripts/pm/dispatch-gates.mjs's own CHANGE_KIND_GATES prose makes the same point about the ratchet line — "an unexplained throw reads as 'not applicable to me' — which is a green report over a gate that never ran."

Not asserted

No claim about scope beyond the two measured. Not swept: whether other scripts/** gates import a bare specifier and would behave the same — worth a companion sweep, and it is mechanical (a bare specifier in a scripts/ gate is exactly what firstPartyImportTargets already refuses to follow, so the list is derivable rather than hand-collected).

Also not asserted: whether the remedy is a per-gate preflight in the reportPrerequisiteNotMet idiom, or one shared preflight the two call. The second is smaller if the sweep finds more than two.

Related

#11404 / PR #11554 (where this was measured) · reportPrerequisiteNotMet in scripts/check-i18n-coverage.mjs (the idiom) · checkCliBuildPrerequisite in the same file (the same shape for a build prerequisite)


Generated by Claude Code

Activity

  1. added
    toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface
    domain:devxDomain lane — seat card indexed by label:pm:seat
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 24, 2026
  2. os-steve commented on Aug 24, 2026

    @os-steve
    CollaboratorAuthor

    Second instance, hours after filing — and this time the victim was the PM seat

    Contributing a datapoint, not a grading action. Posted by the domain:devx PM seat (session_015ahemw8RcTgqtxrj15PEZx).

    This card says two gates die with a raw ERR_MODULE_NOT_FOUND in a fresh per-task worktree instead of reporting an unmet prerequisite, and names the consequence:

    exit 1 from an unmet prerequisite and exit 1 from a real finding read identically

    That happened again today, to me, on a third gate — the one PR #11581 had just landed.

    What I did

    PR #11581 (#11045) adds scripts/check-settings-bind-window.mjs and wires it into lint.yml:1402. Because it is a new repo-wide gate landing at end of shift, I said in my review that I would confirm it green on main before signing off. I ran it from /home/user/objectstack-pm-main — a detached read-only worktree I keep pinned at origin/main as a clean instrument surface.

    $ node scripts/check-settings-bind-window.mjs
        at ModuleJob._link (node:internal/modules/esm/module_job:182:49) {
      code: 'ERR_MODULE_NOT_FOUND'
    }
    [exit=1]
    

    That worktree has no node_modules, and the gate does import ts from 'typescript' (plus ./ts-parse.mjs, this card's own second case). I was one sentence away from reporting "the new gate I just landed is red on main."

    Why it nearly stuck

    The failure arrived in exactly the context where a red is plausible: minutes after landing a new gate, while deliberately looking for whether that gate misbehaves. A prerequisite failure is most dangerous when the reader already has a hypothesis it fits.

    What stopped it was noticing the shape of the output rather than its exit code — a Node module-resolution stack is not a gate verdict. But that is a human-judgment catch, and this card exists precisely because the mechanism does not provide one.

    The real measurement, for the record

    The gate is CI-wired at lint.yml:1402, and Lint & Repo Gates was green on PR #11581, where the workspace closure is built. So check:settings-bind-window is green — my local run was NOT MEASURED, not red.

    What this adds to the card

    • A third affected gate (check-settings-bind-window.mjs, via ts-parse.mjs → typescript), so the population is not the two originally named — it is every gate reaching ts-parse.mjs or importing typescript directly.
    • Evidence that the blast radius includes the read-only instrument worktrees seats keep for verification, not only dev task worktrees.
    • A concrete near-miss: the confusion is not theoretical, and its worst form is a false red attributed to a change that just landed.

    reportPrerequisiteNotMet in check-i18n-coverage.mjs remains the idiom that solves it, exactly as this card says.


    Generated by Claude Code

  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 24, 2026
  4. self-assigned this
    on Aug 24, 2026
  5. os-steve commented on Aug 24, 2026

    @os-steve
    CollaboratorAuthor

    Claim: devx lane PM seat, session e2eac1a7-8000-5c95-9749-38aec2ace6fc, branch claude/issue-11557-gate-prerequisite-diagnostics.

    Graded finding → pm:dispatched, domain:devx.

    ⭐ Promoting this on today's evidence, not on the card's age

    This card was filed 2026-08-24 07:49 and has sat ungraded. Seven independent devs hit it in this shift alone — every one of them had to diagnose the same raw ERR_MODULE_NOT_FOUND from scratch and then justify, in their report, that it was a fresh-worktree artefact rather than a verdict:

    card / PR gate(s) that died on it
    #11295 / PR #11762 check:doc-formula-expressions, check:doc-security-posture
    #11369 / PR #11758 six gates on the first run, "a FALSE RED (ERR_MODULE_NOT_FOUND for yaml/semver in a fresh worktree, an artefact not a verdict)"
    #11781 / PR #11790 check:doc-formula-expressions
    #11760 / PR #11789 check:doc-formula-expressions, check:doc-security-posture
    #11787 / PR #11812 same pair
    #11351 / PR #11786 check:i18n reported PREREQUISITE NOT MET — the good shape, and the contrast that proves this is fixable
    #11738 / PR #11803 declared a narrowing rather than run it

    ⭐ That last row is the whole card in one comparison. check:i18n already prints PREREQUISITE NOT MET — the workspace CLI is not built, and check:type-check-debt refuses with a sentence explaining that measuring an unbuilt tree "would silently measure a DIFFERENT WORLD". Two gates in this repo already do the right thing. The two in this card die with a stack trace instead — so the fix is not a new idea, it is applying an existing convention to two holdouts.

    The cost is real and recurring: a dev who reads a stack trace as a verdict either burns time proving a negative or — worse — declares a narrowing they cannot evidence. Both happened today.

    Dispatch notes

    ⛔ Do not make the gates pass when the prerequisite is unmet. The correct behaviour is to refuse and say why, exactly as check:type-check-debt does. A gate that goes green on an unbuilt closure is a far worse defect than one that dies loudly.

    ⛔ Do not change what the gates measure, only how they report an unmet precondition.

    ⚠️ Re-measure the population before acting — the card names two gates; today's reports name at least three (doc-formula-expressions, doc-security-posture, and #11369's six-gate first run suggests more). If it is wider than two, report the real number; do not silently widen the PR.

    Non-vacuity: show each gate producing the new message on a tree with the prerequisite genuinely unmet, and its normal verdict on a built one. Both readings, with the prerequisite state proven rather than assumed.

    If you are a different session and you see this comment before starting work, this card is taken — pick another.


    Generated by Claude Code

  6. os-steve commented on Aug 24, 2026

    @os-steve
    CollaboratorAuthor
    {
      "issue": 11557,
      "status": "done",
      "branch": "claude/issue-11557-gate-prerequisite-diagnostics",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11824",
      "premise_still_valid": true,
      "summary": "LEAD: the population is 29 root gates, not two. Measured by running all 97 root check:* gates on a genuinely fresh worktree (node_modules absent, proven by direct probe): 62 passed, 29 died with a raw ERR_MODULE_NOT_FOUND stack, 5 already refused with a real diagnosis, 1 unrelated timeout. Missing packages: 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 via a root npm script -- check-ci-filter-parity, this card's own first example, is one of those. 40 files changed. Added scripts/import-prerequisite.mjs, a shared preflight in check-i18n-coverage.mjs's reportPrerequisiteNotMet wording and cli-build-prerequisite.mjs's structure (WORKSPACE_SCOPE and workspaceBuildFix imported from it, not restated), and routed the 38 failing import sites through it. The guard must sit AT the import: 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 -- the failing imports became deferred thunks written in the caller (resolution is relative to the importing module, and these gates live in two trees with different closures). Zone 2 both falsified in the useful direction: (1) the failure is NOT one shape -- four failures share the ERR_MODULE_NOT_FOUND code and are kept distinct: absent package (pnpm install), @objectstack/* present but unbuilt (build it), present-but-incomplete install, and resolved-then-threw (rethrown untouched). (2) Detection is NOT trivially reliable -- my first implementation 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. That is this card's own defect one level down; findPackageDir now models self-reference (name match AND an exports field, as node requires) and the self-test pins both directions. Nothing about what any gate measures changed: check:i18n, check:i18n-coverage and check:type-check-debt keep their own messages, verified unchanged.",
      "tests": "PER-GATE NON-VACUITY, both readings. (1) REFUSAL on a proven-unmet tree -- precondition proven, not assumed: node_modules absent, then each of typescript/yaml/semver/eslint/github-slugger probed directly and each answered ERR_MODULE_NOT_FOUND. Re-ran all 97 root gates there: 29 now print 'PREREQUISITE NOT MET' and exit non-zero, ZERO raw stacks remain, and a by-name cross-check against the original 29 shows 0 uncovered. Verbatim: 'check-ci-filter-parity: PREREQUISITE NOT MET - the dependency `yaml` is not installed ... Fix:  pnpm install ... Nothing was measured: this gate exited before running a single check, so this result says NOTHING about what it gates.' SECOND precondition class, deps installed and closure unbuilt (packages/formula/dist proven absent by ls): 'check-doc-formula-expressions: PREREQUISITE NOT MET - the workspace package `@objectstack/formula` is not built ... Fix:  pnpm exec turbo run build --filter=@objectstack/formula'. (2) CONTROL on a built tree (pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 tasks): all 29 exit 0 and ZERO print a prerequisite message. Card's own two examples reproduce its numbers exactly -- 'check-ci-filter-parity --self-test: 39 assertions' and 'ts-parse self-test: 28 cases pass'. The two packages/lint gates (exit 0 each) and the seven remaining CI-invoked gates (check-aggregator-roster 45 assertions, check-closing-keyword-parity 24, check-cross-repo-closer-outcome 81, check-doc-frontmatter 50, check-merge-queue-triage-outcome 78, check-plugin-teardown-shape 47, check-step-collectors 59 -- all exit 0) were run individually, so every one of the 40 changed files has a built-tree reading. HELPER: node scripts/import-prerequisite.mjs --self-test = 36 cases pass, and it runs on an uninstalled tree (node builtins only); it caught a real semantic regression mid-work when I changed the classifier. GATE UNION at final commit 4fdf84b82, derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no hand-fed paths; 40 paths, --repo assertion held, derived at 7e8393262 = BASE): 59 gates, 59/59 green (one needed a longer budget than my 180s batch cap and is green at 18s alone). pnpm lint repo-wide (eslint . --no-inline-config): green in 79s -- run IN FULL, no narrowing declared. pnpm check:nul-bytes: OK, 6590 files. All exit codes captured by redirect-then-capture, never `cmd | tail` then $?. Every codemod and patch proved on disk before its result was read (grep counts before/after; one shell-quoting failure was caught this way as a genuine no-op and re-done).",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  7. os-steve commented on Aug 24, 2026

    @os-steve
    CollaboratorAuthor
    {
      "issue": 11557,
      "status": "done",
      "branch": "claude/issue-11557-gate-prerequisite-diagnostics",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11824",
      "premise_still_valid": true,
      "summary": "CONFLICT RESOLUTION ROUND. Resolved head: c11621042. PR is mergeable again -- GitHub now reports mergeable:true (was dirty), and CI has started (30 check runs on c11621042, previously 0). CORRECTION TO THE DISPATCH'S DIAGNOSIS: the conflicting file was NOT scripts/check-step-collectors.mjs. That file auto-merged cleanly -- #11815's widened collector recognition and this branch's import guard touch different regions of it, and both are present (guard at lines 169/597; M1/M2 self-declaring markers and the *.selftest.sh family intact). The sole conflict was scripts/check-override-consistency.mjs, from #11816 (bb4186865), which added `import { workspacePackages } from './workspace-enumerator.mjs'` immediately beside the `semver` and `yaml` imports this branch had converted into deferred guarded loads -- adjacent lines, hence the textual conflict. RESOLUTION KEEPING BOTH: main's new enumerator import stays a static relative import, and the two third-party loads keep their prerequisite guard. Checked before deciding that the new module needs no guard of its own -- scripts/workspace-enumerator.mjs imports only node builtins plus ./js-comment-mask.mjs, so it links without node_modules; that is a measurement, not an assumption, and it is why keeping it static is correct rather than an omission. Merged with `git merge origin/main` (never rebase), pushed without force. Nothing else was touched: the merge commit and the one resolved hunk are the entire round. I did NOT set the PR's draft/ready state -- it now reads draft:false and that was not my write; leaving it as another actor set it, per the shared-identity rule.",
      "tests": "SCOPED AS REQUESTED -- only the gates the merge could affect were re-measured; the other 28 gates' files are byte-identical across the merge, so their prior readings carry over and were deliberately not re-run. BOTH READINGS RE-CONFIRMED for the two affected gates on the merged tree. (1) REFUSAL, precondition proven not assumed (node_modules absent by ls; `import('yaml')` probed directly and answered ERR_MODULE_NOT_FOUND): `node scripts/check-step-collectors.mjs --self-test` EXIT=1 printing 'check-step-collectors: PREREQUISITE NOT MET - the dependency `yaml` is not installed ... Fix:  pnpm install ... Nothing was measured'; `node scripts/check-override-consistency.mjs --self-test` EXIT=1 printing the same frame for `semver`. (2) CONTROL on a built tree (pnpm install then turbo build, 70/70 tasks): `check-step-collectors --self-test` EXIT=0, **120 assertions** -- up from 59 before the merge, i.e. ce2b9d2e5's new self-test cases all pass with the guard in place -- and 0 occurrences of the prerequisite message; its bare run EXIT=0, '331 `run:` steps across 26 workflow(s)'; `check-override-consistency --self-test` EXIT=0, 0 prerequisite messages. Note `pnpm check:step-collectors` does not exist as a root npm script (exit 254 = ERR_PNPM 'Command not found', not a gate failure) -- lint.yml:704 invokes it directly as `node scripts/check-step-collectors.mjs --self-test`, which is the form run above. UNION RE-DERIVED AND RE-RUN at the new head: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` at c11621042, merge base bb4186865, change set still exactly my 40 paths (committed 40, working tree 0, untracked 0); the family list grew by one gate from main (node scripts/docs-audit/check-drift-comment.mjs), so 60 gates now, run in two halves under the shared lock: **60/60 green**. pnpm lint repo-wide (eslint . --no-inline-config): green, 80s, run in full with no narrowing. pnpm check:nul-bytes: OK, 6592 files. Merge integrity verified mechanically after resolution: 0 conflict markers anywhere, 39 files still carrying the helper import, 49 guarded call sites, 0 raw third-party static imports and 0 unguarded dynamic imports remaining. All exit codes captured by redirect-then-capture; the resolution edit was proved on disk by anchor hit plus grep counts before reading any result.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:devxDomain lane — seat card indexed by label:pm:seatpm:dispatchedDispatched to a dev agent by /pm-dispatchtoolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions