Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,81 @@ seconds instead of the full run's minute — and the full run stays the gate.
other class failed with "there is no such class"; the same mistake reached
review once already with `Fixture\Cls\Stamp`.

- **Inline forwarding enters a real body by reflection, not by `parent::`.**
`Runtime::runInline()` calls `ReflectionMethod::invokeArgs()` on a
reflection of the *doubled* class, which enters that class's body even
though the double overrides the method, while `$this` stays the double so
the body's self-calls dispatch virtually and come back through the
override. The alternative — a generated `__understudyReal_*()` sibling
calling `parent::`, which is what jasonmccreary/double does — is closed to
us by golden rule 3, and a per-call `fn () => parent::m(...)` closure
handed to the dispatcher was built, measured and reverted: it cost **+10%
per call on every class double**, forwarding or not, because the argument
is evaluated before dispatch can decide it is not needed (`perf/` doubles
only interfaces, so `make perf` cannot see this — the measurement was a
loop over a loose class double). The language claim is pinned by
`spikes/08-inline-forwarding` on 8.3/8.4/8.5. The reflections are cached in
a static keyed by `Class::method`, and `reset()` does not clear it: it is
metadata about a type, not state of a double.
- **Inline forwarding needs the instance to be exactly the doubled class, and
the state copy decides when it is on.** `get_parent_class($double) ===
$real::class` is the whole test: an interface double has no parent, and a
donor that is a *subclass* carries overrides the doubled class's bodies do
not contain — running those instead would silently change which code
executes, so it keeps the proxy form. `DoubleState::$inlineParent` is set
only after `Runtime::copyState()` succeeded, because a body that runs
inline reads `$this->` properties the skipped constructor never filled.
Re-pointing an inline double at another instance is refused rather than
copied twice: a `readonly` property cannot be written again, and a second
copy would discard what the real code has written since.
- **A state copy reflects each property against its DECLARING class.** Before
PHP 8.4 a `readonly` property can only be initialized from the scope of the
class that declares it, and a `ReflectionProperty` reached through the
subclass writes from the subclass's scope. The loop therefore walks the
donor's hierarchy and skips what the current class did not declare. A
hooked property is copied through `setRawValue()`/`getRawValue()` (reading
through the hooks would execute the donor's code during setup, writing
through them would re-run a `set` the real object already ran), a virtual
one has no state to copy, and a property the donor left uninitialized is
`unset()` on the double — which is enough from outside, because the only
thing that writes a property on a constructor-less double is
`PropertyDefaults`, and it writes public writable ones and nothing else.
- **A clone made while a real body runs inherits that double's forwarding;
an object no context knows is refused.** Both shapes satisfy the override's
`: static` — they are of the generated class — so the language stops
refusing what the proxy path refused as a foreign instance, and the two
need opposite answers. `clone $this` is adopted in `Runtime::adoptClone()`
from `Runtime::$inlineFrames`, the stack of doubles whose bodies are
running: PHP already copied the state those bodies read, and without the
frame a `: self` wither — or a body merely computing over an internal copy,
which proxied worked — would hold a double answering defaults in the middle
of real code. Expectations are still not copied, and a `clone $double` the
test makes is untouched — whether no body is running at all, or the test's
own `answers()` closure was reached from one, which `Runtime::$inlineFloor`
hides the frames underneath for exactly the span of. The frame alone is a
guess, because one generated class serves every double of a type: a body
handed another double of its own class — `merge(Basket $other)` — and cloning
THAT would adopt it on the running double's behalf, with bodies to run and
no state copy to run them over, which is a bare `must not be accessed before
initialization` where the property is typed and a quiet wrong answer where
it has a declared default. `Runtime::isCopyOf()` therefore requires the copy
to hold the frame's own state, and the comparison is
`get_mangled_object_vars()` on both — the object's own property table, which
is the only reading that reaches a parent's private properties, answers a
hooked property with its backing store rather than running the doubled
class's `get` in the middle of `__clone()`, has no entry for a virtual one,
and omits a property nobody initialized, so "never set" differs from every
value. The copy is property-identical to its source at this moment, because
a generated `__clone()` does nothing but call `adoptClone()`. Two doubles in
the same state stay indistinguishable — the copy is adopted and runs real
bodies where a plain double answered defaults — and that is the residual of
PHP's blind spot, not a thing the comparison can close: it runs them over
the state the copy already holds, so no reading is of a value the object
never had, it is the other double's. `new static` cannot be adopted — its constructor built a new object
with its own state — so it is refused by
`OriginalReturnTypeViolation::foreignSelf()`, and the check is registration
(`ownerOf() === null`), not return type: an unknown instance is broken for
every caller, not only for one whose signature says `static`.
- **Forwarding is not gated on `$matched`, and strictness is.** Strict mode is a
complaint that a matched expectation answers; forwarding is the mode's own
answer, and an expectation that only counts the call —
Expand Down
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,41 @@

## 1.1.0 — Unreleased

- **Changed.** A forwarding double of a **class** now runs the real bodies on
itself instead of proxying them to the instance: the state that instance held
is copied onto the double when `forwarding()` is called, and an unmatched
call executes the parent implementation with `$this` still the double. A
self-call inside real code therefore re-enters the double — it meets stubs,
an armed protocol and the call log — which is what `makePartial()` ports
needed and what the standing limitation ("understudy proxies an object; it
does not instrument one") described. An interface double has no bodies to
reach and proxies exactly as before. Five consequences in a suite that
already forwards class doubles: a self-call now counts toward
`expect(...)->times(n)` and can fill a step of an armed protocol;
`callOriginal()` on such a double runs on the double rather than on the
instance, over the state the double holds; `static::class` inside a real
body names the generated class; a real body that hands out a `new static`
is refused with `OriginalReturnTypeViolation` (an object no context knows,
where a `clone $this` is adopted while the body runs and comes back
working); and the instance stops receiving the calls, so a test asserting
on it afterwards sees the state as of the moment `forwarding()` ran. That
copy is shallow, which is the same reach the instance had before: an
object-valued property is the same object on both, so writes *through* it
still land on the donor's collaborators, and a closure the constructor
bound to the donor still runs with `$this` on the donor. Inline needs the instance to be exactly
the doubled class — a subclass instance keeps the proxy form, so its own
overrides answer — and an inline double cannot be re-pointed at a second
instance, because a `readonly` property cannot be copied onto it twice
(`ForwardingTargetMismatch`). Adopting a clone asks for a copy of the double
whose body is running, holding its state: one generated class serves every
double of a type, so a body that clones another double it was handed — and a
`clone $double` the test writes in its own `answers()` closure, which a real
body can reach — gets a fresh double of that class, the documented rule for
`clone $double`, rather than real bodies over a state nothing copied. Two
doubles of one class in the same state are indistinguishable, because
`__clone()` is handed no reference to its source: a copy of one of those is
adopted and answers for real, over the state it already holds. No name,
signature or readonly field of the public API changed. (#150)
- **Changed.** Dispatch between overlapping specifications 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
Expand Down
2 changes: 1 addition & 1 deletion MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ here, and the sections after it cover what a mechanical translation gets wrong.
| `$mock->shouldNotHaveReceived('save')` | `Understudy::unused($mock)` | or `verify(…, never: true)` for one call |
| `$mock->shouldHaveReceived('save')` | `verify(fn () => $mock->save(…))` | after the fact |
| `->ordered()` | `expect(…)->ordered()` | or `verifySequence()` / `expectSequence()` for a whole protocol |
| `->makePartial()` / `Mockery::spy($real)` | `Understudy::delegate(Contract::class, $real)` + stubs on top | a stub wins, everything else runs for real — and is recorded |
| `->makePartial()` / `Mockery::spy($real)` | `Understudy::delegate(Contract::class, $real)` + stubs on top | a stub wins, everything else runs for real — and is recorded. Pass the **class** to get what `makePartial()` does with a self-call: the bodies run on the double, so `$this->other()` meets your stub. An interface there proxies instead |
| `Mockery::close()` | the adapter's `reset()`, or your own teardown | |

## The traps
Expand Down
58 changes: 48 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -891,11 +891,33 @@ when(fn () => $spy->get('key'))
->answers(fn (Invocation $call) => strtoupper((string) $call->callOriginal()));
```

Five things are worth knowing before relying on it:

- **Only the call at the boundary is recorded.** If the real method calls
another method on itself, that happens inside the real object. Understudy
proxies an object; it does not instrument one.
Eight things are worth knowing before relying on it:

- **A class double runs the real bodies on itself.** The state the real
instance held is copied onto the double when `forwarding()` is called, and an
unmatched call executes the parent implementation with `$this` still the
double — so a self-call inside the real code re-enters the double: it meets
stubs, an armed protocol and the call log, and every call is recorded, the
boundary one and the self-calls alike. Two consequences to hold on to:
`static::class` inside a real body names the generated class rather than
yours, and a `final` or `private` method is not overridden, so a self-call to
one of those runs the real body directly — it works, but nothing intercepts
or records it. The instance itself stops receiving the calls: after
`forwarding()` it holds the state it had at that moment, and assertions
belong on the double. The copy is shallow, which is the reach the instance
already had — an object-valued property is the same object on both sides, so
writes *through* one still land on the donor's collaborators.
- **An interface double proxies instead.** It has no bodies to reach: the call
runs on the real instance, only the call at the boundary is recorded, and
self-calls happen inside the real object where nothing can see them.
- **Running inline needs the instance to be exactly the doubled class.** A
donor that is a *subclass* carries overrides the doubled class's own bodies
do not contain, and running those bodies in their place would change which
code executes — such a double keeps the proxy form, where the overrides answer.
An inline double also cannot be re-pointed at a second instance: its state
was copied from the first, a `readonly` property cannot be written twice, and
a second copy would discard everything the real code has written since. Build
a second double for the other instance.
- **A `: never` method reaches the real implementation.** Its throw lives
there, and a forwarding double has something that can answer for itself.
- **An understudy is not a valid target.** Forwarding to one — itself included
Expand All @@ -904,11 +926,27 @@ Five things are worth knowing before relying on it:
- **A by-reference argument is the caller's variable.** A forwarded method
writes to it, and the call log keeps both readings — what was passed and what
it became — so a verification still sees the value the caller handed over.
- **A fluent method comes back as the double.** When the real instance returns
itself, the double is returned instead, so a chain stays doubled. A `static`
method that returns a *different* instance of the real class is refused —
that object is not a double, and returning it would break the override's own
`: static`.
- **A fluent method comes back as the double.** When the real code returns
`$this`, that already *is* the double — the parent body ran on it — so a
chain stays doubled by itself. A `clone $this` works too: the copy is
adopted while the body runs and inherits this double's forwarding, so a
wither comes back as a working double — carrying none of this one's stubs,
the same rule a `clone $double` in the test follows. Adoption asks for a
copy of *this* double, holding its state: a body that clones another double
it was handed gets a fresh double of the right class instead, and so does a
`clone $double` written in your own `answers()` closure, even when a real
body is what reached it. Two doubles of one class in the same state cannot
be told apart — `__clone()` is given no reference to what it copied — so a
copy of one of those is adopted and answers for real; it does so over the
state it already holds, which is the other double's. What is refused, with
`OriginalReturnTypeViolation`, is a real body handing out a `new static`:
that object is of the generated class, so the return type accepts it, but no
context knows it and every call on it would fail as an unregistered
double — under the proxy the same shape was refused as a foreign instance.
- **`callOriginal()` follows the double's own form.** On an inline double it
runs the parent body on the double, over the state the double holds — the
donor's copy stopped being the current one at the first inline call. On every
other double it runs on the real instance, where self-calls stay inside it.

### Failure messages

Expand Down
Loading
Loading