Skip to content

fix: Cap composition error collection, not branch iteration (#54) - #55

Open
shadowhand wants to merge 1 commit into
duyler:mainfrom
shadowhand:fix/54-composition-error-cap-branch-iteration
Open

fix: Cap composition error collection, not branch iteration (#54)#55
shadowhand wants to merge 1 commit into
duyler:mainfrom
shadowhand:fix/54-composition-error-cap-branch-iteration

Conversation

@shadowhand

Copy link
Copy Markdown

Closes #54.

The bug

AbstractCompositionalValidator::validateSchemas() stops collecting errors once
MAX_COMPOSITION_ERRORS (20) is reached — but it does so with a return, which
leaves the outer foreach ($schemas ...) loop too. Every branch after the cap
goes unevaluated, so $validCount can never be incremented again. AnyOfValidator
then throws "At least one of the schemas must match, but none did" even though a
later branch matches; OneOfValidator can likewise under-count matches.

An error cap silently became a branch cap, making the outcome depend on branch
declaration order — which anyOf semantics forbid.

Before / after

Running the reproduction from #54 verbatim (a JSON:API-shaped anyOf whose first
branches are noisy allOfs of a type enum plus an additionalProperties: false
attribute object, and whose last branch matches):

Before

MatchingFirst  => PASS
MatchingLast   => FAIL  At least one of the schemas must match, but none did (21 errors)

After

MatchingFirst  => PASS
MatchingLast   => PASS

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:

+            if ($capped) {
+                continue;
+            }
+
             foreach ($outcome->errors as $error) {
...
-                    return new ValidationResult($validCount, $errors, $abstractErrors);
+                    $capped = true;
+                    break;

The $capped guard sits after the $outcome->matched short-circuit and before
both 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.php

  • anyOf passes with the matching branch last and first — same branch set, both
    orders. A test that checked only one ordering would pass today for the wrong reason.
  • oneOf counts a match declared after the cap is reached.
  • oneOf still enforces exactly-one: a value matching two branches fails even with
    20+ errors collected from the branches ahead of them.
  • allOf still fails when a branch after the cap fails, and still passes when every
    branch matches. The assertion pins the exact message (but 2 failed), which also
    documents that post-cap branches are evaluated but not collected.
  • Unmatched data reports exactly 21 errors — 20 plus the TooManyErrorsError marker —
    asserted by count so the cap cannot be quietly removed.
  • Exactly one TooManyErrorsError across a 10-branch composition.

tests/Functional/Schema/CompositionBranchOrderTest.php drives the real-world trigger
through the public builder: an anyOf inside an allOf inside array items, i.e. a
JSON:API included array. Both orderings pass; the no-match case still reports 21
errors 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->matched
before the cap is ever consulted, and swapping the new continue for a break would
go 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 deprecations
    are pre-existing, in unrelated files.)
  • make psalm — No errors found.
  • make cs-fix — Fixed 0 of 879 files.
  • make rector — OK.
  • make infection scoped to the changed file — MSI 83.72%, up from 74.36% on
    main. The two mutants still escaping are pre-existing ones in normalizeForBranch,
    untouched by this change.

Notes

  • No inline comment was added to src/. 4e5baf8 states that after the §12 cleanup
    "all inline // comments are resolved: only the machine-greppable ADR exemption
    marker remains", so a comment here would have been the only one in
    src/Validator/SchemaValidator/. The $capped name plus the regression tests and
    the CHANGELOG entry carry the rationale instead. Happy to add one if you'd prefer it
    called out in the source.
  • AllOfValidator output is byte-identical when the cap is hit: the branches whose
    errors 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/unevaluatedItems
    want anyway.
  • Cost: post-cap branches are now validated rather than skipped. That is inherent to
    the fix — you cannot know whether a branch matches without running it.

Comment thread CHANGELOG.md Outdated
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)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
shadowhand force-pushed the fix/54-composition-error-cap-branch-iteration branch from 3a4bde2 to 8edf71a Compare August 11, 2026 14:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

anyOf/oneOf false negative: the 20-error cap aborts branch iteration

1 participant