fix(devx): a multi-prerequisite gate's refusal names the closure, not just the first unmet one - #15911
Merged
os-project-manager merged 2 commits intoSep 5, 2026
Conversation
… just the first unmet one `import-prerequisite` refused at the first unmet prerequisite, so a gate that declares several could only ever print a fix naming one of them: a dev who ran exactly that fix earned a second refusal and a second build round. The printed remedy was not wrong, it was incomplete. The refusal now reads the OTHER prerequisites out of the refusing gate's own source, probes each with the two on-disk facts `classifyImportFailure` already turns on, and prints one command that clears every unmet one. With nothing else unmet the text is byte for byte the one that shipped before. Message change only: when a gate refuses, whether it refuses, the exit code and the "Nothing was measured" clause are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016vtdMao3dQS9EfQfpyWixd
The count in the header was the loose one (callers with more than one call site). The population that can actually print a fix and then refuse again is the one whose prerequisites have DIFFERENT remedies, and it is smaller: state both readings and say why the count is not the argument. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016vtdMao3dQS9EfQfpyWixd
This was referenced Sep 5, 2026
os-project-manager
marked this pull request as ready for review
September 5, 2026 14:19
os-project-manager
enabled auto-merge
September 5, 2026 14:19
This was referenced Sep 5, 2026
os-project-manager
deleted the
claude/issue-15850-prerequisite-closure-fix
branch
September 5, 2026 14:49
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #15850
scripts/import-prerequisite.mjsrefused at the first unmet prerequisite, so a gate declaring several could only ever print a fix naming one of them. The printed remedy was not wrong, it was incomplete: a dev who ran exactly what the gate told them to run earned a second refusal and paid a second build round.Triage ruled option A (repair the helper, not one gate's await order). This is that repair, and it is a message change, not a verdict change — when a gate refuses, whether it refuses, the exit code (3) and the "Nothing was measured" clause are all untouched.
What the refusal does now
At the moment a real import failure has already produced a verdict, the frame reads the refusing gate's other declared prerequisites out of its own source, probes each with the two on-disk facts
classifyImportFailurealready turns on, and prints one command that clears every unmet one.Before / after on the card's own repro — same tree (installed, unbuilt), same exit 3, same headline:
The "after" run also names what else the command covers:
Running that printed command once (
Tasks: 4 successful, 4 total, through the verify lock) and re-running the gate now gives exit 0 — the card's step 2 is gone.On a fully cold tree the same composition spans kinds:
Fix: pnpm install && pnpm exec turbo run build --filter=@objectstack/lint.Two design choices, and why
The other prerequisites are read out of the caller's SOURCE. The unmet ones are exactly the ones whose
awaitwas never reached — the gate exits at the first — so there is no runtime object to ask, and importing them here to find out would be the refusal doing the very work it is refusing to do. The price is the onecheck-cross-package-test-inputspublishes in AGENTS.md (a source scan sees only the spellings it knows), and here it is bounded in the safe direction: an unrecognised spelling contributes nothing and the reader gets exactly the refusal that shipped before.The closure is a repeated
--filter, not a computed dependency graph. turbo already computes the closure. Deriving "lint already carries formula" here would mean reading the workspace graph in the one code path that runs precisely when the tree is unbuilt or uninstalled, and a wrong reduction prints a fix that is again one build short — this card's own defect, re-introduced by its repair. It also stays correct for a caller whose prerequisites are not in one dependency closure, where no single filter exists to find.The population, re-measured on this tip
Triage counted 7 and the dispatch counted 7, both by
requireDependencyoccurrences. Re-derived on this branch point, that instrument over-counts:measure-self-test-floor.mjsreaches 3 occurrences with zero call sites — all prose;check-comment-mask-corpus.mjshas 3 occurrences and one call site).yamltwice, which is one prerequisite and one fix.check-doc-formula-expressionsandcheck-doc-security-posture— declare prerequisites whose remedies differ, which is the population that can print a fix and then refuse again after it. The rest pair a package with../eslint.config.mjs, a relative specifier this frame deliberately defers on, or pair two third parties that onepnpm installclears together.Ruling A still holds and the fix is unchanged: the defect belongs to the frame, so the next multi-prerequisite gate anyone writes would otherwise arrive defective on the day it is written. The count is smaller than the dispatch assumed, and that is stated rather than buried.
Verification
The acceptance is the self-test that pins the refusal wording, and what it pins is the printed
Fix:, never the exit code alone. New batterythe CLOSURE: every declared prerequisite, in one command, 11 cases over real files (the gate source is read off disk; the packages it declares are probed on disk): the card's two-package case, the widened command's head inherited fromworkspaceBuildFixrather than re-spelled, the mixed install-then-build order, both helper spellings, and — load-bearing — the negatives: the fix must not stop at the first unmet one, a second prerequisite that is BUILT widens nothing, a single-prerequisite gate's text is byte for byte the one that shipped, a caller-supplied fix is returned untouched, an unreadable importer degrades to the old refusal, and a relative specifier is deferred rather than guessed at.Self-test: 71 cases pass (60 before, +11). Battery roster floor 17 to 18.
Ablation, both directions — mutation proved on disk by anchor count and blob hash, restored through
git checkout HEAD -- ...and verified by an emptygit diff HEAD:met/unknownskip removed (widen unconditionally)a second prerequisite that is BUILT widens nothingGates: derived with
dispatch-gates --changed --commands --repo objectstack-ai/objectstackon the final file list and reconciled with--ranat0c35ff7d— 33 derived, 33 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. The 7 artifact-roster families whose roster sits underscripts/(which the derivation marks as evidence in neither direction) were run too: 6 green;check:published-readme-exportsrefuses exit 3 PREREQUISITE NOT MET because 43 packages are unbuilt in this worktree — reported NOT MEASURED, not as a pass, and unrelated to this diff.check-governed-merges.mjs --test scripts/import-prerequisite.mjson the final file list: NOT governed.Changeset
skip-changeset. The whole diff is one repo-internal tooling script at the repo root; the root manifest isprivate, no published package includesscripts/, so this PR releases nothing. That is the workflow's own prescription, written inlint.yml: "this PR edits a CI-internal script" is the textbookskip-changesetcase — such a PR releases nothing.Scope
Only
scripts/import-prerequisite.mjs. Triage ruled this card lands first and that the sibling adoptions stay out of flight, so #15328 and #15835 are not addressed here and remain open; no gate's awaits were reordered and no caller was converted.Generated by Claude Code