Skip to content

Dispatch tiers: a specific expectation answers before a catch-all, whichever order was declared - #156

Merged
rasuvaeff merged 2 commits into
masterfrom
feat/dispatch-tiers
Oct 11, 2026
Merged

rasuvaeff merged 2 commits into
masterfrom
feat/dispatch-tiers

Conversation

@rasuvaeff

Copy link
Copy Markdown
Owner

Fixes #152

What

Between overlapping specifications, dispatch now runs in two tiers: one that pins at least one argument to something narrower than "anything" — a literal, any matcher except Arg::any(), or a tail pinned empty by Arg::none() — is tried before one that says nothing about any argument (Arg::any() in every position, an unspelled optional parameter, Arg::rest()/remaining()), regardless of registration order. Within a tier, most-recently-registered still wins; two specifics are never compared against each other for narrowness (double PR #19's rule, applied to our richer matcher set).

The trap from #136 can no longer be written: a catch-all registered after a specific stub used to take every call, leaving the specific one unreachable and undiagnosed — not even strictStubs reported it. Now the specific one answers its own calls and the catch-all takes the rest. Tiering makes the ordering mistake impossible rather than diagnosed.

Minor, considered — the compatibility policy quoted

The policy (README.md → Compatibility policy) freezes "the names, signatures, and readonly fields of the public API stable" across 1.x; none of those change here. What changes is the documented layering semantics: a suite that relied on a catch-all overriding a narrower stub declared earlier now sees the narrow one answer. That reliance means the specific stub was unreachable dead configuration answering nothing — a bug in the test, not a contract. CHANGELOG carries it as Changed, prominently. Shipping as part of the declared 1.1.0 minor boundary.

The classification

Precomputed once per expectation (Expectation::isGeneric(), a boolean read per candidate at dispatch — the issue's "one extra boolean per candidate at most"): AnyArgument, Unspelled (an optional parameter the specification left unspelled), and the AnyTail/AnyRest/UnspelledTail family say nothing; a literal, any narrowing matcher, and EmptyTail (Arg::none() pins the tail empty) pin.

Applied exactly where the issue points: DoubleState::expectationsFor() merges the literal-indexed and walked lists through one (tier asc, declaration desc) sort. hasActionFor() — the by-reference slot walk — now passes $args through, so it walks candidates in the same tier order dispatch does; the "same walk" invariant extends to tiers, and a test pins it.

Perf — the A/B the issue asks for

Full harness, three runs per side, same pinning, one sitting (master 4715d3c-based a05d04a vs this branch):

table understudy Mockery Prophecy
stub 1 call −2.8% −2.7% −3.9%
stub 20 calls +3.1% +0.8% −0.2%
mock+verify −0.4% +0.1% +0.0%
argument matchers +0.5% −1.8% −1.4%
create 1 method +0.7% +0.6% −0.8%
create 8 methods −2.6% −1.1% −1.3%

Understudy moved −2.8% to +3.1% while the competitors moved −3.9% to +0.8% in the same runs — inside the noise floor the recorded methodology documents (medians move 0.2–3.5% between runs). Nothing attributable; the recorded figures in perf/README.md and both READMEs stay. The measured candidate even carried the since-removed array_reverse calls.

Notes for reviewers

  • The property oracles encoded the old law and now state the new one: theAnsweringStubIsTheLastMatchingOne → theMostSpecificMatchingStubAnswers (tier-aware oracle + new examples), theIndexAnswersWhatALinearWalkWouldHave → theIndexAnswersWhatATieredWalkWouldHave, and the lifecycle state machine's EngineState model answers by tier.
  • mutation-diff: 33 mutants, zero escaped after restructuring (the equivalent array_reverse/guard mutants died by simplification — the tier sort subsumes the reverse).
  • Docs: README + README.ru.md + llms.txt + the site guide (stubbing/index.md, migrating-from-phpunit.md, regenerated MIGRATION.md); the "register the broad stub first" advice is gone from everywhere it lived. docs-claims green (66 claims).

@coderabbitai

coderabbitai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: bfbe2407-7e92-44a8-850d-c782ae36cda2

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@rasuvaeff
rasuvaeff merged commit c4d60e5 into master Oct 11, 2026
25 checks passed
@rasuvaeff
rasuvaeff deleted the feat/dispatch-tiers branch October 11, 2026 08:25
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.

Dispatch falls back to registration order between overlapping stubs — consider specificity tiers (follow-up to #136, double PR #19)

1 participant