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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,12 +486,12 @@ Commands: `make docs-install`, `make docs-api`, `make docs-dev`,
need PHP; the rest are Node, so the site stays buildable without a PHP
toolchain.

Since the 2026-09-18 review wave: the null-safe reads in
`Runtime::probeAnswer()` and `Understudy::rejectNestedCalls()` (a generated
Since the 2026-09-18 review wave: the null-safe reads in the recording
branch of `Runtime::dispatch()` and in `Understudy::record()` (a generated
class always has a blueprint, and the signal's double is registered unless
the specification names a forgotten one — a path the message tolerates with
`understudy`), the `nested: true` flag of the probe's default (the throwaway
context observes nothing either way), the value written into the
`understudy`), the `nested: true` flag of the recording's default (the
throwaway context observes nothing either way), the value written into the
`droppedByScope` map (only membership is read), the Fiber branch of
`popScope()` passing `byScope` (no Fiber scope test exists — add one if the
branch grows), and the trailing `return true` of the intersection loop in
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## 0.12.0 — 2026-09-19

- **Fixed.** A specification closure is run exactly once. Since 0.11.0 the
recording, which ended on the first double call, re-ran the closure a second
time to see whether another call hid in the arguments of the first — and
the closure is user code: a counter in it went up twice, a factory built
twice, an `Understudy::for()` inside it created a second double that stayed
in the context and failed `verifyAll(strictStubs: true)` as a stub never
used. The recording now keeps going past a call instead of ending on it:
the dispatcher retains the call and answers it with the mode's type-safe
default out of a throwaway context, so every call the closure makes is
seen in one pass. A method with no safe default (`: never`, an object
type) still ends the closure on its signal, as every recording used to.
Code after the call now runs during a recording, against that default; if
it does not survive it (a call on the `null` a `?Book` answers with, a
closure return type the default does not fit), the specification stands —
that code was never run before and is not the specification. (#142)
- **Changed.** Two calls side by side — `when(fn () => $r->count() .
$r->describe())` — are refused like a nested pair, and the refusal reads
"calls `count()` … and then `describe()` …" for both shapes; the earlier
wording assumed the nested one. `InvalidCallSpecification::nestedCall()` is
`moreThanOneCall()`.

## 0.11.0 — 2026-09-18

- **Fixed.** An interface extending `Throwable`, `DateTimeInterface`,
Expand Down
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,13 +290,17 @@ same as uncalled, so not even `strictStubs` reports it. An exhausted
call-count expectation keeps answering the matching call, so use a
non-overlapping matcher when a broad fallback should handle later calls.

The closure makes exactly one call on a double. A call nested in the arguments
of another — `when(fn () => $repo->find($repo->count()))` — is refused with
`InvalidCallSpecification` naming both: the inner call would have been
specified silently and the outer one not at all. Stub the inner call in a
`when()` of its own and pass a literal or a matcher. A required parameter has
to be spelled; `Arg::rest()` says the ones after it do not matter, so a wide
signature does not need an `Arg::any()` per position.
The closure makes exactly one call on a double, and runs exactly once. Two
calls — nested in the arguments of another, `when(fn () => $repo->find($repo->count()))`,
or side by side — are refused with `InvalidCallSpecification` naming both:
with two, one of them would be specified and the other not. Stub each call
in a `when()` of its own and pass a literal or a matcher where one fed the
other. A recording answers the call with the type's default (`null` for
`?Book`, `0` for `int`) and lets the closure finish, so code after the call
runs against that default; if it does not survive it, the specification
stands. A required parameter has to be spelled; `Arg::rest()` says the ones
after it do not matter, so a wide signature does not need an `Arg::any()` per
position.

