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

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

  2. self-assigned this
    on Aug 24, 2026
  3. 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

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

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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions