From 7dfe068b0db813731cb86b48ffd31cb6230ab5c5 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Mon, 12 Oct 2026 22:52:38 +0300 Subject: [PATCH 1/4] Inline forwarding: a class double runs the real bodies on itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A forwarding double of a class no longer proxies the real bodies to the instance it was given: the state that instance held is copied onto the double, and an unmatched call runs the doubled class's own body 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 a `makePartial()` port needs 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. The body is entered through `ReflectionMethod::invokeArgs()` on a reflection of the doubled class, not through a generated `parent::` closure. The closure variant was built and measured first: handed to the dispatcher as an argument of every generated method, 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 — and `perf/` doubles only interfaces, so the harness cannot see it. With the reflection the generated code is byte-identical to master. A generated `__understudyReal_*()` sibling is the third way, and it is closed by golden rule 3. The language claim — non-virtual entry, virtual self-calls, protected bodies, by-reference parameters, `$this` identity — is pinned by spikes/08-inline-forwarding on 8.3, 8.4 and 8.5. Decisions behind the shape: - Inline needs the instance to be exactly the doubled class. A donor that is a subclass carries overrides the doubled class's bodies do not contain, and running those instead would silently change which code executes, so it keeps the proxy form. - `callOriginal()` follows the double's own form, because after the copy the donor's state is no longer the current one. - A clone made while a real body runs inherits that double's forwarding: without it a `: self` wither, or a body merely computing over an internal copy, would hold a double answering defaults in the middle of real code. Expectations are still not copied, and a clone the test makes is untouched. - A `new static` cannot be adopted — its constructor built its own state — so it is refused by registration rather than by return type: an instance no context knows is broken for every caller. - Re-pointing an inline double at another instance is refused. A `readonly` property cannot be written twice, and a second copy would discard what the real code has written since. Measured: mutation 3573 mutants / 93% MSI (gate 92), against 3481 / 93% on master; the class-double dispatch loop moves -3.4%, -4.8%, +3.4% across three paired runs, and `perf`/`perf-memory` are unchanged. Suites green on 8.3, 8.4 and 8.5. --- AGENTS.md | 56 ++ CHANGELOG.md | 27 + MIGRATION.md | 2 +- README.md | 51 +- README.ru.md | 53 +- docs/scripts/api-snapshot.json | 556 ++++++++++++------ docs/scripts/check-integrity.mjs | 2 +- docs/src/api/classes/Arg.md | 2 +- docs/src/api/classes/Captor.md | 2 +- .../Exception/AmbiguousDefaultFactory.md | 2 +- .../classes/Exception/BypassUnavailable.md | 2 +- docs/src/api/classes/Exception/CannotWire.md | 2 +- .../Exception/ConflictingExpectation.md | 2 +- .../Exception/ContextOwnershipViolation.md | 2 +- .../api/classes/Exception/ForgottenDouble.md | 2 +- .../Exception/ForwardingTargetMismatch.md | 25 +- .../Exception/InvalidCallSpecification.md | 2 +- .../classes/Exception/InvalidDefaultValue.md | 2 +- .../Exception/InvalidSpecificationArgument.md | 2 +- .../api/classes/Exception/MatcherLeaked.md | 2 +- .../classes/Exception/NeverMethodCalled.md | 2 +- .../api/classes/Exception/NoDefaultValue.md | 2 +- .../api/classes/Exception/NothingCaptured.md | 2 +- .../Exception/OriginalCallUnavailable.md | 2 +- .../Exception/OriginalReturnTypeViolation.md | 23 +- .../classes/Exception/OutcomeUnavailable.md | 2 +- .../classes/Exception/StrictModeViolation.md | 2 +- .../api/classes/Exception/UnderstudyError.md | 2 +- .../classes/Exception/UnsupportedTarget.md | 2 +- .../classes/Exception/VerificationFailed.md | 2 +- docs/src/api/classes/ExpectBuilder.md | 2 +- docs/src/api/classes/FailureKind.md | 2 +- docs/src/api/classes/Invocation.md | 17 +- docs/src/api/classes/Understudy.md | 37 +- docs/src/api/classes/VerificationFailure.md | 2 +- docs/src/api/classes/WhenBuilder.md | 2 +- docs/src/guide/forwarding.md | 49 +- docs/src/guide/migrating-from-mockery.md | 2 +- llms.txt | 25 +- spikes/08-inline-forwarding/spike.php | 138 +++++ spikes/run.sh | 4 +- src/Exception/ForwardingTargetMismatch.php | 27 + src/Exception/OriginalReturnTypeViolation.php | 27 + src/Invocation.php | 15 +- src/Runtime/DoubleState.php | 42 ++ src/Runtime/Runtime.php | 207 ++++++- src/Understudy.php | 63 +- tests/Fixture/Fwd/BasePipeline.php | 21 + tests/Fixture/Fwd/OtherPipeline.php | 18 + tests/Fixture/Fwd/Pipeline.php | 130 ++++ tests/Fixture/Fwd/SpecialPipeline.php | 19 + tests/ForwardingTest.php | 8 +- tests/InlineForwardingTest.php | 486 +++++++++++++++ 53 files changed, 1891 insertions(+), 289 deletions(-) create mode 100644 spikes/08-inline-forwarding/spike.php create mode 100644 tests/Fixture/Fwd/BasePipeline.php create mode 100644 tests/Fixture/Fwd/OtherPipeline.php create mode 100644 tests/Fixture/Fwd/Pipeline.php create mode 100644 tests/Fixture/Fwd/SpecialPipeline.php create mode 100644 tests/InlineForwardingTest.php diff --git a/AGENTS.md b/AGENTS.md index aa8aa49..afef1a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,6 +273,62 @@ 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 because no body is running then; a body cloning a + *different* double of the same class would be misattributed, which is the + same blind spot `__clone()` has always had. `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 — diff --git a/CHANGELOG.md b/CHANGELOG.md index 9390c15..dcf7713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ ## 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`). 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 diff --git a/MIGRATION.md b/MIGRATION.md index 079c9ae..529868c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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 diff --git a/README.md b/README.md index ff5f807..221199e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -904,11 +926,20 @@ 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. 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 diff --git a/README.ru.md b/README.ru.md index e8bcc72..5d0841d 100644 --- a/README.ru.md +++ b/README.ru.md @@ -887,11 +887,34 @@ when(fn () => $spy->get('key')) ->answers(fn (Invocation $call) => strtoupper((string) $call->callOriginal())); ``` -Пять вещей, которые стоит знать заранее: - -- **Записывается только вызов на границе.** Если реальный метод зовёт другой - метод на себе, это происходит внутри реального объекта. Understudy проксирует - объект, а не инструментирует его. +Восемь вещей, которые стоит знать заранее: + +- **Дубль класса выполняет реальные тела на самом себе.** Состояние, которое + держал реальный инстанс, копируется на дубль в момент `forwarding()`, и + несовпавший вызов исполняет реализацию родителя, оставляя `$this` дублем — + поэтому self-call внутри реального кода снова входит в дубль: встречает + стабы, вооружённый протокол и журнал вызовов, и записывается каждый вызов — + и граничный, и вложенные. Два следствия: `static::class` внутри реального + тела называет сгенерированный класс, а не ваш, а методы `final` и `private` + не переопределяются — self-call в такой метод выполняет реальное тело + напрямую: работает, но ничего его не перехватывает и не записывает. Сам + инстанс больше не получает вызовов: после `forwarding()` он держит то + состояние, которое было на тот момент, и проверять нужно дубль. Копия + поверхностная — ровно та же досягаемость, что была у инстанса: свойство с + объектом — один и тот же объект с обеих сторон, так что записи *через* него + по-прежнему попадают в коллабораторов донора. +- **Дубль интерфейса вместо этого проксирует.** Ему не до каких тел + дотягиваться: вызов исполняется на реальном инстансе, записывается только + вызов на границе, а self-call'ы происходят внутри реального объекта, где их + никто не видит. +- **Для инлайна инстанс должен быть ровно тем классом, который дублируется.** + Инстанс-*наследник* несёт переопределения, которых в телах самого + дублируемого класса нет, и исполнение этих тел вместо них сменило бы + исполняемый код — такой дубль остаётся прокси, где отвечают его + переопределения. Инлайновый дубль ещё и нельзя перенаправить на второй + инстанс: его состояние скопировано с первого, `readonly`-свойство нельзя + записать дважды, а вторая копия выбросила бы всё, что реальный код записал с + тех пор. Для другого инстанса — другой дубль. - **Метод с `: never` доходит до реальной реализации.** Бросок живёт именно там, а у forwarding-дубля есть кому ответить за себя. - **Дубль не может быть целью.** Делегирование дублю — в том числе самому себе — @@ -900,10 +923,22 @@ when(fn () => $spy->get('key')) - **Аргумент по ссылке — это переменная вызывающего.** Форвардящийся метод в неё пишет, и журнал хранит оба чтения — что передали и чем оно стало, — так что проверка по-прежнему видит значение, которое отдал вызывающий. -- **Fluent-метод возвращается дублем.** Когда реальный инстанс вернул сам себя, - наружу уходит дубль — цепочка остаётся дублированной. Метод с `static`, - вернувший *другой* экземпляр реального класса, отвергается: этот объект не - дубль, и вернуть его значило бы нарушить собственный `: static` переопределения. +- **Fluent-метод возвращается дублем.** Когда реальный код возвращает `$this`, + это и *есть* дубль — тело родителя исполнилось на нём, — так что цепочка + остаётся дублированной сама собой. `clone $this` тоже работает: копия + принимается в момент исполнения тела и наследует форвардинг этого дубля — + wither возвращается рабочим дублем, но без стабов этого дубля, по тому же + правилу, что и `clone $double` в тесте. Отвергается — + `OriginalReturnTypeViolation` — реальное тело, отдающее наружу `new static`: + этот объект сгенерированного класса, тип возврата его принимает, но ни один + контекст его не знает, и любой вызов на нём упал бы как на + незарегистрированном дубле; под прокси ровно эта форма отвергалась как чужой + экземпляр. +- **`callOriginal()` следует форме самого дубля.** На инлайновом дубле он + исполняет тело родителя на дубле, над состоянием, которое дубль держит, — + копия донора перестала быть текущей на первом же инлайновом вызове. На + любом другом дубле вызов идёт на реальный инстанс, где self-call'ы остаются + внутри него. ### Сообщения об ошибках diff --git a/docs/scripts/api-snapshot.json b/docs/scripts/api-snapshot.json index 7f0dc0a..58e3e4b 100644 --- a/docs/scripts/api-snapshot.json +++ b/docs/scripts/api-snapshot.json @@ -2,7 +2,7 @@ "classes": [ { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Arg", "kind": "class", @@ -437,7 +437,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Bypass\\FileWrapper", "kind": "class", @@ -1099,7 +1099,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Bypass\\FinalStripper", "kind": "class", @@ -1159,7 +1159,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Captor", "kind": "class", @@ -1287,7 +1287,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Cardinality", "kind": "class", @@ -1480,7 +1480,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\Blueprint", "kind": "class", @@ -1617,7 +1617,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\DoubleFactory", "kind": "class", @@ -1746,7 +1746,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\MethodSignature", "kind": "class", @@ -1940,7 +1940,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\PropertyDefaults", "kind": "class", @@ -1993,7 +1993,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\PropertySignature", "kind": "class", @@ -2083,7 +2083,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\TargetUnifier", "kind": "class", @@ -2136,7 +2136,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\TypeRenderer", "kind": "class", @@ -2256,7 +2256,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Defaults\\DefaultFactories", "kind": "class", @@ -2361,7 +2361,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Defaults\\TypeDefaultResolver", "kind": "class", @@ -2500,7 +2500,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\AmbiguousDefaultFactory", "kind": "class", @@ -2560,7 +2560,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\BypassUnavailable", "kind": "class", @@ -2666,7 +2666,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\CannotWire", "kind": "class", @@ -2911,7 +2911,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ConflictingExpectation", "kind": "class", @@ -3031,7 +3031,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ContextOwnershipViolation", "kind": "class", @@ -3076,7 +3076,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ForgottenDouble", "kind": "class", @@ -3236,7 +3236,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ForwardingTargetMismatch", "kind": "class", @@ -3317,6 +3317,43 @@ "deprecated": null, "attributes": [], "startLine": 36 + }, + { + "name": "alreadyInline", + "static": true, + "params": [ + { + "name": "label", + "type": "non-empty-string", + "description": "the double's own name in reports", + "default": null, + "variadic": false + }, + { + "name": "current", + "type": "class-string", + "description": "the class whose bodies the double already runs", + "default": null, + "variadic": false + }, + { + "name": "given", + "type": "class-string", + "description": "the class of the instance offered instead", + "default": null, + "variadic": false + } + ], + "returnType": "Rasuvaeff\\Understudy\\Exception\\ForwardingTargetMismatch", + "summary": "Builds the error for re-pointing a double that already forwards inline.", + "description": "The state of the first donor was copied onto the double, and a copy\ncannot be taken twice: a `readonly` property is initialized, and\nwhatever the inline calls have written since would be overwritten by a\nsecond donor's reading. Re-pointing is refused rather than silently\nleaving the double running the first donor's state against the second\none's instance.", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 62 } ], "constants": [], @@ -3326,7 +3363,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\InvalidCallSpecification", "kind": "class", @@ -3911,7 +3948,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\InvalidDefaultValue", "kind": "class", @@ -3971,7 +4008,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\InvalidSpecificationArgument", "kind": "class", @@ -4240,7 +4277,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\MatcherLeaked", "kind": "class", @@ -4307,7 +4344,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\NeverMethodCalled", "kind": "class", @@ -4427,7 +4464,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\NoDefaultValue", "kind": "class", @@ -4494,7 +4531,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\NothingCaptured", "kind": "class", @@ -4547,7 +4584,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\OriginalCallUnavailable", "kind": "class", @@ -4630,7 +4667,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\OriginalReturnTypeViolation", "kind": "class", @@ -4688,6 +4725,36 @@ "deprecated": null, "attributes": [], "startLine": 20 + }, + { + "name": "foreignSelf", + "static": true, + "params": [ + { + "name": "label", + "type": "non-empty-string", + "description": "the double's own name in reports", + "default": null, + "variadic": false + }, + { + "name": "method", + "type": "non-empty-string", + "description": "the method whose real body returned it", + "default": null, + "variadic": false + } + ], + "returnType": "Rasuvaeff\\Understudy\\Exception\\OriginalReturnTypeViolation", + "summary": "Builds the error for an inline call whose real body built a new instance\nof the double's own class — a `new static`.", + "description": "It is of the generated class, so it satisfies the override's `: static`\nand the language lets it through; no context knows it, so every call on\nit would fail as a double that was never registered. A `clone $this`\ndoes not reach here: the copy inherits the original's forwarding and\nanswers for real.", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 48 } ], "constants": [], @@ -4697,7 +4764,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\OutcomeUnavailable", "kind": "class", @@ -4780,7 +4847,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\StrictModeViolation", "kind": "class", @@ -4847,7 +4914,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\UnderstudyError", "kind": "interface", @@ -4896,7 +4963,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\UnsupportedTarget", "kind": "class", @@ -5016,7 +5083,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\VerificationFailed", "kind": "class", @@ -5103,7 +5170,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\ExpectBuilder", "kind": "class", @@ -5151,7 +5218,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\Action", "kind": "interface", @@ -5209,7 +5276,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ArgumentFormatter", "kind": "class", @@ -5292,7 +5359,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ComputeAnswer", "kind": "class", @@ -5359,7 +5426,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\Expectation", "kind": "class", @@ -5400,6 +5467,21 @@ ], "publicProperties": [], "publicMethods": [ + { + "name": "isGeneric", + "static": false, + "params": [], + "returnType": "bool", + "summary": "Whether no position of this expectation narrows the call — the tier it\ndispatches in. A generic expectation is tried only after every\nspecific one for the same method refused the call, whichever order\nthe two were declared in.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 143 + }, { "name": "hasCaptors", "static": false, @@ -5413,7 +5495,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 110 + "startLine": 148 }, { "name": "setGuard", @@ -5443,7 +5525,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 131 + "startLine": 169 }, { "name": "captureFrom", @@ -5466,7 +5548,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 174 + "startLine": 212 }, { "name": "setAction", @@ -5496,7 +5578,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 195 + "startLine": 233 }, { "name": "hasAction", @@ -5511,7 +5593,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 210 + "startLine": 248 }, { "name": "cardinality", @@ -5526,7 +5608,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 219 + "startLine": 257 }, { "name": "setCardinality", @@ -5549,7 +5631,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 224 + "startLine": 262 }, { "name": "declareClaim", @@ -5564,7 +5646,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 234 + "startLine": 272 }, { "name": "isClaim", @@ -5579,7 +5661,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 239 + "startLine": 277 }, { "name": "setDeclarationOrder", @@ -5602,7 +5684,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 250 + "startLine": 288 }, { "name": "declarationOrder", @@ -5617,7 +5699,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 258 + "startLine": 296 }, { "name": "matchedSequences", @@ -5632,7 +5714,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 266 + "startLine": 304 }, { "name": "requireOrder", @@ -5647,7 +5729,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 275 + "startLine": 313 }, { "name": "isOrdered", @@ -5662,7 +5744,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 280 + "startLine": 318 }, { "name": "matchCount", @@ -5677,7 +5759,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 288 + "startLine": 326 }, { "name": "matches", @@ -5707,7 +5789,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 297 + "startLine": 335 }, { "name": "matchesArguments", @@ -5730,7 +5812,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 312 + "startLine": 350 }, { "name": "specEquals", @@ -5753,7 +5835,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 377 + "startLine": 415 }, { "name": "recordMatch", @@ -5776,7 +5858,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 412 + "startLine": 450 }, { "name": "performAction", @@ -5799,7 +5881,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 426 + "startLine": 464 }, { "name": "describeAgainst", @@ -5822,7 +5904,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 450 + "startLine": 488 }, { "name": "describe", @@ -5837,17 +5919,17 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 547 + "startLine": 585 } ], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Expectation/Expectation.php#L21", + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Expectation/Expectation.php#L24", "implementedBy": [] }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ReturnContract", "kind": "class", @@ -5937,7 +6019,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ReturnValue", "kind": "class", @@ -6004,7 +6086,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ThrowComputed", "kind": "class", @@ -6071,7 +6153,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ThrowError", "kind": "class", @@ -6138,7 +6220,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\FailureKind", "kind": "enum", @@ -6227,7 +6309,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\FailureReport", "kind": "class", @@ -6412,7 +6494,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Invocation", "kind": "class", @@ -6588,8 +6670,8 @@ "static": false, "params": [], "returnType": "mixed", - "summary": "Delegates this call to the double's real instance and returns what it\nanswered — the `answers()` escape hatch for \"behave normally, except\nhere\".", - "description": "Without a forwarding target it raises `OriginalCallUnavailable` rather\nthan reaching for the parent implementation: the double's constructor\nnever ran, so the parent body would work over state that does not exist.", + "summary": "Runs the real implementation of this call and returns what it answered —\nthe `answers()` escape hatch for \"behave normally, except here\".", + "description": "Where that implementation runs follows the double's own form. A class\ndouble that forwards runs the real body on itself, over the state\n`forwarding()` copied there; every other double delegates to the real\ninstance it remembers, where the body's own self-calls stay inside that\nobject.\n\nWithout a forwarding target it raises `OriginalCallUnavailable` rather\nthan reaching for the real body anyway: nothing copied the state, so the\nbody would work over properties the skipped constructor never filled.", "throws": [ { "type": "\\Rasuvaeff\\Understudy\\Exception\\OriginalCallUnavailable", @@ -6601,7 +6683,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 140 + "startLine": 145 }, { "name": "recordOutcome", @@ -6624,7 +6706,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 163 + "startLine": 168 }, { "name": "recordReturned", @@ -6647,7 +6729,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 177 + "startLine": 182 }, { "name": "recordDiscardedReturn", @@ -6662,7 +6744,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 197 + "startLine": 202 }, { "name": "isReturnDiscarded", @@ -6677,7 +6759,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 212 + "startLine": 217 }, { "name": "recordThrown", @@ -6700,7 +6782,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 222 + "startLine": 227 }, { "name": "markAccounted", @@ -6715,7 +6797,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 240 + "startLine": 245 }, { "name": "isAccounted", @@ -6730,7 +6812,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 250 + "startLine": 255 }, { "name": "didReturn", @@ -6745,7 +6827,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 259 + "startLine": 264 }, { "name": "didThrow", @@ -6760,7 +6842,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 267 + "startLine": 272 }, { "name": "returned", @@ -6780,7 +6862,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 280 + "startLine": 285 }, { "name": "thrown", @@ -6795,7 +6877,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 302 + "startLine": 307 } ], "constants": [], @@ -6805,7 +6887,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AllOf", "kind": "class", @@ -6889,7 +6971,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyArgument", "kind": "class", @@ -6963,7 +7045,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyOf", "kind": "class", @@ -7047,7 +7129,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyRest", "kind": "class", @@ -7147,7 +7229,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyTail", "kind": "class", @@ -7247,7 +7329,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\ArgumentMatcher", "kind": "interface", @@ -7337,7 +7419,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\ArrayContaining", "kind": "class", @@ -7421,7 +7503,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\BooleanValue", "kind": "class", @@ -7495,7 +7577,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Bounds", "kind": "class", @@ -7555,7 +7637,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Capturing", "kind": "class", @@ -7648,7 +7730,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\CountBetween", "kind": "class", @@ -7741,7 +7823,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\EmptyTail", "kind": "class", @@ -7841,7 +7923,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\FloatInRange", "kind": "class", @@ -7934,7 +8016,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\IdenticalTo", "kind": "class", @@ -8018,7 +8100,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\InstanceOfType", "kind": "class", @@ -8102,7 +8184,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\IntInRange", "kind": "class", @@ -8195,7 +8277,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Negated", "kind": "class", @@ -8279,7 +8361,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Operand", "kind": "class", @@ -8362,7 +8444,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\QueryEquals", "kind": "class", @@ -8455,7 +8537,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Satisfying", "kind": "class", @@ -8548,7 +8630,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\StringMatching", "kind": "class", @@ -8632,7 +8714,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\TailMatcher", "kind": "interface", @@ -8692,7 +8774,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Unspelled", "kind": "class", @@ -8766,7 +8848,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\UnspelledTail", "kind": "class", @@ -8866,7 +8948,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Outcome", "kind": "class", @@ -12439,7 +12521,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\Absent", "kind": "enum", @@ -12482,7 +12564,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\ArmedSequence", "kind": "class", @@ -12688,7 +12770,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\DoubleState", "kind": "class", @@ -12751,7 +12833,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 77 + "startLine": 80 }, { "name": "makeLean", @@ -12766,7 +12848,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 88 + "startLine": 91 }, { "name": "isLean", @@ -12781,7 +12863,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 93 + "startLine": 96 }, { "name": "setMode", @@ -12804,7 +12886,60 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 98 + "startLine": 101 + }, + { + "name": "forwardsInline", + "static": false, + "params": [], + "returnType": "bool", + "summary": "Whether forwarding runs the real bodies on the double itself.", + "description": "True only for a class double whose parent is exactly the donor's class,\nand only once its state has been copied over: the inline form reads\n`$this->` properties, so turning it on without the copy would run real\ncode against a double the constructor never touched. Every other\nforwarding double — an interface double, a donor that is a subclass of\nthe doubled class — proxies instead.\n\nIt survives a later `setMode()`: the state copy cannot be undone, and\n`callOriginal()` still has to know which form to use.", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 119 + }, + { + "name": "inlineParent", + "static": false, + "params": [], + "returnType": "?string", + "summary": "The class whose bodies an inline double runs: the one the generated\nclass extends, which is also the donor's own class.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 130 + }, + { + "name": "forwardInline", + "static": false, + "params": [ + { + "name": "parent", + "type": "class-string", + "description": "", + "default": null, + "variadic": false + } + ], + "returnType": "void", + "summary": "One-way, and called only after the state copy succeeded.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 140 }, { "name": "label", @@ -12819,7 +12954,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 106 + "startLine": 148 }, { "name": "setDefaultLabel", @@ -12842,7 +12977,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 118 + "startLine": 160 }, { "name": "forwardingTarget", @@ -12857,7 +12992,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 129 + "startLine": 171 }, { "name": "setForwardingTarget", @@ -12880,7 +13015,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 134 + "startLine": 176 }, { "name": "setLabel", @@ -12903,7 +13038,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 142 + "startLine": 184 }, { "name": "writeProperty", @@ -12933,7 +13068,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 150 + "startLine": 192 }, { "name": "hasPropertyValue", @@ -12956,7 +13091,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 158 + "startLine": 200 }, { "name": "propertyValue", @@ -12979,7 +13114,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 166 + "startLine": 208 }, { "name": "referenceSlot", @@ -13016,7 +13151,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 180 + "startLine": 222 }, { "name": "hasActionFor", @@ -13046,7 +13181,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 202 + "startLine": 244 }, { "name": "addExpectation", @@ -13069,7 +13204,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 221 + "startLine": 266 }, { "name": "expectations", @@ -13084,7 +13219,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 293 + "startLine": 338 }, { "name": "expectationsFor", @@ -13107,14 +13242,14 @@ ], "returnType": "array", "summary": "Most recently registered expectations for one method, in matching order.", - "description": "The overwhelmingly common shape — a method with a single stub — is\nanswered by a lookup and nothing else. Indexing only earns its keep once\nthere is something to choose between, and computing a key for a method\nthat has one expectation would tax every call in every suite to speed up\nthe rare one.", + "description": "The overwhelmingly common shape — a method with a single stub — is\nanswered by a lookup and nothing else. Indexing only earns its keep once\nthere is something to choose between, and computing a key for a method\nthat has one expectation would tax every call in every suite to speed up\nthe rare one.\n\nTwo tiers, whichever order they were declared in: an expectation that\npins at least one argument to something narrower than \"anything\" is\ntried before one that says nothing about any argument, so a catch-all\nregistered after a specific stub cannot steal the calls the specific\none describes — the specific one is never unreachable, and the trap\nneeds no diagnosis because it cannot be written. Within a tier the\ndocumented layering stands: most recently registered first, earlier\nones as fallbacks. Two specifics are never compared against each other\nfor narrowness; registration order decides between them.", "throws": [], "throwsInBody": false, "inheritedFrom": null, "see": [], "deprecated": null, "attributes": [], - "startLine": 311 + "startLine": 366 }, { "name": "declaredExpectations", @@ -13129,7 +13264,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 359 + "startLine": 413 }, { "name": "settle", @@ -13144,7 +13279,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 369 + "startLine": 423 }, { "name": "record", @@ -13167,7 +13302,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 398 + "startLine": 452 }, { "name": "callLog", @@ -13182,7 +13317,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 406 + "startLine": 460 } ], "constants": [], @@ -13192,7 +13327,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\InvocationSignal", "kind": "class", @@ -13268,7 +13403,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\Mode", "kind": "enum", @@ -13331,7 +13466,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\ReferenceSlot", "kind": "class", @@ -13367,7 +13502,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\Runtime", "kind": "class", @@ -13402,7 +13537,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 98 + "startLine": 121 }, { "name": "currentIfAny", @@ -13417,7 +13552,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 112 + "startLine": 135 }, { "name": "pushScope", @@ -13432,7 +13567,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 123 + "startLine": 146 }, { "name": "popScope", @@ -13447,7 +13582,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 131 + "startLine": 154 }, { "name": "adopt", @@ -13477,7 +13612,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 197 + "startLine": 220 }, { "name": "liveContexts", @@ -13492,7 +13627,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 231 + "startLine": 254 }, { "name": "adoptClone", @@ -13515,7 +13650,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 262 + "startLine": 285 }, { "name": "adoptInto", @@ -13545,7 +13680,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 290 + "startLine": 335 }, { "name": "adoptContractsInto", @@ -13575,7 +13710,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 302 + "startLine": 347 }, { "name": "ownerOf", @@ -13598,7 +13733,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 318 + "startLine": 363 }, { "name": "isForgotten", @@ -13621,7 +13756,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 332 + "startLine": 377 }, { "name": "isDroppedByScope", @@ -13644,7 +13779,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 341 + "startLine": 386 }, { "name": "forgottenDouble", @@ -13674,7 +13809,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 352 + "startLine": 397 }, { "name": "isRetiredOnPurpose", @@ -13697,7 +13832,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 368 + "startLine": 413 }, { "name": "forget", @@ -13720,7 +13855,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 380 + "startLine": 425 }, { "name": "stateOf", @@ -13743,7 +13878,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 457 + "startLine": 502 }, { "name": "isOwnedByCurrentContext", @@ -13766,7 +13901,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 462 + "startLine": 507 }, { "name": "dispatch", @@ -13803,7 +13938,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 475 + "startLine": 520 }, { "name": "propertyRead", @@ -13833,7 +13968,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 827 + "startLine": 876 }, { "name": "propertyWrite", @@ -13870,7 +14005,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 863 + "startLine": 912 }, { "name": "referenceSlot", @@ -13907,7 +14042,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 921 + "startLine": 970 }, { "name": "callOriginal", @@ -13936,15 +14071,45 @@ } ], "returnType": "mixed", - "summary": "Delegates one recorded call to the real instance, from inside an answer.", - "description": "Explicit rather than mode-driven: `answers(fn (Invocation $i) =>\n$i->callOriginal())` says \"this one call goes through\", and works whether\nor not the double is forwarding by default.", + "summary": "Runs the real implementation of one recorded call, from inside an answer.", + "description": "Explicit rather than mode-driven: `answers(fn (Invocation $i) =>\n$i->callOriginal())` says \"this one call goes through\", and works whether\nor not the double is forwarding by default.\n\nWhichever form the double forwards in: an inline double runs the real\nbody on itself, because the state that body reads lives on the double\nfrom the moment `forwarding()` copied it — the donor's own copy is\nwhatever the first inline call left behind. Everything else proxies to\nthe remembered instance.", "throws": [], "throwsInBody": true, "inheritedFrom": null, "see": [], "deprecated": null, "attributes": [], - "startLine": 971 + "startLine": 1026 + }, + { + "name": "copyState", + "static": true, + "params": [ + { + "name": "double", + "type": "object", + "description": "", + "default": null, + "variadic": false + }, + { + "name": "donor", + "type": "object", + "description": "", + "default": null, + "variadic": false + } + ], + "returnType": "void", + "summary": "Copies the donor instance's initialized state onto the double, so a\nparent body run inline works over the values the real object held.", + "description": "The double's constructor never ran — beyond the empty scalars\n`PropertyDefaults` wrote, nothing on it is initialized — while the\nparent bodies inline forwarding runs read `$this->` properties. Every\nproperty the donor has initialized is copied; one the donor left\nuninitialized is left (or made) uninitialized on the double, so the\nreal body sees exactly what it would have seen on the donor — including\nPHP's own \"must not be accessed before initialization\" for a property\nnobody set.\n\nEach property is handled through a reflection against its **declaring**\nclass: before PHP 8.4 a readonly property can only be initialized from\nthe scope of the class that declares it, and a reflection reached\nthrough the subclass writes from the subclass's scope.", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 1202 }, { "name": "reset", @@ -13959,7 +14124,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1177 + "startLine": 1372 } ], "constants": [], @@ -13969,7 +14134,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\RuntimeContext", "kind": "class", @@ -14354,7 +14519,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\SequenceVerdict", "kind": "enum", @@ -14563,7 +14728,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Understudy", "kind": "class", @@ -14850,15 +15015,20 @@ } ], "returnType": "void", - "summary": "Delegates unmatched calls to a real instance, recording each one.", - "description": "With `$real`, the double starts standing in front of that object; without\nit, the mode is turned on for a double built from an instance by\n\\Rasuvaeff\\Understudy\\for(). Splitting the two is deliberate: `for($real)` gives you a\ndouble that remembers where it came from, and until you say so it still\nanswers with defaults rather than running real code.\n\nOnly the call at the boundary is recorded. A real method that calls\nanother method on itself does so inside the real object; understudy\nproxies an object, it does not instrument one.", - "throws": [], + "summary": "Delegates unmatched calls to the real implementation, recording each\none.", + "description": "With `$real`, the double starts standing in front of that object; without\nit, the mode is turned on for a double built from an instance by\n\\Rasuvaeff\\Understudy\\for(). Splitting the two is deliberate: `for($real)` gives you a\ndouble that remembers where it came from, and until you say so it still\nanswers with defaults rather than running real code.\n\nA double of a **class** runs the real bodies inline, on itself: the\nstate the real instance held is copied onto the double, and an unmatched\ncall executes the parent implementation with `$this` still the double —\nso a self-call inside the real code re-enters the double, meets stubs,\nan armed protocol and the call log, and is recorded like the boundary\ncall. `Invocation::callOriginal()` runs inline there too, over the state\nthe double now holds. A double of an **interface** has no bodies to\nreach and proxies to the real instance — only the boundary call is\nrecorded, and self-calls happen inside the real object where nothing can\nsee them.\n\nInline needs `$real` to be *exactly* the doubled class: a subclass\ninstance carries overrides the doubled class's own bodies do not\ncontain, and it keeps the proxy form so that those overrides answer. A `final` or `private` method\nis not overridden either, so a self-call to one of those runs the real\nbody directly — it works, over the double's state, but it is not\nintercepted and the log does not show it. And `static::class` inside a\nreal body names the generated class, not the doubled one.\n\nCalling this twice with the same instance changes nothing; re-pointing\nan inline double at a *different* instance is refused, because the state\ncopy cannot be taken twice — a `readonly` property is already\ninitialized, and whatever the real code has written since would be\ndiscarded.", + "throws": [ + { + "type": "\\Rasuvaeff\\Understudy\\Exception\\ForwardingTargetMismatch", + "description": "when `$real` is an understudy, misses a\ncontract, or re-points an inline double" + } + ], "throwsInBody": true, "inheritedFrom": null, "see": [], "deprecated": null, "attributes": [], - "startLine": 679 + "startLine": 704 }, { "name": "delegate", @@ -14888,7 +15058,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 730 + "startLine": 785 }, { "name": "wire", @@ -14918,7 +15088,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 762 + "startLine": 817 }, { "name": "bypassFinals", @@ -14946,7 +15116,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 799 + "startLine": 854 }, { "name": "defaults", @@ -14976,7 +15146,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 880 + "startLine": 935 }, { "name": "label", @@ -15006,7 +15176,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 899 + "startLine": 954 }, { "name": "unused", @@ -15029,7 +15199,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 909 + "startLine": 964 }, { "name": "forget", @@ -15052,7 +15222,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 949 + "startLine": 1004 }, { "name": "nothingElse", @@ -15082,7 +15252,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 969 + "startLine": 1024 }, { "name": "allVerified", @@ -15105,7 +15275,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1012 + "startLine": 1067 }, { "name": "expectSequence", @@ -15128,7 +15298,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1095 + "startLine": 1150 }, { "name": "verifySequence", @@ -15151,7 +15321,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1135 + "startLine": 1190 }, { "name": "transcript", @@ -15174,7 +15344,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1161 + "startLine": 1216 }, { "name": "scope", @@ -15204,7 +15374,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1297 + "startLine": 1352 }, { "name": "checkpoint", @@ -15227,7 +15397,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1330 + "startLine": 1385 }, { "name": "reset", @@ -15242,7 +15412,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1357 + "startLine": 1412 }, { "name": "idle", @@ -15257,7 +15427,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1369 + "startLine": 1424 } ], "constants": [], @@ -15267,7 +15437,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\VerificationFailure", "kind": "class", @@ -15374,7 +15544,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\WhenBuilder", "kind": "class", @@ -15590,7 +15760,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Wiring\\Wire", "kind": "class", @@ -15655,7 +15825,7 @@ "functions": [ { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\expect", @@ -15679,7 +15849,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\expectSequence", @@ -15703,7 +15873,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\verify", @@ -15762,7 +15932,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\when", diff --git a/docs/scripts/check-integrity.mjs b/docs/scripts/check-integrity.mjs index 924e341..6f9f47b 100644 --- a/docs/scripts/check-integrity.mjs +++ b/docs/scripts/check-integrity.mjs @@ -42,7 +42,7 @@ const COMPLETENESS_BUDGET = { 'method without a summary': 0, 'parameter without a description': 154, 'constructor parameter without a description': 10, - 'throwing method without @throws': 8, + 'throwing method without @throws': 7, } const errors = [] diff --git a/docs/src/api/classes/Arg.md b/docs/src/api/classes/Arg.md index 506970f..bb155e4 100644 --- a/docs/src/api/classes/Arg.md +++ b/docs/src/api/classes/Arg.md @@ -9,7 +9,7 @@ description: "Argument matchers, usable only inside a specification closure:" `Rasuvaeff\Understudy\Arg` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Arg.php#L50) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Arg.php#L50) — **Version:** v1.0.1-3-gc4d60e5 Argument matchers, usable only inside a specification closure: diff --git a/docs/src/api/classes/Captor.md b/docs/src/api/classes/Captor.md index 07cffcd..d28de17 100644 --- a/docs/src/api/classes/Captor.md +++ b/docs/src/api/classes/Captor.md @@ -9,7 +9,7 @@ description: "A typed argument captor, built by Arg::captor()." `Rasuvaeff\Understudy\Captor` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Captor.php#L38) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Captor.php#L38) — **Version:** v1.0.1-3-gc4d60e5 **Type parameters:** diff --git a/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md b/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md index abd19ee..86ce910 100644 --- a/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md +++ b/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md @@ -9,7 +9,7 @@ description: "Two registered default factories are equally close to the requeste `Rasuvaeff\Understudy\Exception\AmbiguousDefaultFactory` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/AmbiguousDefaultFactory.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/AmbiguousDefaultFactory.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/BypassUnavailable.md b/docs/src/api/classes/Exception/BypassUnavailable.md index 99ce16a..eef7e0f 100644 --- a/docs/src/api/classes/Exception/BypassUnavailable.md +++ b/docs/src/api/classes/Exception/BypassUnavailable.md @@ -9,7 +9,7 @@ description: "`bypassFinals()` cannot do what was asked of it." `Rasuvaeff\Understudy\Exception\BypassUnavailable` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/BypassUnavailable.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/BypassUnavailable.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/CannotWire.md b/docs/src/api/classes/Exception/CannotWire.md index a7525b7..3a64382 100644 --- a/docs/src/api/classes/Exception/CannotWire.md +++ b/docs/src/api/classes/Exception/CannotWire.md @@ -9,7 +9,7 @@ description: "`wire()` cannot build the subject, or cannot decide what to pass i `Rasuvaeff\Understudy\Exception\CannotWire` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/CannotWire.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/CannotWire.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `InvalidArgumentException` diff --git a/docs/src/api/classes/Exception/ConflictingExpectation.md b/docs/src/api/classes/Exception/ConflictingExpectation.md index c3616f1..b6d0d09 100644 --- a/docs/src/api/classes/Exception/ConflictingExpectation.md +++ b/docs/src/api/classes/Exception/ConflictingExpectation.md @@ -9,7 +9,7 @@ description: "A `when()` or `expect()` names a call another registration already `Rasuvaeff\Understudy\Exception\ConflictingExpectation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ConflictingExpectation.php#L21) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ConflictingExpectation.php#L21) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/ContextOwnershipViolation.md b/docs/src/api/classes/Exception/ContextOwnershipViolation.md index 5832c68..3a99f82 100644 --- a/docs/src/api/classes/Exception/ContextOwnershipViolation.md +++ b/docs/src/api/classes/Exception/ContextOwnershipViolation.md @@ -9,7 +9,7 @@ description: "Configuration and verification belong to the context that created `Rasuvaeff\Understudy\Exception\ContextOwnershipViolation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ContextOwnershipViolation.php#L14) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ContextOwnershipViolation.php#L14) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/ForgottenDouble.md b/docs/src/api/classes/Exception/ForgottenDouble.md index 1065ffe..e31eb1b 100644 --- a/docs/src/api/classes/Exception/ForgottenDouble.md +++ b/docs/src/api/classes/Exception/ForgottenDouble.md @@ -9,7 +9,7 @@ description: "A double outlived the context that created it — almost always a `Rasuvaeff\Understudy\Exception\ForgottenDouble` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForgottenDouble.php#L16) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForgottenDouble.php#L16) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/ForwardingTargetMismatch.md b/docs/src/api/classes/Exception/ForwardingTargetMismatch.md index 61f11e2..231af0b 100644 --- a/docs/src/api/classes/Exception/ForwardingTargetMismatch.md +++ b/docs/src/api/classes/Exception/ForwardingTargetMismatch.md @@ -9,7 +9,7 @@ description: "The instance offered as a forwarding target does not satisfy what `Rasuvaeff\Understudy\Exception\ForwardingTargetMismatch` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForwardingTargetMismatch.php#L13) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForwardingTargetMismatch.php#L13) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `InvalidArgumentException` @@ -40,3 +40,26 @@ static missingContract( Builds the error for a forwarding target missing a contract. +### alreadyInline() + +```php +static alreadyInline( + non-empty-string $label, + class-string $current, + class-string $given, +): Exception\ForwardingTargetMismatch +``` + +Builds the error for re-pointing a double that already forwards inline. + +- `$label` — the double's own name in reports +- `$current` — the class whose bodies the double already runs +- `$given` — the class of the instance offered instead + +The state of the first donor was copied onto the double, and a copy +cannot be taken twice: a `readonly` property is initialized, and +whatever the inline calls have written since would be overwritten by a +second donor's reading. Re-pointing is refused rather than silently +leaving the double running the first donor's state against the second +one's instance. + diff --git a/docs/src/api/classes/Exception/InvalidCallSpecification.md b/docs/src/api/classes/Exception/InvalidCallSpecification.md index b0b4370..0e90070 100644 --- a/docs/src/api/classes/Exception/InvalidCallSpecification.md +++ b/docs/src/api/classes/Exception/InvalidCallSpecification.md @@ -9,7 +9,7 @@ description: "A specification of the wrong SHAPE: the closure handed to when()/v `Rasuvaeff\Understudy\Exception\InvalidCallSpecification` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidCallSpecification.php#L25) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidCallSpecification.php#L25) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/InvalidDefaultValue.md b/docs/src/api/classes/Exception/InvalidDefaultValue.md index ec4d70d..eaa9e24 100644 --- a/docs/src/api/classes/Exception/InvalidDefaultValue.md +++ b/docs/src/api/classes/Exception/InvalidDefaultValue.md @@ -9,7 +9,7 @@ description: "A registered default factory produced a value the contract cannot `Rasuvaeff\Understudy\Exception\InvalidDefaultValue` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidDefaultValue.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidDefaultValue.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/InvalidSpecificationArgument.md b/docs/src/api/classes/Exception/InvalidSpecificationArgument.md index b82ef44..9c07d3b 100644 --- a/docs/src/api/classes/Exception/InvalidSpecificationArgument.md +++ b/docs/src/api/classes/Exception/InvalidSpecificationArgument.md @@ -9,7 +9,7 @@ description: "A VALUE inside a specification that no run could act on: a maximum `Rasuvaeff\Understudy\Exception\InvalidSpecificationArgument` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidSpecificationArgument.php#L24) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidSpecificationArgument.php#L24) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `InvalidArgumentException` diff --git a/docs/src/api/classes/Exception/MatcherLeaked.md b/docs/src/api/classes/Exception/MatcherLeaked.md index 786a3d2..899bd61 100644 --- a/docs/src/api/classes/Exception/MatcherLeaked.md +++ b/docs/src/api/classes/Exception/MatcherLeaked.md @@ -9,7 +9,7 @@ description: "A matcher reached a real call instead of a specification closure." `Rasuvaeff\Understudy\Exception\MatcherLeaked` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/MatcherLeaked.php#L14) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/MatcherLeaked.php#L14) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/NeverMethodCalled.md b/docs/src/api/classes/Exception/NeverMethodCalled.md index b0c2fde..b54f2aa 100644 --- a/docs/src/api/classes/Exception/NeverMethodCalled.md +++ b/docs/src/api/classes/Exception/NeverMethodCalled.md @@ -9,7 +9,7 @@ description: "A method declared `: never` was called without an expectation that `Rasuvaeff\Understudy\Exception\NeverMethodCalled` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NeverMethodCalled.php#L14) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NeverMethodCalled.php#L14) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/NoDefaultValue.md b/docs/src/api/classes/Exception/NoDefaultValue.md index 9167035..55cb0c4 100644 --- a/docs/src/api/classes/Exception/NoDefaultValue.md +++ b/docs/src/api/classes/Exception/NoDefaultValue.md @@ -9,7 +9,7 @@ description: "A loose understudy had to answer a call, but the declared return t `Rasuvaeff\Understudy\Exception\NoDefaultValue` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NoDefaultValue.php#L14) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NoDefaultValue.php#L14) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/NothingCaptured.md b/docs/src/api/classes/Exception/NothingCaptured.md index 661f69b..e95ccea 100644 --- a/docs/src/api/classes/Exception/NothingCaptured.md +++ b/docs/src/api/classes/Exception/NothingCaptured.md @@ -9,7 +9,7 @@ description: "`Captor::last()` was read before any matched call carried a value `Rasuvaeff\Understudy\Exception\NothingCaptured` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NothingCaptured.php#L13) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NothingCaptured.php#L13) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/OriginalCallUnavailable.md b/docs/src/api/classes/Exception/OriginalCallUnavailable.md index 51904ff..7337584 100644 --- a/docs/src/api/classes/Exception/OriginalCallUnavailable.md +++ b/docs/src/api/classes/Exception/OriginalCallUnavailable.md @@ -9,7 +9,7 @@ description: "`callOriginal()` was asked to delegate, and there is nothing to de `Rasuvaeff\Understudy\Exception\OriginalCallUnavailable` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalCallUnavailable.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalCallUnavailable.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md b/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md index 51da8fe..2e77888 100644 --- a/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md +++ b/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md @@ -9,7 +9,7 @@ description: "A forwarded call returned an object the double cannot stand in for `Rasuvaeff\Understudy\Exception\OriginalReturnTypeViolation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalReturnTypeViolation.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalReturnTypeViolation.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `RuntimeException` @@ -31,3 +31,24 @@ static foreignInstance( Builds the error for a forwarded fluent call returning another object. +### foreignSelf() + +```php +static foreignSelf( + non-empty-string $label, + non-empty-string $method, +): Exception\OriginalReturnTypeViolation +``` + +Builds the error for an inline call whose real body built a new instance +of the double's own class — a `new static`. + +- `$label` — the double's own name in reports +- `$method` — the method whose real body returned it + +It is of the generated class, so it satisfies the override's `: static` +and the language lets it through; no context knows it, so every call on +it would fail as a double that was never registered. A `clone $this` +does not reach here: the copy inherits the original's forwarding and +answers for real. + diff --git a/docs/src/api/classes/Exception/OutcomeUnavailable.md b/docs/src/api/classes/Exception/OutcomeUnavailable.md index 6155203..b4e7ddf 100644 --- a/docs/src/api/classes/Exception/OutcomeUnavailable.md +++ b/docs/src/api/classes/Exception/OutcomeUnavailable.md @@ -9,7 +9,7 @@ description: "An invocation's outcome was read as the wrong kind: a returned val `Rasuvaeff\Understudy\Exception\OutcomeUnavailable` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OutcomeUnavailable.php#L14) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OutcomeUnavailable.php#L14) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/StrictModeViolation.md b/docs/src/api/classes/Exception/StrictModeViolation.md index 9f0a235..b3961e1 100644 --- a/docs/src/api/classes/Exception/StrictModeViolation.md +++ b/docs/src/api/classes/Exception/StrictModeViolation.md @@ -9,7 +9,7 @@ description: "A strict understudy received a call no expectation matched." `Rasuvaeff\Understudy\Exception\StrictModeViolation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/StrictModeViolation.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/StrictModeViolation.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/UnderstudyError.md b/docs/src/api/classes/Exception/UnderstudyError.md index e0e4907..2176147 100644 --- a/docs/src/api/classes/Exception/UnderstudyError.md +++ b/docs/src/api/classes/Exception/UnderstudyError.md @@ -9,7 +9,7 @@ description: "Implemented by every exception this library throws, so a test can `Rasuvaeff\Understudy\Exception\UnderstudyError` -**Interface** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnderstudyError.php#L14) — **Version:** v1.0.1-1-g9f93626 +**Interface** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnderstudyError.php#L14) — **Version:** v1.0.1-3-gc4d60e5 **Implements:** `Throwable`, `Stringable` diff --git a/docs/src/api/classes/Exception/UnsupportedTarget.md b/docs/src/api/classes/Exception/UnsupportedTarget.md index edb1351..52b76b8 100644 --- a/docs/src/api/classes/Exception/UnsupportedTarget.md +++ b/docs/src/api/classes/Exception/UnsupportedTarget.md @@ -9,7 +9,7 @@ description: "The requested target cannot be doubled, and no option would make i `Rasuvaeff\Understudy\Exception\UnsupportedTarget` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnsupportedTarget.php#L13) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnsupportedTarget.php#L13) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/VerificationFailed.md b/docs/src/api/classes/Exception/VerificationFailed.md index 38ae159..895771c 100644 --- a/docs/src/api/classes/Exception/VerificationFailed.md +++ b/docs/src/api/classes/Exception/VerificationFailed.md @@ -9,7 +9,7 @@ description: "A verification about what the code under test did (or did not) do `Rasuvaeff\Understudy\Exception\VerificationFailed` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/VerificationFailed.php#L20) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/VerificationFailed.php#L20) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/ExpectBuilder.md b/docs/src/api/classes/ExpectBuilder.md index eb1f19b..6e57fb1 100644 --- a/docs/src/api/classes/ExpectBuilder.md +++ b/docs/src/api/classes/ExpectBuilder.md @@ -9,7 +9,7 @@ description: "Configures a call the code under test is expected to make." `Rasuvaeff\Understudy\ExpectBuilder` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/ExpectBuilder.php#L19) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/ExpectBuilder.php#L19) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** [`WhenBuilder`](/api/classes/WhenBuilder) diff --git a/docs/src/api/classes/FailureKind.md b/docs/src/api/classes/FailureKind.md index 42a186b..84b8c80 100644 --- a/docs/src/api/classes/FailureKind.md +++ b/docs/src/api/classes/FailureKind.md @@ -9,7 +9,7 @@ description: "What kind of verification claim a VerificationFailure reports." `Rasuvaeff\Understudy\FailureKind` -**Enum** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/FailureKind.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Enum** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/FailureKind.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Implements:** `UnitEnum` diff --git a/docs/src/api/classes/Invocation.md b/docs/src/api/classes/Invocation.md index 66f78b1..c31d0bf 100644 --- a/docs/src/api/classes/Invocation.md +++ b/docs/src/api/classes/Invocation.md @@ -9,7 +9,7 @@ description: "One recorded call on an understudy." `Rasuvaeff\Understudy\Invocation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Invocation.php#L21) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Invocation.php#L21) — **Version:** v1.0.1-3-gc4d60e5 One recorded call on an understudy. @@ -98,17 +98,22 @@ Checks whether this invocation belongs to a given double. callOriginal(): mixed ``` -Delegates this call to the double's real instance and returns what it -answered — the `answers()` escape hatch for "behave normally, except -here". +Runs the real implementation of this call and returns what it answered — +the `answers()` escape hatch for "behave normally, except here". **Throws:** - [`Exception\OriginalCallUnavailable`](/api/classes/Exception/OriginalCallUnavailable) +Where that implementation runs follows the double's own form. A class +double that forwards runs the real body on itself, over the state +`forwarding()` copied there; every other double delegates to the real +instance it remembers, where the body's own self-calls stay inside that +object. + Without a forwarding target it raises `OriginalCallUnavailable` rather -than reaching for the parent implementation: the double's constructor -never ran, so the parent body would work over state that does not exist. +than reaching for the real body anyway: nothing copied the state, so the +body would work over properties the skipped constructor never filled. ### recordOutcome() diff --git a/docs/src/api/classes/Understudy.md b/docs/src/api/classes/Understudy.md index c4aa8be..281cea6 100644 --- a/docs/src/api/classes/Understudy.md +++ b/docs/src/api/classes/Understudy.md @@ -9,7 +9,7 @@ description: "The whole public surface, as static methods so that an understudy `Rasuvaeff\Understudy\Understudy` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Understudy.php#L41) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Understudy.php#L41) — **Version:** v1.0.1-3-gc4d60e5 The whole public surface, as static methods so that an understudy itself can stay free of service members: every one of them would be a name the doubled @@ -164,7 +164,13 @@ lifecycle teardown runs. static forwarding(object $double, ?object $real = NULL): void ``` -Delegates unmatched calls to a real instance, recording each one. +Delegates unmatched calls to the real implementation, recording each +one. + +**Throws:** + +- [`Exception\ForwardingTargetMismatch`](/api/classes/Exception/ForwardingTargetMismatch) — when `$real` is an understudy, misses a +contract, or re-points an inline double With `$real`, the double starts standing in front of that object; without it, the mode is turned on for a double built from an instance by @@ -172,9 +178,30 @@ it, the mode is turned on for a double built from an instance by double that remembers where it came from, and until you say so it still answers with defaults rather than running real code. -Only the call at the boundary is recorded. A real method that calls -another method on itself does so inside the real object; understudy -proxies an object, it does not instrument one. +A double of a **class** runs the real bodies inline, on itself: the +state the real instance held is copied onto the double, 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, meets stubs, +an armed protocol and the call log, and is recorded like the boundary +call. `Invocation::callOriginal()` runs inline there too, over the state +the double now holds. A double of an **interface** has no bodies to +reach and proxies to the real instance — only the boundary call is +recorded, and self-calls happen inside the real object where nothing can +see them. + +Inline needs `$real` to be *exactly* the doubled class: a subclass +instance carries overrides the doubled class's own bodies do not +contain, and it keeps the proxy form so that those overrides answer. A `final` or `private` method +is not overridden either, so a self-call to one of those runs the real +body directly — it works, over the double's state, but it is not +intercepted and the log does not show it. And `static::class` inside a +real body names the generated class, not the doubled one. + +Calling this twice with the same instance changes nothing; re-pointing +an inline double at a *different* instance is refused, because the state +copy cannot be taken twice — a `readonly` property is already +initialized, and whatever the real code has written since would be +discarded. ### delegate() diff --git a/docs/src/api/classes/VerificationFailure.md b/docs/src/api/classes/VerificationFailure.md index d9f32a1..398d47b 100644 --- a/docs/src/api/classes/VerificationFailure.md +++ b/docs/src/api/classes/VerificationFailure.md @@ -9,7 +9,7 @@ description: "The structured half of one verification failure — the same facts `Rasuvaeff\Understudy\VerificationFailure` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/VerificationFailure.php#L34) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/VerificationFailure.php#L34) — **Version:** v1.0.1-3-gc4d60e5 The structured half of one verification failure — the same facts the rendered message states, addressable by field. diff --git a/docs/src/api/classes/WhenBuilder.md b/docs/src/api/classes/WhenBuilder.md index 859c74a..8e3b635 100644 --- a/docs/src/api/classes/WhenBuilder.md +++ b/docs/src/api/classes/WhenBuilder.md @@ -9,7 +9,7 @@ description: "Configures what a stubbed call does." `Rasuvaeff\Understudy\WhenBuilder` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/WhenBuilder.php#L39) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/WhenBuilder.php#L39) — **Version:** v1.0.1-3-gc4d60e5 **Type parameters:** diff --git a/docs/src/guide/forwarding.md b/docs/src/guide/forwarding.md index 33dc092..04b10df 100644 --- a/docs/src/guide/forwarding.md +++ b/docs/src/guide/forwarding.md @@ -38,11 +38,33 @@ when(fn () => $spy->get('key')) ->answers(fn (Invocation $call) => strtoupper((string) $call->callOriginal())); ``` -## Five things worth knowing first +## Eight things worth knowing first -- **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. +- **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 @@ -51,11 +73,20 @@ when(fn () => $spy->get('key')) - **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. 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. ::: warning Retention A forwarding double returns whatever the real object returns, including values diff --git a/docs/src/guide/migrating-from-mockery.md b/docs/src/guide/migrating-from-mockery.md index 9219a18..b254ab0 100644 --- a/docs/src/guide/migrating-from-mockery.md +++ b/docs/src/guide/migrating-from-mockery.md @@ -35,7 +35,7 @@ the site still gets them. | `$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 diff --git a/llms.txt b/llms.txt index d9c68a6..e4d8973 100644 --- a/llms.txt +++ b/llms.txt @@ -82,13 +82,24 @@ No runtime deps; `ext-mbstring` is not needed. - `Understudy::delegate(Contract::class, $real)` is `for()` + `forwarding()` in one expression and returns the double. The target is validated the same way. - `Invocation::callOriginal()` delegates one call from inside `answers()`, - whatever the mode. Without a target it raises `OriginalCallUnavailable` — the - parent implementation is never reached, because the double's constructor never - ran. -- Forwarding records the boundary call only: a real method calling itself is not - visible. A fluent method that returned the real instance comes back as the - double; a `static` method returning another instance of the real class raises - `OriginalReturnTypeViolation`. + whatever the mode. On an inline double it runs the parent body on the double; + otherwise on the real instance. Without a target it raises + `OriginalCallUnavailable` — nothing copied the state, so the parent + implementation would read properties the skipped constructor never filled. +- A CLASS double forwards inline: the doubled class's bodies run on the double, + over state copied from the instance when `forwarding()` was called, so a + self-call re-enters the double and meets stubs, protocols and the call log. + It needs the instance to be exactly the doubled class — a subclass instance + keeps the proxy form — and an inline double cannot be re-pointed at a second + instance (`ForwardingTargetMismatch`). `static::class` inside a real body is + the generated class; `final` and `private` methods are not intercepted. +- An INTERFACE double proxies: it records the boundary call only, and a real + method calling itself is not visible. A fluent method that returned the real + instance comes back as the double; a `: static` method returning another + instance of the real class raises `OriginalReturnTypeViolation`. Inline, a + `clone $this` is adopted while the body runs and comes back forwarding (no + stubs copied); a `new static` raises `OriginalReturnTypeViolation`, because + no context knows that object. - `Understudy::bypassFinals(FinalX::class)` lifts `final` off one class before it loads; `Understudy::bypassFinals()` does it for every class the process loads from then on. Opt-in, and order-dependent: it works only for classes not diff --git a/spikes/08-inline-forwarding/spike.php b/spikes/08-inline-forwarding/spike.php new file mode 100644 index 0000000..2094d43 --- /dev/null +++ b/spikes/08-inline-forwarding/spike.php @@ -0,0 +1,138 @@ +()` sibling that + * calls `parent::()` — is what jasonmccreary/double does. It is not + * open to us: every member added to a generated double is a name the doubled + * type can no longer use. + */ + +declare(strict_types=1); + +namespace Understudy\Spikes\InlineForwarding; + +use function Understudy\Spikes\assertSame; +use function Understudy\Spikes\assertTrue; + +require __DIR__ . '/../lib.php'; + +class Doubled +{ + public function __construct(private readonly string $name = 'real') {} + + /** The template method: everything it does goes through itself. */ + public function run(string $input): string + { + return $this->apply($input); + } + + public function apply(string $input): string + { + return $input . ':' . $this->name; + } + + protected function hidden(): string + { + return 'hidden:' . $this->name; + } + + public function fill(string &$slot, string $value): void + { + $slot = $value . '!'; + } + + public function self(): static + { + return $this; + } +} + +/** Stands in for the generated double: every method is an interception point. */ +final class Generated extends Doubled +{ + /** @var list */ + public array $seen = []; + + public function run(string $input): string + { + $this->seen[] = 'run'; + + return 'intercepted run'; + } + + public function apply(string $input): string + { + $this->seen[] = 'apply'; + + return 'intercepted apply'; + } + + protected function hidden(): string + { + $this->seen[] = 'hidden'; + + return 'intercepted hidden'; + } +} + +/** + * @param list $args + */ +function inline(object $double, string $class, string $method, array $args): mixed +{ + return (new \ReflectionMethod($class, $method))->invokeArgs($double, $args); +} + +$double = new Generated(); + +// The real `run()` body ran — its own override was not entered — and the +// self-call inside it DID enter the override. +assertSame( + inline($double, Doubled::class, 'run', ['in']), + 'intercepted apply', + 'a reflection on the doubled class enters its body, and the self-call inside it is intercepted', +); +assertSame($double->seen, ['apply'], 'only the self-call was intercepted, not the entry'); + +assertSame( + inline($double, Doubled::class, 'apply', ['in']), + 'in:real', + 'the real body reads $this state copied onto the subclass instance', +); + +assertSame( + inline($double, Doubled::class, 'hidden', []), + 'hidden:real', + 'a protected body is reachable the same way', +); + +$slot = 'empty'; +$args = [&$slot, 'written']; +inline($double, Doubled::class, 'fill', $args); +assertSame($slot, 'written!', 'a by-reference parameter still writes to the caller variable'); + +assertTrue( + inline($double, Doubled::class, 'self', []) === $double, + 'a body returning $this hands back the double, so a fluent chain stays doubled', +); + +$unchanged = new Generated(); +assertSame( + $unchanged->run('in'), + 'intercepted run', + 'an ordinary call still goes to the override — the reflection is the only non-virtual entry', +); diff --git a/spikes/run.sh b/spikes/run.sh index cf87bb7..c679669 100644 --- a/spikes/run.sh +++ b/spikes/run.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Runs feasibility spikes 01-06 with the PHP binary on PATH. +# Runs feasibility spikes 01-06 and 08 with the PHP binary on PATH. # Spike 07 (Psalm plugin) has its own runner: spikes/07-psalm-builder/run.sh. set -u @@ -8,7 +8,7 @@ cd "$(dirname "$0")" php -v | head -1 status=0 -for spike in 01-sentinel 02-matcher-union 03-byref-return 04-dnf-multitarget 05-fiber-contexts 06-bypass-finals; do +for spike in 01-sentinel 02-matcher-union 03-byref-return 04-dnf-multitarget 05-fiber-contexts 06-bypass-finals 08-inline-forwarding; do if php "$spike/spike.php"; then echo "PASS $spike" else diff --git a/src/Exception/ForwardingTargetMismatch.php b/src/Exception/ForwardingTargetMismatch.php index bfcd4c1..5f6ab9a 100644 --- a/src/Exception/ForwardingTargetMismatch.php +++ b/src/Exception/ForwardingTargetMismatch.php @@ -44,4 +44,31 @@ public static function missingContract(string $label, string $contract, string $ $given, )); } + + /** + * Builds the error for re-pointing a double that already forwards inline. + * + * The state of the first donor was copied onto the double, and a copy + * cannot be taken twice: a `readonly` property is initialized, and + * whatever the inline calls have written since would be overwritten by a + * second donor's reading. Re-pointing is refused rather than silently + * leaving the double running the first donor's state against the second + * one's instance. + * + * @param non-empty-string $label the double's own name in reports + * @param class-string $current the class whose bodies the double already runs + * @param class-string $given the class of the instance offered instead + */ + public static function alreadyInline(string $label, string $current, string $given): self + { + return new self(sprintf( + "Understudy `%s` already runs the bodies of `%s` on itself, and cannot be re-pointed at `%s`.\n" + . 'The first instance\'s state was copied onto the double when forwarding was turned on; a second ' + . 'copy would overwrite everything the real code has written since, and a `readonly` property ' + . 'cannot be written twice at all. Build a second double for the other instance.', + $label, + $current, + $given, + )); + } } diff --git a/src/Exception/OriginalReturnTypeViolation.php b/src/Exception/OriginalReturnTypeViolation.php index 1efe3c5..322d576 100644 --- a/src/Exception/OriginalReturnTypeViolation.php +++ b/src/Exception/OriginalReturnTypeViolation.php @@ -31,4 +31,31 @@ public static function foreignInstance(string $label, string $method, string $re $method, )); } + + /** + * Builds the error for an inline call whose real body built a new instance + * of the double's own class — a `new static`. + * + * It is of the generated class, so it satisfies the override's `: static` + * and the language lets it through; no context knows it, so every call on + * it would fail as a double that was never registered. A `clone $this` + * does not reach here: the copy inherits the original's forwarding and + * answers for real. + * + * @param non-empty-string $label the double's own name in reports + * @param non-empty-string $method the method whose real body returned it + */ + public static function foreignSelf(string $label, string $method): self + { + return new self(sprintf( + "Understudy `%s` ran `%s()` inline, and the real body built a new instance of itself — a " + . "`new static` or `new self`.\n" + . 'It is of the double\'s own class, so the return type accepts it, but no context knows it and ' + . 'every call on it would fail as an unregistered double. Configure the method instead: ' + . 'when(fn () => $double->%s(...))->returns($double).', + $label, + $method, + $method, + )); + } } diff --git a/src/Invocation.php b/src/Invocation.php index cecc42f..5cf832c 100644 --- a/src/Invocation.php +++ b/src/Invocation.php @@ -127,13 +127,18 @@ public function belongsTo(object $double): bool } /** - * Delegates this call to the double's real instance and returns what it - * answered — the `answers()` escape hatch for "behave normally, except - * here". + * Runs the real implementation of this call and returns what it answered — + * the `answers()` escape hatch for "behave normally, except here". + * + * Where that implementation runs follows the double's own form. A class + * double that forwards runs the real body on itself, over the state + * `forwarding()` copied there; every other double delegates to the real + * instance it remembers, where the body's own self-calls stay inside that + * object. * * Without a forwarding target it raises `OriginalCallUnavailable` rather - * than reaching for the parent implementation: the double's constructor - * never ran, so the parent body would work over state that does not exist. + * than reaching for the real body anyway: nothing copied the state, so the + * body would work over properties the skipped constructor never filled. * * @throws OriginalCallUnavailable */ diff --git a/src/Runtime/DoubleState.php b/src/Runtime/DoubleState.php index a3001fc..fd734c8 100644 --- a/src/Runtime/DoubleState.php +++ b/src/Runtime/DoubleState.php @@ -74,6 +74,9 @@ public function __construct( private bool $lean = false; + /** @var class-string|null */ + private ?string $inlineParent = null; + public function mode(): Mode { return $this->mode; @@ -100,6 +103,45 @@ public function setMode(Mode $mode): void $this->mode = $mode; } + /** + * Whether forwarding runs the real bodies on the double itself. + * + * True only for a class double whose parent is exactly the donor's class, + * and only once its state has been copied over: the inline form reads + * `$this->` properties, so turning it on without the copy would run real + * code against a double the constructor never touched. Every other + * forwarding double — an interface double, a donor that is a subclass of + * the doubled class — proxies instead. + * + * It survives a later `setMode()`: the state copy cannot be undone, and + * `callOriginal()` still has to know which form to use. + */ + public function forwardsInline(): bool + { + return $this->inlineParent !== null; + } + + /** + * The class whose bodies an inline double runs: the one the generated + * class extends, which is also the donor's own class. + * + * @return class-string|null + */ + public function inlineParent(): ?string + { + return $this->inlineParent; + } + + /** + * One-way, and called only after the state copy succeeded. + * + * @param class-string $parent + */ + public function forwardInline(string $parent): void + { + $this->inlineParent = $parent; + } + /** * @return non-empty-string */ diff --git a/src/Runtime/Runtime.php b/src/Runtime/Runtime.php index 8d1bc76..7245948 100644 --- a/src/Runtime/Runtime.php +++ b/src/Runtime/Runtime.php @@ -31,6 +31,29 @@ final class Runtime /** How deep an argument snapshot follows nested arrays. */ private const int SNAPSHOT_DEPTH = 8; + /** + * The doubled classes' own method bodies, by `Class::method`, for inline + * forwarding. Class metadata, so it outlives a context: `reset()` drops + * doubles, not the shape of the types they stood in for. + * + * @var array + */ + private static array $inlineMethods = []; + + /** + * The doubles whose real bodies are running right now, innermost last. + * + * A body that clones `$this` gets a copy of a double, and the generated + * `__clone()` cannot tell what it was cloned from — PHP hands it no + * reference to the original. Without this frame the copy would be a + * default-answering double in the middle of real code that, proxied, + * would have gone on working: `withTag()` returning `clone $this`, or a + * body computing over an internal copy. + * + * @var list + */ + private static array $inlineFrames = []; + /** * A stack, not a single context: `scope()` nests one inside another, and * the outer one must come back intact when the inner ends. @@ -270,7 +293,29 @@ public static function adoptClone(object $clone): void } $context = self::current(); - $context->register($clone, new DoubleState($blueprint)); + $state = new DoubleState($blueprint); + + // Cloned by real code running inline: the copy carries the original's + // forwarding, because PHP already copied the state those bodies read + // and the alternative is real code holding a double that answers + // defaults. Expectations are still not copied — that part of the rule + // above stands, and a clone the TEST makes is untouched by this + // because no body is running then. + $frame = self::$inlineFrames === [] ? null : self::$inlineFrames[\count(self::$inlineFrames) - 1]; + + if ($frame !== null && $frame::class === $clone::class) { + $origin = (self::ownerOf($frame) ?? $context)->stateOf($frame); + $target = $origin?->forwardingTarget(); + $parent = $origin?->inlineParent(); + + if ($target !== null && $parent !== null) { + $state->setForwardingTarget($target); + $state->setMode(Mode::Forwarding); + $state->forwardInline($parent); + } + } + + $context->register($clone, $state); self::owners()->offsetSet($clone, $context); } @@ -684,6 +729,10 @@ private static function answer( // `expect(...)->times(1)` with no action — still needs one. Returning a // default there would make counting a call change what it answers. if ($state->mode() === Mode::Forwarding) { + if ($state->forwardsInline()) { + return self::runInline($state, $double, $method, $args); + } + // The live arguments, not the log's reading of them: a by-reference // parameter is the caller's variable, and the whole point of // forwarding is that the real method can write to it. @@ -959,12 +1008,18 @@ public static function referenceSlot(object $double, string $method, array $args } /** - * Delegates one recorded call to the real instance, from inside an answer. + * Runs the real implementation of one recorded call, from inside an answer. * * Explicit rather than mode-driven: `answers(fn (Invocation $i) => * $i->callOriginal())` says "this one call goes through", and works whether * or not the double is forwarding by default. * + * Whichever form the double forwards in: an inline double runs the real + * body on itself, because the state that body reads lives on the double + * from the moment `forwarding()` copied it — the donor's own copy is + * whatever the first inline call left behind. Everything else proxies to + * the remembered instance. + * * @param non-empty-string $method * @param list $args */ @@ -977,16 +1032,94 @@ public static function callOriginal(object $double, string $method, array $args) throw ForgottenDouble::afterReset($method); } + if ($state->forwardsInline()) { + return self::runInline($state, $double, $method, $args); + } + return self::forward($state, $double, $method, $args); } + /** + * Runs the real body on the double itself. + * + * `ReflectionMethod::invokeArgs()` is what makes this possible without + * adding anything to the generated class: a reflection taken on the + * *doubled* class enters that class's body even though the double + * overrides the method — the same non-virtual entry `parent::` gives — + * while `$this` inside the body stays the double, so its self-calls + * dispatch virtually and land back in the override. Verified on 8.3, 8.4 + * and 8.5; `spikes/08-inline-forwarding` is the executable form of the + * claim. A generated `__understudyReal_*()` sibling would be the other + * way, and it is the way this library does not take: every member added + * to a double is a name the doubled type can no longer use. + * + * The arguments are the live ones — a reference survives the array, so a + * by-reference parameter still writes to the caller's variable — and the + * body runs inside dispatch, so its outcome is recorded like any other + * answer's. + * + * The one thing the proxy form refused and this form has to refuse too: a + * real body that builds another instance of itself and hands it out. Under + * the proxy it was an instance of the real class the double could not + * stand in for; here it is an instance of the generated class, which + * satisfies the override's `: static` and so gets past the language. A + * `clone $this` is fine — `adoptClone()` gave the copy this double's + * forwarding, so it answers for real — but a `new static` belongs to no + * context at all, and the next call on it would fail as a double that was + * never registered. That is what is refused, by registration rather than + * by return type: an unknown instance is broken for every caller, not only + * for one whose signature says `static`. + * + * @param non-empty-string $method + * @param list $args + */ + private static function runInline(DoubleState $state, object $double, string $method, array $args): mixed + { + $parent = $state->inlineParent(); + \assert($parent !== null); + + // Cached across the suite: the reflection describes a class, and a + // fresh one per call is reflection on the dispatch path. Every contract + // of the double is satisfied by the donor's class, which IS this class, + // so the method is always there to be found. + $real = self::$inlineMethods[$parent . '::' . $method] ??= new \ReflectionMethod($parent, $method); + + self::$inlineFrames[] = $double; + + try { + /** @var mixed $value */ + $value = $real->invokeArgs($double, $args); + } finally { + array_pop(self::$inlineFrames); + } + + if (!\is_object($value)) { + // Only an object can be the double, its clone, or a `new static`. + return $value; + } + + if ($value === $double) { + // The body's own `return $this`, which already IS the double. + return $value; + } + + if ($value instanceof $double && self::ownerOf($value) === null) { + throw OriginalReturnTypeViolation::foreignSelf($state->label(), $method); + } + + return $value; + } + /** * Delegates a call to the double's real instance and adopts the result. * - * Only the call at the boundary is recorded. If the real method calls - * another method on itself, that happens inside the real object and never - * reaches this dispatcher: understudy proxies an object, it does not - * instrument one. + * The proxy form of forwarding — the one an interface double has no choice + * about, and the one a donor that is a subclass of the doubled class keeps. + * The call runs on the remembered instance, `$this` inside it is that + * instance, and its self-calls stay inside it. A class double standing in + * for exactly the donor's class runs the real bodies on itself instead + * ({@see runInline()}), where `$this` stays the double and self-calls + * re-enter dispatch. * * @param non-empty-string $method * @param list $args @@ -1048,6 +1181,68 @@ private static function adoptForwardedResult( return $value; } + /** + * Copies the donor instance's initialized state onto the double, so a + * parent body run inline works over the values the real object held. + * + * The double's constructor never ran — beyond the empty scalars + * `PropertyDefaults` wrote, nothing on it is initialized — while the + * parent bodies inline forwarding runs read `$this->` properties. Every + * property the donor has initialized is copied; one the donor left + * uninitialized is left (or made) uninitialized on the double, so the + * real body sees exactly what it would have seen on the donor — including + * PHP's own "must not be accessed before initialization" for a property + * nobody set. + * + * Each property is handled through a reflection 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 reflection reached + * through the subclass writes from the subclass's scope. + */ + public static function copyState(object $double, object $donor): void + { + for ($class = new \ReflectionClass($donor); $class !== false; $class = $class->getParentClass()) { + foreach ($class->getProperties() as $property) { + if ($property->isStatic() || $property->getDeclaringClass()->getName() !== $class->getName()) { + continue; + } + + if (\PHP_VERSION_ID >= 80400 && $property->hasHooks()) { + // A virtual property has no state to copy; a backed one + // has its backing store, reachable without running the + // hooks — reading through them would execute the donor's + // code during what is test setup. + if (!$property->isVirtual() && $property->isInitialized($donor)) { + $property->setRawValue($double, $property->getRawValue($donor)); + } + + continue; + } + + if ($property->isInitialized($donor)) { + $property->setValue($double, $property->getValue($donor)); + + continue; + } + + if ($property->isInitialized($double)) { + // The donor's truth is "uninitialized"; the double is + // holding one of the empty-scalar property defaults. + // Restore the donor's reading, or the real body would see + // a value the real object never had. + // + // No visibility dance and no `readonly` guard, because + // only one thing writes a property on a double the + // constructor skipped: `PropertyDefaults`, which writes + // public, writable, non-readonly properties and nothing + // else. That is exactly the set `unset()` from out here + // can reach, and the rest cannot be in this position. + unset($double->{$property->getName()}); + } + } + } + } + /** * The same values, without the references. * diff --git a/src/Understudy.php b/src/Understudy.php index 6ff5fe3..afb0d00 100644 --- a/src/Understudy.php +++ b/src/Understudy.php @@ -664,7 +664,8 @@ public static function lean(object $double): void } /** - * Delegates unmatched calls to a real instance, recording each one. + * Delegates unmatched calls to the real implementation, recording each + * one. * * With `$real`, the double starts standing in front of that object; without * it, the mode is turned on for a double built from an instance by @@ -672,9 +673,33 @@ public static function lean(object $double): void * double that remembers where it came from, and until you say so it still * answers with defaults rather than running real code. * - * Only the call at the boundary is recorded. A real method that calls - * another method on itself does so inside the real object; understudy - * proxies an object, it does not instrument one. + * A double of a **class** runs the real bodies inline, on itself: the + * state the real instance held is copied onto the double, 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, meets stubs, + * an armed protocol and the call log, and is recorded like the boundary + * call. `Invocation::callOriginal()` runs inline there too, over the state + * the double now holds. A double of an **interface** has no bodies to + * reach and proxies to the real instance — only the boundary call is + * recorded, and self-calls happen inside the real object where nothing can + * see them. + * + * Inline needs `$real` to be *exactly* the doubled class: a subclass + * instance carries overrides the doubled class's own bodies do not + * contain, and it keeps the proxy form so that those overrides answer. A `final` or `private` method + * is not overridden either, so a self-call to one of those runs the real + * body directly — it works, over the double's state, but it is not + * intercepted and the log does not show it. And `static::class` inside a + * real body names the generated class, not the doubled one. + * + * Calling this twice with the same instance changes nothing; re-pointing + * an inline double at a *different* instance is refused, because the state + * copy cannot be taken twice — a `readonly` property is already + * initialized, and whatever the real code has written since would be + * discarded. + * + * @throws ForwardingTargetMismatch when `$real` is an understudy, misses a + * contract, or re-points an inline double */ public static function forwarding(object $double, ?object $real = null): void { @@ -693,12 +718,42 @@ public static function forwarding(object $double, ?object $real = null): void } } + $current = $state->forwardingTarget(); + + if ($state->forwardsInline() && $current !== $real) { + \assert($current !== null); + + throw ForwardingTargetMismatch::alreadyInline($state->label(), $current::class, $real::class); + } + $state->setForwardingTarget($real); } elseif ($state->forwardingTarget() === null) { throw OriginalCallUnavailable::forMode($state->label()); } $state->setMode(Mode::Forwarding); + + $target = $state->forwardingTarget(); + \assert($target !== null); + + // Inline only when the bodies the double would run are the bodies this + // instance would have run — the generated class extends exactly the + // donor's class. An interface double has no parent class at all, and a + // donor that is a *subclass* of the doubled class carries overrides + // that are not in the doubled class's bodies; running those bodies + // instead would change which code executes, so it keeps the proxy + // form, where its own overrides answer. + // + // The real bodies an inline double runs read `$this->` properties, and + // the double's own state is empty by construction: whatever the donor + // initialized has to be on the double before the first inline call, + // not lazily at some later point the test would have to know about. + $parent = get_parent_class($double); + + if (!$state->forwardsInline() && $parent === $target::class) { + Runtime::copyState($double, $target); + $state->forwardInline($parent); + } } /** diff --git a/tests/Fixture/Fwd/BasePipeline.php b/tests/Fixture/Fwd/BasePipeline.php new file mode 100644 index 0000000..a6e8dfd --- /dev/null +++ b/tests/Fixture/Fwd/BasePipeline.php @@ -0,0 +1,21 @@ +tag; + } +} diff --git a/tests/Fixture/Fwd/OtherPipeline.php b/tests/Fixture/Fwd/OtherPipeline.php new file mode 100644 index 0000000..cb3b646 --- /dev/null +++ b/tests/Fixture/Fwd/OtherPipeline.php @@ -0,0 +1,18 @@ +normalize($this->apply($input)); + } + + public function apply(string $input): string + { + return $input . '+' . implode(',', $this->steps); + } + + protected function normalize(string $raw): string + { + return trim($raw); + } + + /** + * Reads the state the constructor set up: promoted readonly, promoted + * array — exactly what a state copy has to carry onto the double. + */ + public function describe(): string + { + return $this->name . ':' . count($this->steps); + } + + public function recall(): ?string + { + return $this->memo; + } + + public function addStep(string $step): static + { + $this->steps[] = $step; + + return $this; + } + + public function fill(string &$slot, string $value): void + { + $slot = $value . ':' . $this->name; + } + + public function fail(string $reason): never + { + throw new \DomainException($reason); + } + + /** + * `: static` resolving to another object — the shape the proxy form + * refused as a foreign instance, and the inline form has to refuse as a + * double nobody configured. + */ + public function duplicate(): static + { + return new static($this->name); + } + + /** + * Not `: static`, so the override's return type is satisfied by any + * `Pipeline` and the refusal does not apply. + */ + public function mirror(): self + { + return clone $this; + } + + /** + * Computes over an internal copy of itself and never hands it out: the + * copy has to keep working, or real code breaks where nothing returned a + * double at all. + */ + public function preview(string $step): string + { + $copy = clone $this; + $copy->addStep($step); + + return $copy->describe(); + } + + public function copy(): static + { + $copy = clone $this; + $copy->memo = 'copied'; + + return $copy; + } + + /** + * What `static` resolves to inside a body running inline: the generated + * class, not this one. + */ + public function className(): string + { + return static::class; + } +} diff --git a/tests/Fixture/Fwd/SpecialPipeline.php b/tests/Fixture/Fwd/SpecialPipeline.php new file mode 100644 index 0000000..325d288 --- /dev/null +++ b/tests/Fixture/Fwd/SpecialPipeline.php @@ -0,0 +1,19 @@ + $double->apply(Arg::any()))->returns(' stubbed '); + + // run() is unmatched, runs the real template body — which calls + // $this->apply(), and that self-call re-enters the double and lands + // on the stub rather than in the real body. + Assert::same($double->run('x'), 'stubbed'); + } + + public function selfCallsAreRecordedLikeAnyOtherCall(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + + // The real bodies, all of them: apply() appends the steps, normalize() + // trims what it was handed. + Assert::same($double->run(' x '), 'x +a'); + + // The boundary call and the self-call are both in the log — a verify() + // or nothingElse() sees them, which is what a partial-mock port + // expects of makePartial(). + Assert::same(count(Understudy::calls(fn() => $double->run(Arg::any()))), 1); + Assert::same(count(Understudy::calls(fn() => $double->apply(Arg::any()))), 1); + + $run = Understudy::calls(fn() => $double->run(Arg::any())); + Assert::same($run[0]->args, [' x ']); + + // The self-call was recorded with what the real body passed it, not + // with what the boundary received. + $apply = Understudy::calls(fn() => $double->apply(Arg::any())); + Assert::same($apply[0]->args, [' x ']); + Assert::same($apply[0]->returned(), ' x +a'); + } + + /** + * The behaviour change this brings: a self-call is a call, so it counts + * toward an expectation the test made about that method. + */ + public function aSelfCallCountsTowardAnExpectation(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod')); + + expect(fn() => $double->apply(Arg::any()))->times(1); + + // apply() is never called from the test — only from inside run(). + $double->run('x'); + + Assert::same(Understudy::verifyAll(), 1); + } + + /** + * An armed protocol judges every call the dispatcher sees, so a self-call + * now fills a step the test never made from the outside. The step is + * satisfied by the real code's own call, which is what a protocol over a + * template method means once the bodies run inline. + */ + public function aSelfCallFillsAStepOfAnArmedProtocol(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod')); + + expectSequence( + fn() => $double->run('x'), + fn() => $double->apply('x'), + ); + + // Only the boundary call is made here; apply() arrives from inside + // the real template body and completes the protocol. + $double->run('x'); + + Assert::same(Understudy::verifyAll(), 1); + } + + public function theRealStateIsCopiedOntoTheDouble(): void + { + // The constructor never ran on the double; the bodies it now runs + // inline read `$this->` state, and what they see has to be the real + // instance's — promoted readonly included, which is the pre-8.4 + // initialization-scope trap. + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a', 'b'])); + + Assert::same($double->describe(), 'prod:2'); + Assert::same($double->apply('x'), 'x+a,b'); + } + + /** + * The scope trap itself: `tag` is declared `protected readonly` on the + * parent class, and before PHP 8.4 it can only be initialized from + * there — a reflection reached through the double's own class writes from + * the wrong scope. + */ + public function anInheritedReadonlyPropertyIsCopied(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', [], 'inherited')); + + Assert::same($double->tag(), 'inherited'); + } + + /** + * Two classes declaring the same method name, doubled inline in one + * process: each double has to run its own class's body. The dispatcher + * caches the bodies it enters, and a cache keyed by the method name alone + * would answer the second double with the first one's implementation. + */ + public function twoClassesSharingAMethodNameKeepTheirOwnBodies(): void + { + $pipeline = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + $other = Understudy::delegate(OtherPipeline::class, new OtherPipeline()); + + Assert::same($pipeline->apply('x'), 'x+a'); + Assert::same($other->apply('x'), 'other(x)'); + + // And in the other order, so the test does not depend on which body + // was cached first. + Assert::same($other->apply('y'), 'other(y)'); + Assert::same($pipeline->apply('y'), 'y+a'); + } + + public function whatTheDonorLeftUninitializedStaysUninitialized(): void + { + $real = new Pipeline('prod'); + $real->memo = 'remembered'; + unset($real->memo); + + $double = Understudy::delegate(Pipeline::class, $real); + + // Not a default-filled null: the donor's truth is "never + // initialized", and PHP's own error is the diagnosis — the same one + // the real object would have raised. + Expect::exception(\Error::class)->withMessageContaining('must not be accessed before initialization'); + + $double->recall(); + } + + /** + * A hooked property carries its state in a backing store, and a copy must + * reach it without running the hooks: reading through them would execute + * the donor's code during what is test setup, and writing through them + * would run a `set` the real object already ran. A virtual property has no + * state at all, so there is nothing to copy — only its hooks, which the + * generated class inherits. + * + * Built by eval: the syntax is a parse error on 8.3, where a file carrying + * it would take the whole suite down rather than skip a test. + */ + public function aHookedPropertyIsCopiedThroughItsBackingStore(): void + { + if (PHP_VERSION_ID < 80400) { + // Nothing to skip: the language cannot express a hook yet. + Assert::false(method_exists(\ReflectionProperty::class, 'hasHooks')); + + return; + } + + $class = 'HookedDonor_' . PHP_VERSION_ID; + + eval(sprintf( + 'namespace %s; class %s { public int $scaled { get => $this->scaled * 2; ' + . 'set { $this->scaled = $value; } } public string $virtual { get => \'computed\'; } ' + . 'public string $plain; ' + . 'public function __construct() { $this->scaled = 5; $this->plain = \'carried\'; } ' + . 'public function read(): int { return $this->scaled; } ' + . 'public function readPlain(): string { return $this->plain; } ' + . 'public function readVirtual(): string { return $this->virtual; } }', + __NAMESPACE__, + $class, + )); + + /** @var class-string $fqcn */ + $fqcn = __NAMESPACE__ . '\\' . $class; + $double = Understudy::delegate($fqcn, new $fqcn()); + + // The backing store holds 5, and the inherited `get` hook doubles it: + // the state arrived, and the hooks were not run to put it there. + Assert::same($double->read(), 10); + Assert::same($double->readVirtual(), 'computed'); + + // And the copy carried on past the hooked properties to the plain one + // declared after them. + Assert::same($double->readPlain(), 'carried'); + } + + public function aFluentSelfReturnIsTheDoubleItself(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod')); + + // The parent body's `return $this;` already *is* the double — no + // adoption needed, the chain stays doubled by itself. + Assert::same($double->addStep('x'), $double); + } + + public function aByReferenceArgumentIsWrittenThrough(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod')); + + $slot = 'empty'; + $double->fill($slot, 'v'); + + // The real body wrote through the caller's variable, using state + // (the promoted name) copied from the donor. + Assert::same($slot, 'v:prod'); + } + + public function aNeverMethodThrowsFromTheRealBody(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod')); + + Expect::exception(\DomainException::class)->withMessage('refused'); + + $double->fail('refused'); + } + + /** + * `static` inside a body running inline is the generated class: the body + * runs on the double, and the double is a subclass of the real one. + */ + public function staticInsideARealBodyIsTheGeneratedClass(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod')); + + Assert::same($double->className(), $double::class); + Assert::true($double instanceof Pipeline); + Assert::false($double->className() === Pipeline::class); + } + + /** + * `new static` satisfies the override's `: static` — the object is of the + * generated class — so the language no longer refuses it. No context + * knows that object, every call on it would fail as an unregistered + * double, and the refusal the proxy path used to raise now comes from + * here. + */ + public function aNewStaticFromARealBodyIsRefused(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod')); + + Expect::exception(OriginalReturnTypeViolation::class) + ->withMessageContaining('built a new instance of itself') + ->withMessageContaining('unregistered double') + ->withMessageContaining('->returns($double)'); + + $double->duplicate(); + } + + /** + * The refusal is about an object no context knows, not about the object + * being another instance: a clone was adopted while the body ran, so it + * comes back — and it comes back *working*. PHP copied the state the real bodies read, and the + * clone inherits the original's forwarding, so it answers for real where + * the proxy form would have handed over a real clone. What it does not + * inherit is this double's expectations: a stub written here does not + * follow the copy, exactly as for a clone the test makes itself. + */ + public function aCloneFromANonStaticMethodComesBackForwarding(): void + { + $real = new Pipeline('prod', ['a']); + $double = Understudy::delegate(Pipeline::class, $real); + + $mirror = $double->mirror(); + + Assert::instanceOf($mirror, Pipeline::class); + Assert::false($mirror === $double); + Assert::same($mirror->describe(), 'prod:1'); + + // Its own object from there on: a step added to the copy is not the + // original's, and neither is the donor's. + $mirror->addStep('b'); + Assert::same($mirror->describe(), 'prod:2'); + Assert::same($double->describe(), 'prod:1'); + Assert::same($real->describe(), 'prod:1'); + + // A complete forwarding double, instance and all: turning the mode on + // again finds the instance it came from instead of refusing for want + // of one. + Understudy::forwarding($mirror); + Assert::same($mirror->describe(), 'prod:2'); + } + + /** + * The case nothing returns: a body that clones itself and computes over + * the copy. Under the proxy that copy was a real object; inline it is a + * double, and it has to go on running real code or the body silently + * computes over defaults. + */ + public function aCloneMadeInsideARealBodyKeepsWorking(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + + Assert::same($double->preview('b'), 'prod:2'); + + // The original is untouched by what the body did to its copy. + Assert::same($double->describe(), 'prod:1'); + } + + /** + * A clone the TEST makes is not inside any body, so nothing propagates: + * it is a fresh double of the right class, which is the documented rule + * for `clone $double`. Real bodies ran before the clone — one of them + * threw — and neither left the double looking like it was still running. + */ + public function aCloneMadeByTheTestIsAFreshDouble(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + + $double->describe(); + + try { + $double->fail('thrown from the real body'); + } catch (\DomainException) { + // The throw is the point: an unwound body must not stay on the + // stack of bodies that are running. + } + + $copy = clone $double; + + Assert::same($copy->describe(), ''); + } + + /** + * A static property is not state the double can hold a copy of — it + * belongs to the class, and both the donor and the double read the same + * one. The copy skips it and carries on to the properties declared after + * it. + */ + public function aStaticPropertyIsLeftToTheClass(): void + { + $before = Pipeline::$built; + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + + // One construction, the donor's: the double's constructor never ran. + Assert::same(Pipeline::$built, $before + 1); + + // And the instance properties declared after the static one arrived. + Assert::same($double->describe(), 'prod:1'); + } + + /** + * A `clone`-based wither declared `: static` is the other side of that: + * the copy was adopted while the body ran, with this double's forwarding, + * so the chain comes back working rather than refused. + */ + public function aClonedStaticWitherComesBackForwarding(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + + $copy = $double->copy(); + + Assert::false($copy === $double); + Assert::same($copy->recall(), 'copied'); + Assert::same($copy->describe(), 'prod:1'); + } + + /** + * `callOriginal()` on an inline double runs the parent body on the double, + * not on the donor: the donor's copy of the state stopped being the + * current one at the first inline call. + */ + public function callOriginalRunsInlineOnAnInlineDouble(): void + { + $real = new Pipeline('prod', ['a']); + $double = Understudy::delegate(Pipeline::class, $real); + + when(fn() => $double->addStep(Arg::any())) + ->answers(fn(Invocation $call) => $call->callOriginal()); + + // Fluent through the escape hatch: the parent body returned `$this`, + // which is the double. + Assert::same($double->addStep('b'), $double); + + // The double grew a step; the donor did not. + Assert::same($double->describe(), 'prod:2'); + Assert::same($real->describe(), 'prod:1'); + } + + /** + * An interface double has no bodies to reach, so `callOriginal()` — and + * forwarding itself — stays the proxy form, where a self-call happens + * inside the real object and nothing sees it. + */ + public function callOriginalStaysTheProxyFormOnAnInterfaceDouble(): void + { + $double = Understudy::delegate(Chainable::class, new RealChain()); + + when(fn() => $double->describe()) + ->answers(fn(Invocation $call) => $call->callOriginal()); + + Assert::same($double->describe(), 'described by real'); + Assert::same(count(Understudy::calls(fn() => $double->label())), 0); + } + + /** + * A donor that is a subclass carries overrides the doubled class's bodies + * do not contain. Running those bodies instead would change which code + * executes, so such a double keeps the proxy form: the override answers, + * and only the boundary call is recorded. + */ + public function aSubclassDonorKeepsTheProxyForm(): void + { + $double = Understudy::delegate(Pipeline::class, new SpecialPipeline('prod')); + + // SpecialPipeline::apply(), reached through the real object's own + // self-call — not Pipeline::apply() run on the double. + Assert::same($double->run(' x '), 'X !'); + Assert::same(count(Understudy::calls(fn() => $double->apply(Arg::any()))), 0); + } + + public function aSecondDonorIsRefusedOnAnInlineDouble(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('first')); + + Expect::exception(ForwardingTargetMismatch::class) + ->withMessageContaining('already runs the bodies of') + ->withMessageContaining('cannot be re-pointed') + ->withMessageContaining('Build a second double for the other instance'); + + Understudy::forwarding($double, new Pipeline('second')); + } + + /** + * The same instance twice is the test saying the same thing twice: the + * state copy already happened, and repeating it would be the write a + * `readonly` property refuses. + */ + public function pointingAnInlineDoubleAtTheSameDonorAgainIsIdempotent(): void + { + $real = new Pipeline('prod', ['a']); + $double = Understudy::delegate(Pipeline::class, $real); + + $double->addStep('b'); + Understudy::forwarding($double, $real); + + Assert::same($double->describe(), 'prod:2'); + } +} From b8a8f8efd15f0c23fe5aec33b48b9be6c4191d18 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Mon, 12 Oct 2026 23:01:47 +0300 Subject: [PATCH 2/4] Ask the 8.4-only ReflectionProperty members by name, so psalm passes on 8.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hasHooks()`, `isVirtual()`, `getRawValue()` and `setRawValue()` exist from PHP 8.4, and the state copy reaches them behind a `PHP_VERSION_ID` guard — but Psalm reflects the real `ReflectionProperty` of the PHP it runs on, so naming them directly is an `UndefinedMethod` under 8.3. `composer build` was green on a machine with 8.5 and red in the `PHP 8.3` and `Prefer lowest` matrix jobs, which is where this was caught. They are now asked through two private helpers that take the member name as a parameter, so nothing can resolve it — the idiom `Codegen\PropertyDefaults::isWritableFromOutside()` already uses for the same three members. Verified by running psalm under php:8.3-cli and php:8.4-cli, not only under the 8.5 the build image carries. --- src/Runtime/Runtime.php | 43 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/Runtime/Runtime.php b/src/Runtime/Runtime.php index 7245948..80d8997 100644 --- a/src/Runtime/Runtime.php +++ b/src/Runtime/Runtime.php @@ -1207,13 +1207,16 @@ public static function copyState(object $double, object $donor): void continue; } - if (\PHP_VERSION_ID >= 80400 && $property->hasHooks()) { + if (\PHP_VERSION_ID >= 80400 && self::propertyFlag($property, 'hasHooks')) { // A virtual property has no state to copy; a backed one // has its backing store, reachable without running the // hooks — reading through them would execute the donor's // code during what is test setup. - if (!$property->isVirtual() && $property->isInitialized($donor)) { - $property->setRawValue($double, $property->getRawValue($donor)); + if (!self::propertyFlag($property, 'isVirtual') && $property->isInitialized($donor)) { + self::propertyMember($property, 'setRawValue', [ + $double, + self::propertyMember($property, 'getRawValue', [$donor]), + ]); } continue; @@ -1243,6 +1246,40 @@ public static function copyState(object $double, object $donor): void } } + /** + * Asks a `ReflectionProperty` member that exists only from PHP 8.4. + * + * By name rather than as `$property->hasHooks()`, and the name arrives as + * a parameter so that nothing can resolve it: Psalm reflects the real + * `ReflectionProperty` of the PHP it runs on, so a direct call is an + * `UndefinedMethod` under 8.3 — green on a machine with 8.5 and red in + * the 8.3 matrix job, which is how this reached CI once. Same idiom as + * `Codegen\PropertyDefaults::isWritableFromOutside()`. + * + * @param non-empty-string $member + */ + private static function propertyFlag(\ReflectionProperty $property, string $member): bool + { + /** @var mixed $answer */ + $answer = $property->{$member}(); + + return $answer === true; + } + + /** + * Calls an 8.4-only `ReflectionProperty` member, for the same reason. + * + * @param non-empty-string $member + * @param list $args + */ + private static function propertyMember(\ReflectionProperty $property, string $member, array $args): mixed + { + /** @var mixed $value */ + $value = $property->{$member}(...$args); + + return $value; + } + /** * The same values, without the references. * From b91073a4819d1af4f71729429df76093ec026262 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Tue, 13 Oct 2026 21:20:53 +0300 Subject: [PATCH 3/4] Adopt a clone only when it is a copy of the double whose body is running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline forwarding adopted any clone of the running double's class, and one generated class serves every double of a type: a real body handed another double of its own class — `merge(self $other)` — and cloning that one gave the copy bodies to run with no state copy to run them over. Where the class declares a typed property that is PHP's own "must not be accessed before initialization" out of the clone; where every property has a declared default it is a quiet wrong answer computed from the declaration instead of the donor. Both were a regression: proxied, the copy answered defaults. `Runtime::isCopyOf()` now compares the two property tables with `get_mangled_object_vars()` — 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 what nobody initialized. A copy is property-identical to its source at this moment, because a generated `__clone()` does nothing but call `adoptClone()`. Two doubles of one class in the same state stay indistinguishable, because `__clone()` is handed no reference to its source: such a copy is still adopted and answers for real, over the state it already holds. That residual is documented where the rule is stated — both READMEs, the forwarding guide, the CHANGELOG — rather than called harmless. A `clone $double` written in the test's own `answers()` closure was adopted too, although the documented rule makes it a fresh double: the closure runs inside dispatch, which a real body can reach. `Runtime::$inlineFloor` hides the frames underneath for exactly the closure's span, so only what the closure itself enters — `callOriginal()`, which pushes a frame of its own — is visible to a clone made there. Five of the seven new tests fail or error on the previous commit: both forms of the foreign clone, the test closure under a real body, the comparison reading a hooked property through its backing store, and a plain property declared after a hooked and a virtual one deciding the verdict. The other two pin mechanisms that did not exist before it — the floor coming back after a throwing stub, and the hook that counts its own calls to show neither the state copy nor the comparison runs one. composer build green (Unit 1173, Integration 27, examples 8), full mutation 3586 mutants / 249 escaped / MSI 93% against the gate of 92, rector clean, roave reports only the pre-existing verifyAll(): void -> int from #154, bin/package-audit 0 errors, docs build and claims green. `make docs-vale` needs a vale binary this host has not got; the docs job lints the new prose. Perf was not re-measured. --- AGENTS.md | 27 +++- CHANGELOG.md | 12 +- README.md | 9 +- README.ru.md | 9 +- docs/src/guide/forwarding.md | 9 +- src/Runtime/Runtime.php | 88 +++++++++++-- tests/Fixture/Fwd/Pipeline.php | 13 ++ tests/Fixture/Fwd/Tally.php | 35 ++++++ tests/InlineForwardingTest.php | 221 +++++++++++++++++++++++++++++++++ 9 files changed, 407 insertions(+), 16 deletions(-) create mode 100644 tests/Fixture/Fwd/Tally.php diff --git a/AGENTS.md b/AGENTS.md index afef1a3..27809a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -322,10 +322,29 @@ seconds instead of the full run's minute — and the full run stays the gate. 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 because no body is running then; a body cloning a - *different* double of the same class would be misattributed, which is the - same blind spot `__clone()` has always had. `new static` cannot be adopted — - its constructor built a new object with its own state — so it is refused by + 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(self $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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf7713..d1bdd79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,16 @@ 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`). No name, signature or readonly field of the - public API changed. (#150) + (`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 diff --git a/README.md b/README.md index 221199e..f759f0c 100644 --- a/README.md +++ b/README.md @@ -931,7 +931,14 @@ Eight things are worth knowing before relying on it: 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. What is refused, with + 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 diff --git a/README.ru.md b/README.ru.md index 5d0841d..bee3323 100644 --- a/README.ru.md +++ b/README.ru.md @@ -928,7 +928,14 @@ when(fn () => $spy->get('key')) остаётся дублированной сама собой. `clone $this` тоже работает: копия принимается в момент исполнения тела и наследует форвардинг этого дубля — wither возвращается рабочим дублем, но без стабов этого дубля, по тому же - правилу, что и `clone $double` в тесте. Отвергается — + правилу, что и `clone $double` в тесте. Принимается только копия *этого* + дубля, держащая его состояние: тело, клонирующее другой переданный ему + дубль, получит самостоятельный дубль нужного класса — как и `clone $double`, + написанный в вашем собственном `answers()`-замыкании, даже если до него + добралось реальное тело. Два дубля одного класса в одинаковом состоянии + различить нельзя — `__clone()` не получает ссылки на то, что копировал, — + поэтому копия такого дубля принимается и отвечает по-настоящему: над тем + состоянием, которое она и держит, то есть над состоянием другого дубля. Отвергается — `OriginalReturnTypeViolation` — реальное тело, отдающее наружу `new static`: этот объект сгенерированного класса, тип возврата его принимает, но ни один контекст его не знает, и любой вызов на нём упал бы как на diff --git a/docs/src/guide/forwarding.md b/docs/src/guide/forwarding.md index 04b10df..45657f7 100644 --- a/docs/src/guide/forwarding.md +++ b/docs/src/guide/forwarding.md @@ -78,7 +78,14 @@ when(fn () => $spy->get('key')) 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. What is refused, with + 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 diff --git a/src/Runtime/Runtime.php b/src/Runtime/Runtime.php index 80d8997..fc6dcdc 100644 --- a/src/Runtime/Runtime.php +++ b/src/Runtime/Runtime.php @@ -54,6 +54,19 @@ final class Runtime */ private static array $inlineFrames = []; + /** + * How much of {@see $inlineFrames} a `clone` is allowed to see. + * + * A test's own `answers()`/`returns()` closure runs inside dispatch, which + * can be reached from a real body running inline — and a `clone $double` + * written there is the test's clone, which the documented rule makes a + * fresh double. The frames below the floor are the bodies that happen to + * be on the stack underneath; the floor is raised for the span of the + * closure, so only what the closure itself enters — `callOriginal()`, + * which pushes a frame of its own — is visible to a clone made inside it. + */ + private static int $inlineFloor = 0; + /** * A stack, not a single context: `scope()` nests one inside another, and * the outer one must come back intact when the inner ends. @@ -299,11 +312,23 @@ public static function adoptClone(object $clone): void // forwarding, because PHP already copied the state those bodies read // and the alternative is real code holding a double that answers // defaults. Expectations are still not copied — that part of the rule - // above stands, and a clone the TEST makes is untouched by this - // because no body is running then. - $frame = self::$inlineFrames === [] ? null : self::$inlineFrames[\count(self::$inlineFrames) - 1]; - - if ($frame !== null && $frame::class === $clone::class) { + // above stands, and a clone the TEST makes is untouched by this, + // whether no body is running at all or the test's own closure was + // reached from one ({@see $inlineFloor}). + $frames = self::$inlineFrames; + $frame = $frames === [] || \count($frames) <= self::$inlineFloor + ? null + : $frames[\count($frames) - 1]; + + // The class is not enough, and the same generated class stands in for + // every double of a type: a body handed another double of its own + // class — `merge(self $other)` — and cloning THAT would otherwise + // adopt it on the running double's behalf, giving it bodies to run and + // no state copy to run them over. What PHP leaves to go on is the copy + // itself, which at this moment is property-identical to whatever it + // was cloned from, because the generated `__clone()` does nothing but + // call this method. + if ($frame !== null && $frame::class === $clone::class && self::isCopyOf($clone, $frame)) { $origin = (self::ownerOf($frame) ?? $context)->stateOf($frame); $target = $origin?->forwardingTarget(); $parent = $origin?->inlineParent(); @@ -705,8 +730,19 @@ private static function answer( break; } - /** @var mixed $answer */ - $answer = $expectation->performAction($invocation); + // The test's own closure, so a `clone $double` inside it is the + // test's clone even when a real body is on the stack underneath. + // Two integers rather than a saved copy of the stack: this is the + // dispatch path, and every stubbed call in a suite pays for it. + $floor = self::$inlineFloor; + self::$inlineFloor = \count(self::$inlineFrames); + + try { + /** @var mixed $answer */ + $answer = $expectation->performAction($invocation); + } finally { + self::$inlineFloor = $floor; + } if ($signature !== null && $signature->returnsNever) { // The expectation returned instead of throwing; returning from @@ -1246,6 +1282,44 @@ public static function copyState(object $double, object $donor): void } } + /** + * Whether `$clone` is the copy PHP just took of `$frame`. + * + * Asked because `__clone()` is handed no reference to what it was cloned + * from, and the running body's double is a guess: one generated class + * serves every double of a type, so a body that was given another double + * of its own class and cloned that one looks identical from here. What + * separates them is the state, and this runs before anything could have + * changed it — a generated `__clone()` only calls {@see adoptClone()}, + * never the doubled class's own `__clone()` body, so the copy still holds + * exactly what it was copied from. + * + * `get_mangled_object_vars()` is the whole comparison, and it is the + * right reading rather than a shortcut: it answers with the object's own + * property table — private properties of every parent under their mangled + * names, which a reflection of the double's class cannot reach at all — + * and it answers without running anything. A hooked property comes back + * as its backing store, not through its `get`, which is the doubled + * class's code and must not run while `__clone()` is still deciding what + * this copy is; a virtual property has no entry, because it has no state; + * a static one belongs to the class both objects share; and a property + * nobody initialized is absent, so "the donor never set this" differs + * from any value at all. Two tables compare `===`: same keys in the same + * order — a copy cannot reorder them — and the same values, which for an + * object-valued property means the same object, as a copy holds. + * + * Two doubles in the same state stay indistinguishable, and that is the + * residual of PHP's blind spot rather than something this can close: such + * a copy is adopted and runs real bodies where a plain double would have + * answered defaults. It runs them over the state the copy already holds, + * so nothing reads a value the object never had — the readings are real, + * they are simply the other double's. + */ + private static function isCopyOf(object $clone, object $frame): bool + { + return get_mangled_object_vars($clone) === get_mangled_object_vars($frame); + } + /** * Asks a `ReflectionProperty` member that exists only from PHP 8.4. * diff --git a/tests/Fixture/Fwd/Pipeline.php b/tests/Fixture/Fwd/Pipeline.php index 4ea37be..1387ea1 100644 --- a/tests/Fixture/Fwd/Pipeline.php +++ b/tests/Fixture/Fwd/Pipeline.php @@ -119,6 +119,19 @@ public function copy(): static return $copy; } + /** + * Clones the instance it was handed rather than itself: the copy belongs + * to that double, and nothing copied state onto it — `$name` is readonly + * and uninitialized there, so a copy adopted on this double's behalf + * raises PHP's own "must not be accessed before initialization". + */ + public function mergeWith(self $other): string + { + $copy = clone $other; + + return $copy->describe(); + } + /** * What `static` resolves to inside a body running inline: the generated * class, not this one. diff --git a/tests/Fixture/Fwd/Tally.php b/tests/Fixture/Fwd/Tally.php new file mode 100644 index 0000000..414a0b6 --- /dev/null +++ b/tests/Fixture/Fwd/Tally.php @@ -0,0 +1,35 @@ +count = $count; + } + + public function total(): int + { + return $this->count * 10; + } + + /** Clones the double it was handed, not itself. */ + public function mergeWith(self $other): int + { + $copy = clone $other; + + return $copy->total(); + } +} diff --git a/tests/InlineForwardingTest.php b/tests/InlineForwardingTest.php index 43f4081..08491f9 100644 --- a/tests/InlineForwardingTest.php +++ b/tests/InlineForwardingTest.php @@ -16,6 +16,7 @@ use Rasuvaeff\Understudy\Tests\Fixture\Fwd\Pipeline; use Rasuvaeff\Understudy\Tests\Fixture\Fwd\RealChain; use Rasuvaeff\Understudy\Tests\Fixture\Fwd\SpecialPipeline; +use Rasuvaeff\Understudy\Tests\Fixture\Fwd\Tally; use Rasuvaeff\Understudy\Understudy; use Testo\Assert; use Testo\Codecov\Covers; @@ -232,6 +233,121 @@ public function aHookedPropertyIsCopiedThroughItsBackingStore(): void Assert::same($double->readPlain(), 'carried'); } + /** + * The comparison decides on every property, not on the first few: a + * hooked one, a virtual one and then a plain one that is the only thing + * telling the two doubles apart. Stopping at either of the first two + * would adopt a copy of the wrong double and run real bodies on it. + * + * Built by eval for the reason + * {@see aHookedPropertyIsCopiedThroughItsBackingStore()} gives. + */ + public function aPropertyDeclaredAfterAHookedOneStillDecidesTheComparison(): void + { + if (PHP_VERSION_ID < 80400) { + Assert::false(method_exists(\ReflectionProperty::class, 'hasHooks')); + + return; + } + + $class = 'HookedOrder_' . PHP_VERSION_ID; + + eval(sprintf( + 'namespace %s; class %s { ' + . 'public int $scaled { get => $this->scaled * 2; set { $this->scaled = $value; } } ' + . 'public string $virtual { get => \'computed\'; } ' + . 'public string $plain = \'declared\'; ' + . 'public function __construct(int $scaled, string $plain) ' + . '{ $this->scaled = $scaled; $this->plain = $plain; } ' + . 'public function tagline(): string { return \'real\'; } ' + . 'public function mergeWith(self $other): string ' + . '{ $copy = clone $other; return $copy->tagline(); } }', + __NAMESPACE__, + $class, + )); + + /** @var class-string $fqcn */ + $fqcn = __NAMESPACE__ . '\\' . $class; + + // The backing store of the hooked property agrees with what a + // constructor-less double holds, so only `$plain` — declared after + // both the hooked and the virtual property — separates them. + $double = Understudy::delegate($fqcn, new $fqcn(0, 'carried')); + $other = Understudy::for($fqcn); + + // A fresh double of that class: `tagline()` answers the empty string + // rather than the 'real' its body returns. + Assert::same($double->mergeWith($other), ''); + } + + /** + * The floor comes back down even when the test's closure throws. The + * stub's exception unwinds through dispatch and out of the real body that + * called it, and a floor left raised there would quietly stop adopting + * every clone a later body makes of itself. + */ + public function aThrowingStubLeavesCloneAdoptionWorking(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + + when(fn() => $double->apply(Arg::any()))->throws(new \RuntimeException('from the stub')); + + try { + $double->run('x'); + } catch (\RuntimeException) { + // The throw is the point: it leaves dispatch from inside a real + // body, which is the path that has to put the floor back. + } + + Assert::same($double->preview('b'), 'prod:2'); + } + + /** + * The claim both the state copy and the clone comparison rest on: neither + * runs a hook. A `get` hook is the doubled class's own code — reading + * through it would execute real code during what is setup, and during a + * `__clone()` that has not decided yet whether this copy is a double of + * anything. The hook counts its own calls, so a reading taken through it + * is visible rather than merely reasoned about. + * + * Built by eval for the reason + * {@see aHookedPropertyIsCopiedThroughItsBackingStore()} gives. + */ + public function neitherTheStateCopyNorTheCloneComparisonRunsAHook(): void + { + if (PHP_VERSION_ID < 80400) { + Assert::false(method_exists(\ReflectionProperty::class, 'hasHooks')); + + return; + } + + $class = 'CountingHooks_' . PHP_VERSION_ID; + + eval(sprintf( + 'namespace %s; class %s { public static int $reads = 0; ' + . 'public int $scaled { get { self::$reads++; return $this->scaled * 2; } ' + . 'set { $this->scaled = $value; } } ' + . 'public function __construct(int $scaled) { $this->scaled = $scaled; } ' + . 'public function mirror(): int { $copy = clone $this; return 1; } }', + __NAMESPACE__, + $class, + )); + + /** @var class-string $fqcn */ + $fqcn = __NAMESPACE__ . '\\' . $class; + $donor = new $fqcn(5); + $fqcn::$reads = 0; + + $double = Understudy::delegate($fqcn, $donor); + + // The copy reached the backing store, not the `get`. + Assert::same($fqcn::$reads, 0); + + // And neither did the comparison that adopted the body's own clone. + Assert::same($double->mirror(), 1); + Assert::same($fqcn::$reads, 0); + } + public function aFluentSelfReturnIsTheDoubleItself(): void { $double = Understudy::delegate(Pipeline::class, new Pipeline('prod')); @@ -344,6 +460,111 @@ public function aCloneMadeInsideARealBodyKeepsWorking(): void Assert::same($double->describe(), 'prod:1'); } + /** + * The other side of that: a body handed another double of its own class + * and cloning THAT one. The class is the same generated class — every + * double of a type shares it — so only the state separates the copy from + * a `clone $this`, and here nothing copied any state onto the double the + * body was given. Adopting it would run `describe()` over a readonly + * `$name` the skipped constructor never filled, which is PHP's own fatal + * rather than anything a test could read. + */ + public function aCloneOfAnotherDoubleInsideARealBodyIsAFreshDouble(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + $other = Understudy::for(Pipeline::class); + + Assert::same($double->mergeWith($other), ''); + + // Handed its own double, the same body clones the frame itself, and + // that copy is adopted as before. + Assert::same($double->mergeWith($double), 'prod:1'); + } + + /** + * The same mistake where nothing raises: a class whose every property has + * a declared default leaves a constructor-less double readable, so an + * adopted copy of it would quietly compute from the declaration's value + * instead of the donor's — 70 where the test set up 42. + */ + public function aCloneOfAnotherDoubleWithDeclaredDefaultsIsAFreshDouble(): void + { + $double = Understudy::delegate(Tally::class, new Tally(42)); + $other = Understudy::for(Tally::class); + + // A fresh double of `Tally`: `total()` is unconfigured, so it answers + // the type's default rather than running the real body at all. + Assert::same($double->mergeWith($other), 0); + Assert::same($double->mergeWith($double), 420); + } + + /** + * A clone the test writes in its own `answers()` closure, reached from a + * real body. The closure is test code — the rule for `clone $double` is + * the test's, not the body's — and the frames underneath it are hidden + * for exactly its span. + */ + public function aCloneMadeByATestClosureUnderARealBodyIsAFreshDouble(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + + when(fn() => $double->apply(Arg::any())) + ->answers(fn(): string => (clone $double)->describe()); + + // run() is the real body; it calls apply() on itself, the stub + // answers, and the clone made there is a fresh double whose + // describe() is the empty default rather than 'prod:1'. + Assert::same($double->run('ignored'), ''); + + // And the floor went back down with the closure: the next body to + // clone itself is adopted as before. + Assert::same($double->preview('b'), 'prod:2'); + } + + /** + * Comparing a copy against the frame reads a hooked property the way the + * state copy writes it — through the backing store. A `get` hook is the + * doubled class's own code, and running it to decide whether to adopt a + * clone would execute real code in the middle of `__clone()`. + * + * Built by eval for the reason + * {@see aHookedPropertyIsCopiedThroughItsBackingStore()} gives. + */ + public function aHookedPropertyIsComparedThroughItsBackingStore(): void + { + if (PHP_VERSION_ID < 80400) { + Assert::false(method_exists(\ReflectionProperty::class, 'hasHooks')); + + return; + } + + $class = 'HookedMerger_' . PHP_VERSION_ID; + + eval(sprintf( + 'namespace %s; class %s { public int $scaled { get => $this->scaled * 2; ' + . 'set { $this->scaled = $value; } } public string $virtual { get => \'computed\'; } ' + . 'public function __construct(int $scaled) { $this->scaled = $scaled; } ' + . 'public function read(): int { return $this->scaled; } ' + . 'public function mirror(): int { $copy = clone $this; return $copy->read(); } ' + . 'public function mergeWith(self $other): int { $copy = clone $other; return $copy->read(); } }', + __NAMESPACE__, + $class, + )); + + /** @var class-string $fqcn */ + $fqcn = __NAMESPACE__ . '\\' . $class; + $double = Understudy::delegate($fqcn, new $fqcn(5)); + $other = Understudy::for($fqcn); + + // Its own copy: the backing stores match, the copy is adopted, and + // the inherited `get` hook doubles the 5 that was copied over. + Assert::same($double->mirror(), 10); + + // A copy of the other double: the backing store is not the frame's, + // so it stays a fresh double and answers the default. + Assert::same($double->mergeWith($other), 0); + } + /** * A clone the TEST makes is not inside any body, so nothing propagates: * it is a fresh double of the right class, which is the documented rule From 8befa94658dfaa96302ae930d3b32c3ad98466fb Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Tue, 13 Oct 2026 21:34:41 +0300 Subject: [PATCH 4/4] Type the clone fixtures by class name: a `self` parameter is a fatal below 8.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixtures of the previous commit declared `mergeWith(self $other)`, and the suite died on PHP 8.3 and 8.4 before reaching a single test: Declaration of …\Generated\Understudy_c28436a2f78b6ee1::mergeWith( Understudy_c28436a2f78b6ee1|ArgumentMatcher|Absent $other = …): string must be compatible with …\Fixture\Fwd\Pipeline::mergeWith( Pipeline $other): string The generated override renders the parameter as `self`, which inside the generated class is the generated class — a subclass of the doubled one, so the widened union excludes the very type the contract accepts. PHP 8.5 lets it through, which is why the authoring host and the 8.5 job were green while every other job was not. That is a codegen gap of its own, not something this branch introduced: no fixture had a `self`-typed parameter before. It is filed separately, and the regression fixtures here name their class explicitly, which is what a user's class usually does anyway. Unit suite 1173 green on 8.3, 8.4 and 8.5 (the first two in `php:8.N-cli` directly, since `composer:2` is 8.5 and cannot see this); `composer build` and `bin/package-audit` green. --- AGENTS.md | 2 +- src/Runtime/Runtime.php | 2 +- tests/Fixture/Fwd/Pipeline.php | 2 +- tests/Fixture/Fwd/Tally.php | 2 +- tests/InlineForwardingTest.php | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 27809a6..91e97c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -326,7 +326,7 @@ seconds instead of the full run's minute — and the full run stays the gate. 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(self $other)` — and cloning + 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 diff --git a/src/Runtime/Runtime.php b/src/Runtime/Runtime.php index fc6dcdc..f58cdaa 100644 --- a/src/Runtime/Runtime.php +++ b/src/Runtime/Runtime.php @@ -322,7 +322,7 @@ public static function adoptClone(object $clone): void // The class is not enough, and the same generated class stands in for // every double of a type: a body handed another double of its own - // class — `merge(self $other)` — and cloning THAT would otherwise + // class — `merge(Basket $other)` — and cloning THAT would otherwise // adopt it on the running double's behalf, giving it bodies to run and // no state copy to run them over. What PHP leaves to go on is the copy // itself, which at this moment is property-identical to whatever it diff --git a/tests/Fixture/Fwd/Pipeline.php b/tests/Fixture/Fwd/Pipeline.php index 1387ea1..852b69b 100644 --- a/tests/Fixture/Fwd/Pipeline.php +++ b/tests/Fixture/Fwd/Pipeline.php @@ -125,7 +125,7 @@ public function copy(): static * and uninitialized there, so a copy adopted on this double's behalf * raises PHP's own "must not be accessed before initialization". */ - public function mergeWith(self $other): string + public function mergeWith(Pipeline $other): string { $copy = clone $other; diff --git a/tests/Fixture/Fwd/Tally.php b/tests/Fixture/Fwd/Tally.php index 414a0b6..20ddc8f 100644 --- a/tests/Fixture/Fwd/Tally.php +++ b/tests/Fixture/Fwd/Tally.php @@ -26,7 +26,7 @@ public function total(): int } /** Clones the double it was handed, not itself. */ - public function mergeWith(self $other): int + public function mergeWith(Tally $other): int { $copy = clone $other; diff --git a/tests/InlineForwardingTest.php b/tests/InlineForwardingTest.php index 08491f9..f6af74d 100644 --- a/tests/InlineForwardingTest.php +++ b/tests/InlineForwardingTest.php @@ -260,7 +260,7 @@ public function aPropertyDeclaredAfterAHookedOneStillDecidesTheComparison(): voi . 'public function __construct(int $scaled, string $plain) ' . '{ $this->scaled = $scaled; $this->plain = $plain; } ' . 'public function tagline(): string { return \'real\'; } ' - . 'public function mergeWith(self $other): string ' + . 'public function mergeWith(%2$s $other): string ' . '{ $copy = clone $other; return $copy->tagline(); } }', __NAMESPACE__, $class, @@ -546,7 +546,7 @@ public function aHookedPropertyIsComparedThroughItsBackingStore(): void . 'public function __construct(int $scaled) { $this->scaled = $scaled; } ' . 'public function read(): int { return $this->scaled; } ' . 'public function mirror(): int { $copy = clone $this; return $copy->read(); } ' - . 'public function mergeWith(self $other): int { $copy = clone $other; return $copy->read(); } }', + . 'public function mergeWith(%2$s $other): int { $copy = clone $other; return $copy->read(); } }', __NAMESPACE__, $class, ));