`returns()` is checked against the declared return type where it is written:
a value on a `: void` method (`returns(null)` is allowed — it is the idiom for
Expand Down
18 changes: 11 additions & 7 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,13 +288,17 @@ catch-all, зарегистрированный *после* точечного,
точечный не диагностируется — недостижимый не значит невызванный, и даже
`strictStubs` его не покажет.

Замыкание делает ровно один вызов на дубле. Вызов, вложенный в аргументы
другого — `when(fn () => $repo->find($repo->count()))`, — отвергается
`InvalidCallSpecification` с обоими именами: внутренний вызов был бы
застаблен молча, а внешний — не застаблен вовсе. Застабьте внутренний
отдельным `when()` и передайте литерал или матчер. Обязательный параметр надо
записать; `Arg::rest()` говорит, что остальное неважно, и широкой сигнатуре
не нужен `Arg::any()` на каждую позицию.
Замыкание делает ровно один вызов на дубле и выполняется ровно один раз.
Два вызова — вложенный в аргументы другого, `when(fn () => $repo->find($repo->count()))`,
или два подряд — отвергаются `InvalidCallSpecification` с обоими именами:
из двух один был бы застаблен, а другой нет. Застабьте каждый отдельным
`when()`, а там, где один вызов питал аргументы другого, передайте литерал
или матчер. Запись отвечает на вызов дефолтом типа (`null` для `?Book`, `0`
для `int`) и даёт замыканию дойти до конца, так что код после вызова
исполняется с этим дефолтом; если он его не переживает, спецификация
остаётся в силе. Обязательный параметр надо записать; `Arg::rest()` говорит,
что остальное неважно, и широкой сигнатуре не нужен `Arg::any()` на каждую
позицию.

`returns()` сверяется с объявленным типом возврата там, где написан: значение
на `: void`-методе (`returns(null)` разрешён — это идиома «ничего не
Expand Down
10 changes: 6 additions & 4 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ No runtime deps; `ext-mbstring` is not needed.
covers it where the contract declares it required. A real call still logs the
contract's default for an argument the caller omitted, so `tag('a')` and
`tag('a', 1)` are one call.
- The closure must contain **exactly one** direct call on a double. Anything
else raises `InvalidCallSpecification` — including a call nested in the
arguments of another (`fn () => $repo->find($repo->count())`), which is
refused naming both calls rather than silently specifying the inner one.
- The closure must contain **exactly one** direct call on a double, and it
runs exactly once. Two calls — nested in the arguments of another
(`fn () => $repo->find($repo->count())`) or side by side — raise
`InvalidCallSpecification` naming both. The recording answers the call with
the type's default and lets the closure finish; code after the call that
does not survive the default does not fail the specification.
- `returns()` is checked against the declared return type at registration:
a value on `: void` (`returns(null)` is allowed), anything on `: never`, and
a value the type cannot hold (`null` on a non-nullable type, an array or an
Expand Down
33 changes: 17 additions & 16 deletions src/Exception/InvalidCallSpecification.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,26 +38,27 @@ public static function noCallRecorded(): self
}

/**
* The closure called one double method while evaluating the arguments of
* another. The inner call is what the recording saw; the outer call is
* what the test meant, and neither reading is safe to guess.
* The closure called more than one double method. Nested in the arguments
* of another (`find($r->count())`) or one after the other (`a() + b()`),
* the recording cannot tell which one the test meant, and neither reading
* is safe to guess.
*
* @param non-empty-string $innerLabel the double the inner call was made on
* @param non-empty-string $innerMethod the inner call, the one the recording saw
* @param non-empty-string $outerLabel the double the outer call was made on
* @param non-empty-string $outerMethod the outer call, whose arguments held the inner one
* @param non-empty-string $firstLabel the double the first call was made on
* @param non-empty-string $firstMethod the first call dispatched — for a nested pair, the inner one
* @param non-empty-string $lastLabel the double the last call was made on
* @param non-empty-string $lastMethod the last call dispatched — for a nested pair, the outer one
*/
public static function nestedCall(string $innerLabel, string $innerMethod, string $outerLabel, string $outerMethod): self
public static function moreThanOneCall(string $firstLabel, string $firstMethod, string $lastLabel, string $lastMethod): self
{
return new self(sprintf(
'The specification closure calls `%s()` on understudy `%s` while evaluating the arguments of `%s()` '
. 'on understudy `%s`. A closure must contain exactly one direct call on a double; the inner call would '
. 'have been specified silently and the outer one not at all. Stub the inner call in a when() of its own '
. 'and pass a literal or a matcher here, for example: when(fn () => $repository->find(Arg::any()))',
$innerMethod,
$innerLabel,
$outerMethod,
$outerLabel,
'The specification closure calls `%s()` on understudy `%s` and then `%s()` on understudy `%s`. '
. 'A closure must contain exactly one direct call on a double; with two, one of them would be specified '
. 'silently and the other not at all. Stub each call in a when() of its own, and where one call feeds the '
. 'arguments of another pass a literal or a matcher instead, for example: when(fn () => $repository->find(Arg::any()))',
$firstMethod,
$firstLabel,
$lastMethod,
$lastLabel,
));
}

Expand Down
99 changes: 28 additions & 71 deletions src/Runtime/Runtime.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,70 +95,6 @@ final class Runtime
*/
private static array $live = [];

/**
* The calls a specification closure made while it was re-run in probe
* mode, or `null` outside one. See {@see probe()}.
*
* @var list<array{object, non-empty-string}>|null
*/
private static ?array $probed = null;

/**
* Re-runs a specification closure with every double call answered instead
* of signalled, and reports the calls it made.
*
* The recording proper throws on the FIRST dispatch — that is what lets a
* `: ?Book` or a `: never` method hand its name back without producing a
* value — but the first dispatch is the innermost call, so a closure like
* `fn () => $r->find($r->count())` was abandoned before `find()` was ever
* seen and silently specified `count()`. Here the closure runs again, each
* call answered with the mode's type-safe default out of a throwaway
* context, and the caller refuses the specification when more than one
* call turns up.
*
* Everything about this pass is advisory. A default may not exist (a
* `: never` method, a return type nothing can stand in for) and the code
* after a call may not survive a default (`->title` on the `null` a
* `?Book` answers with): any throwable ends the probe as inconclusive and
* the specification the first pass recorded stands, exactly as before.
* Nothing the probe creates is registered anywhere that verification,
* accounting or `idle()` can see.
*
* @return list<array{object, non-empty-string}>|null the calls, in the
* order they were made; `null` when the probe could not finish
*/
public static function probe(callable $call): ?array
{
// Called from inside the recording that the first pass opened, so the
// dispatcher already routes every call here; nothing to open or close.
\assert(self::current()->isRecording());
self::$probed = [];

try {
$call();

return self::$probed;
} catch (\Throwable) {
return null;
} finally {
self::$probed = null;
}
}

/**
* The probe's answer to one call: recorded, then the type-safe default the
* mode would give, built in a context nobody else holds.
*
* @param non-empty-string $method
*/
private static function probeAnswer(object $double, string $method): mixed
{
self::$probed[] = [$double, $method];
$signature = DoubleFactory::blueprintOfGenerated($double::class)?->method($method);

return TypeDefaultResolver::forSignature('probe', $signature, $method, new RuntimeContext(), nested: true, double: $double);
}

public static function current(): RuntimeContext
{
$stack = self::stack();
Expand Down Expand Up @@ -545,11 +481,31 @@ public static function dispatch(object $double, string $method, array $args): mi
$current = self::current();

if ($current->isRecording()) {
if (self::$probed !== null) {
return self::probeAnswer($double, $method);
// The signal is retained, not thrown: the closure goes on, so a
// second call — `fn () => $r->find($r->count())` dispatches
// `count()` first — is seen and refused by the recorder instead
// of being specified silently. The call is answered with the
// mode's type-safe default out of a context nobody else holds,
// `nested` so that no collaborator is invented along the way.
// Where no default exists (`: never`, an object type) the signal
// is thrown after all and the closure ends here, as every
// recording used to.
$signal = new InvocationSignal($double, $method, $args);
$current->recordSpecificationCall($signal);
$signature = DoubleFactory::blueprintOfGenerated($double::class)?->method($method);

try {
return TypeDefaultResolver::forSignature(
self::stateOf($double)?->label() ?? 'understudy',
$signature,
$method,
new RuntimeContext(),
nested: true,
double: $double,
);
} catch (\Throwable) {
throw $signal;
}

throw new InvocationSignal($double, $method, $args);
}

self::rejectLeakedMatchers($method, $args);
Expand Down Expand Up @@ -982,9 +938,10 @@ public static function referenceSlot(object $double, string $method, array $args
/** @var mixed $value */
$value = self::dispatch($double, $method, $args);

if (self::$probed !== null) {
// A probe answers with a throwaway: the per-method slot is state
// the test can observe through the reference it already holds.
if (self::current()->isRecording()) {
// A recording answers with a throwaway: the per-method slot is
// state the test can observe through the reference it already
// holds, and the closure's own value must not land in it.
$slot = new ReferenceSlot();
$slot->value = $value;

Expand Down
18 changes: 18 additions & 0 deletions src/Runtime/RuntimeContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ final class RuntimeContext
*/
private int $recordingDepth = 0;

/** @var list<InvocationSignal> */
private array $recordingSignals = [];

/** @var \SplObjectStorage<object, DoubleState> */
private \SplObjectStorage $doubles;

Expand Down Expand Up @@ -78,6 +81,10 @@ public function isRecording(): bool

public function beginRecording(): void
{
if ($this->recordingDepth === 0) {
$this->recordingSignals = [];
}

$this->recordingDepth++;
}

Expand All @@ -88,6 +95,17 @@ public function endRecording(): void
}
}

public function recordSpecificationCall(InvocationSignal $signal): void
{
$this->recordingSignals[] = $signal;
}

/** @return list<InvocationSignal> */
public function recordingSignals(): array
{
return $this->recordingSignals;
}

public function register(object $double, DoubleState $state): void
{
$this->doubles[$double] = $state;
Expand Down
Loading
Loading