Skip to content

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 into
mainfrom
claude/issue-15850-prerequisite-closure-fix
Sep 5, 2026
Merged

fix(devx): a multi-prerequisite gate's refusal names the closure, not just the first unmet one#15911
os-project-manager merged 2 commits into
mainfrom
claude/issue-15850-prerequisite-closure-fix

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #15850

scripts/import-prerequisite.mjs refused 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 classifyImportFailure already 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:

before   Fix:  pnpm exec turbo run build --filter=@objectstack/formula
after    Fix:  pnpm exec turbo run build --filter=@objectstack/formula --filter=@objectstack/lint

The "after" run also names what else the command covers:

  This gate declares 3 prerequisites in all, and one more is ALSO unmet:

    @objectstack/lint — a workspace package that is not built

  The fix below clears every one of them, so following it does not earn a second
  refusal for a prerequisite this gate had already declared.

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 await was 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 one check-cross-package-test-inputs publishes 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 requireDependency occurrences. Re-derived on this branch point, that instrument over-counts:

  • 10 callers make more than one call through this frame (measure-self-test-floor.mjs reaches 3 occurrences with zero call sites — all prose; check-comment-mask-corpus.mjs has 3 occurrences and one call site).
  • 6 of those name more than one distinct specifier. The other four call yaml twice, which is one prerequisite and one fix.
  • 2check-doc-formula-expressions and check-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 one pnpm install clears 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 battery the 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 from workspaceBuildFix rather 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 empty git diff HEAD:

mutation expected observed
composition returns the unwidened verdict (the pre-fix helper) the 6 widening cases red, the 5 byte-identity cases stay green exactly those 6 red, 71 total, exit 1
the met/unknown skip removed (widen unconditionally) only the NEGATIVE CONTROL reds 1 red: a second prerequisite that is BUILT widens nothing

Gates: derived with dispatch-gates --changed --commands --repo objectstack-ai/objectstack on the final file list and reconciled with --ran at 0c35ff7d33 derived, 33 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. The 7 artifact-roster families whose roster sits under scripts/ (which the derivation marks as evidence in neither direction) were run too: 6 green; check:published-readme-exports refuses 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.mjs on 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 is private, no published package includes scripts/, so this PR releases nothing. That is the workflow's own prescription, written in lint.yml: "this PR edits a CI-internal script" is the textbook skip-changeset case — 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

os-project-manager and others added 2 commits September 5, 2026 13:24
… 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
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 5, 2026
@github-actions github-actions Bot added the size/m label Sep 5, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review September 5, 2026 14:19
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 1290156 Sep 5, 2026
36 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-15850-prerequisite-closure-fix branch September 5, 2026 14:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant