fix: Cap composition error collection, not branch iteration (#54) - #55
Open
shadowhand wants to merge 1 commit into
Open
fix: Cap composition error collection, not branch iteration (#54)#55shadowhand wants to merge 1 commit into
shadowhand wants to merge 1 commit into
Conversation
shadowhand
commented
Aug 11, 2026
Comment on lines
+100
to
+107
| - `anyOf`/`oneOf` no longer depend on branch declaration order. The | ||
| `MAX_COMPOSITION_ERRORS` cap in `AbstractCompositionalValidator` used | ||
| `return` to stop collecting errors, which also abandoned the remaining | ||
| branches — so a branch that would match went unevaluated whenever | ||
| earlier branches produced 20+ errors, and `anyOf` reported "At least | ||
| one of the schemas must match, but none did". The cap now bounds error | ||
| collection only; every branch is still evaluated. Error output is | ||
| unchanged (20 errors plus one `TooManyErrorsError`). (#54) |
Author
There was a problem hiding this comment.
This was added to the wrong section, it should be under a new Unreleased section.
AbstractCompositionalValidator::validateSchemas() stopped collecting errors at MAX_COMPOSITION_ERRORS with a `return`, which left the outer `foreach ($schemas ...)` loop as well. Every branch after the cap went unevaluated, so $validCount could not grow: anyOf reported "At least one of the schemas must match, but none did" for data that a later branch matches, and oneOf could under-count matches. Validation outcome depended on branch declaration order, which anyOf semantics forbid. The cap now tracks a $capped flag and breaks out of the error loop only; the branch loop continues so every branch is still evaluated. Error formatting is still skipped for post-cap branches, so the cap keeps doing the work it was added for. Error output is unchanged: 20 collected errors plus exactly one TooManyErrorsError marker, no matter how many branches follow. Closes duyler#54
shadowhand
force-pushed
the
fix/54-composition-error-cap-branch-iteration
branch
from
August 11, 2026 14:07
3a4bde2 to
8edf71a
Compare
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.
Closes #54.
The bug
AbstractCompositionalValidator::validateSchemas()stops collecting errors onceMAX_COMPOSITION_ERRORS(20) is reached — but it does so with areturn, whichleaves the outer
foreach ($schemas ...)loop too. Every branch after the capgoes unevaluated, so
$validCountcan never be incremented again.AnyOfValidatorthen throws "At least one of the schemas must match, but none did" even though a
later branch matches;
OneOfValidatorcan likewise under-count matches.An error cap silently became a branch cap, making the outcome depend on branch
declaration order — which
anyOfsemantics forbid.Before / after
Running the reproduction from #54 verbatim (a JSON:API-shaped
anyOfwhose firstbranches are noisy
allOfs of atypeenum plus anadditionalProperties: falseattribute object, and whose last branch matches):
Before
After
Identical branch sets, different order — now the same result either way.
The fix
Six lines. Track that the cap was reached, stop appending, and keep looping:
The
$cappedguard sits after the$outcome->matchedshort-circuit and beforeboth error loops, so post-cap branches are still validated but skip all error
accumulation and formatting — the cap keeps doing the work it was added for. Only
the branch loop continues.
Tests
Written first, and confirmed failing against the unfixed validator.
tests/Unit/Validator/SchemaValidator/CompositionBranchOrderIndependenceTest.phpanyOfpasses with the matching branch last and first — same branch set, bothorders. A test that checked only one ordering would pass today for the wrong reason.
oneOfcounts a match declared after the cap is reached.oneOfstill enforces exactly-one: a value matching two branches fails even with20+ errors collected from the branches ahead of them.
allOfstill fails when a branch after the cap fails, and still passes when everybranch matches. The assertion pins the exact message (
but 2 failed), which alsodocuments that post-cap branches are evaluated but not collected.
TooManyErrorsErrormarker —asserted by count so the cap cannot be quietly removed.
TooManyErrorsErroracross a 10-branch composition.tests/Functional/Schema/CompositionBranchOrderTest.phpdrives the real-world triggerthrough the public builder: an
anyOfinside anallOfinside arrayitems, i.e. aJSON:API
includedarray. Both orderings pass; the no-match case still reports 21errors with one marker.
Each ordering test places a failing branch between the cap-tripping branch and the
matching one. Without that, a matching branch short-circuits on
$outcome->matchedbefore the cap is ever consulted, and swapping the new
continuefor abreakwouldgo unnoticed. Verified by hand: applying that mutation fails 4 of the new tests.
Verification
make tests— 7141 tests, 14731 assertions, 0 failures. (The 2 reported deprecationsare pre-existing, in unrelated files.)
make psalm— No errors found.make cs-fix— Fixed 0 of 879 files.make rector— OK.make infectionscoped to the changed file — MSI 83.72%, up from 74.36% onmain. The two mutants still escaping are pre-existing ones innormalizeForBranch,untouched by this change.
Notes
src/. 4e5baf8 states that after the §12 cleanup"all inline
//comments are resolved: only the machine-greppable ADR exemptionmarker remains", so a comment here would have been the only one in
src/Validator/SchemaValidator/. The$cappedname plus the regression tests andthe CHANGELOG entry carry the rationale instead. Happy to add one if you'd prefer it
called out in the source.
AllOfValidatoroutput is byte-identical when the cap is hit: the branches whoseerrors are now skipped were never reached before either. The one behavioural
difference is that a matching branch declared after the cap now merges its child
annotations into the parent context, which
unevaluatedProperties/unevaluatedItemswant anyway.
the fix — you cannot know whether a branch matches without running it.