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
27 changes: 22 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,8 +299,12 @@ seconds instead of the full run's minute — and the full run stays the gate.
the signature path. Every other magic method — including `__get`, `__set`,
`__call`, `__invoke` and `__toString` — accepts the widened
`OriginalType|ArgumentMatcher` parameter union on 8.3, 8.4 and 8.5.
- **Never call `getDefaultValue()` to find out what a default is.** On
`= new Foo()` it runs the constructor. The default's source expression comes
- **Never call `getDefaultValue()` to find out what a default is at generation
time.** On `= new Foo()` it runs the constructor. Dispatch is the exception,
and the only one: `MethodSignature::defaultAt()` calls it for an argument the
caller omitted, which is the moment PHP itself would have evaluated the
initializer — and per call, so `= new Foo()` still builds one object per
call rather than sharing one. The default's source expression comes
from `ReflectionParameter::__toString()`, which renders it fully qualified
without reading the declaring file — and is the only way to see a `new`
default without evaluating it. Two things it does not qualify:
Expand Down Expand Up @@ -383,9 +387,22 @@ seconds instead of the full run's minute — and the full run stays the gate.
divergent return types (covariant) and by-reference mismatches.
- **Generated methods collect arguments by name, never `func_get_args()`**,
which omits parameters left at their default — `tag('alpha')` and
`tag('alpha', 1)` must record as the same call.
- **`= null` on a non-nullable parameter is a deprecated implicit nullable.**
When a parameter becomes optional through unification, `null` joins the type.
`tag('alpha', 1)` must record as the same call. `func_num_args()` cannot
stand in for the sentinel either: with named arguments PHP reports the
*filled* count, so a specification that skipped a middle parameter would be
invisible.
- **Every generated parameter defaults to the arity sentinel, optional ones
included.** That is what lets a specification leave a parameter unspelled and
*mean* it: a materialized default in its place is indistinguishable from
spelling the default value, which made arity an implicit part of every
specification (#123). Dispatch puts the contract's default back for a real
call, so `tag('alpha')` and `tag('alpha', 1)` still log as one call. Two
consequences to hold on to: a double's generated signature no longer
advertises the contract's defaults, and `null` joins the type only where **no
target declares a default** — a position optional because another target does
not declare it at all. Widening on the rendered default is what made
`mixed $v = null` render as `mixed|null`, an uncatchable fatal out of
`eval()`.
- **Mutation numbers lie unless every class has its own `#[Covers]` test.**
Infection's mutant-to-test mapping is `#[Covers]`-driven: with one test class
covering one class, the run reported 56 mutants at 93% MSI while the honest
Expand Down
82 changes: 81 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,86 @@
# Changelog

## Unreleased
## 0.10.0 — 2026-09-06

The wave the 1.0 candidate turned out to still need: ten defects found by
migrating the monorepo onto the library and by probing the engine against its
own documentation. Two of them change what a specification means, which is why
this is a minor and not the tag after 0.9.0.

### A specification is read the way the contract reads a call

- **An optional parameter a specification did not spell no longer arrives as
the contract's default value.** It used to, and arity therefore became an
implicit part of every specification:
`claimReady(Arg::any(), Arg::any(), Arg::any())` did not match
`claimReady($now, 3, [], 100)` on a method whose fourth parameter defaults to
`1000`, and the report said `never called` beside a call whose only
difference was a position the author never wrote. Every generated parameter
now defaults to the arity sentinel, optional ones included, and what an
omission means follows the contract: one it declares **optional** may be left
out by any caller, so a specification that leaves it out says nothing about
it and matches whatever was passed there; one it declares **required** is
present in every real call, so stopping before it still has to be said with
`Arg::rest()`. A failure message renders an unspelled position as `…`, which
is what tells it apart from an `any()` the test did write. Found migrating
`yii3-outbox` and `yii3-centrifugo` (#123).
- **`Arg::rest()` works where the remaining parameters are optional.** The
matcher list called it "declared parameters left unspelled" and the engine
refused it — `translate(Arg::rest())` on a signature with four optional
parameters raised "`rest()` … may only be the last argument" about argument
#1 — because the optional ones had already become literals by the time the
check looked. Same root cause, and the docs, the message and the matcher now
agree (#125).
- **A real call is unchanged**: dispatch materializes the declared default for
an argument the caller omitted, so `tag('alpha')` and `tag('alpha', 1)` are
still one call in the log. What changed with it is that a double's
*generated signature* no longer advertises the contract's defaults — it
carries the sentinel — so Reflection over a double reports them differently
than Reflection over the contract.
- **`mixed $v = null` was a fatal error.** The nullability widening applied to
`mixed` too, and `mixed|null` is a type PHP refuses at compile time: an
uncatchable fatal out of `eval()`, for a signature that is neither exotic nor
rare. The widening now belongs to the branch where the union is real, and is
driven by what the contract declares rather than by what the double renders.

### Refusals for what used to be silent

- **A captor inside `Arg::allOf()`, `anyOf()`, `not()` or `containing()`
matched and recorded nothing.** The specification behaved correctly in every
observable way except the one it was written for, and the only way to find
out was an assertion on an empty captor further down the test. It is refused
where it is written (#124).
- **A matcher inside a plain array argument matched nothing** —
`find(['id' => Arg::any()])` compares by identity — and said nothing about
it. Refused, and `Arg::containing()` now reads matchers in its own entries,
nested, so there is something to be refused *towards*.
- **Understudy's own refusals are no longer rewrapped** in "the specification
closure threw before it reached an understudy", which buried the sentence
that said what to change.
- **A by-reference slot was chosen from arguments dispatch had not yet
completed.** `referenceSlot()` asks which expectation will answer *before*
dispatch, and asked with the omitted arguments still sentinels: a
specification spelling the contract's default answered "nothing configured",
so the slot kept what the test had written through the reference instead of
being replaced by the configured value. The call answered correctly and the
next read did not.
- **A protocol step due on another double says so.** Two doubles under one
`expectSequence()` render every step by its call alone, so `count()` arriving
on the wrong one read as the step that was due — identical text, and no hint
that the difference was the receiver.

### Additions

- **`Understudy::strict()` and `Understudy::label()` answer with the double
they configured**, so the mode can be chosen where the double is handed over:
`ClientInterface::class => Understudy::strict(Understudy::for(ClientInterface::class))`
used to store `null` and fail three steps away from the cause (#126).
- **`WhenBuilder::throwsWith()`** builds the exception from the call it
answers, one per call — the shape `throws()` cannot express, and which
everyone re-derived as a throwing `answers()` closure (#127).
- **`Invocation::arg()`** reads one argument by position or by the contract's
own parameter name, and refuses a name the method does not declare rather
than answering `null` (#127).

- **The API reference documents the satellites at their current versions.**
`docs/.api-workspace/composer.json` pinned `understudy-psalm ^0.2`,
Expand Down
65 changes: 60 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,19 +292,33 @@ fallback should handle later calls.
| `Arg::remaining()` | the whole variadic tail, any length — last argument only |
| `Arg::rest()` | declared parameters left unspelled — last argument only |

An **optional** parameter needs no matcher at all. The contract says a caller
may leave it out, so a specification that leaves it out says nothing about it
and matches whatever the call passed there:

```php
// claimReady(DateTimeImmutable $t, int $max, array $kinds = [], int $limit = 1000)
verify(fn () => $storage->claimReady(Arg::any(), Arg::any(), Arg::any()), times: 1);
```

matches `claimReady($now, 3, [], 100)`. A failure message renders the
positions the specification never mentioned as `…`, so the report tells the
two apart: `claimReady(any(), any(), any(), …)`.

`Arg::rest()` and `Arg::remaining()` differ in what they stand for:
`remaining()` matches the variadic tail a method declares, while `rest()` says
"the arguments before me matter, the rest of the arity does not" — it is the
one matcher that lets a specification stop before the method's required
one matcher that lets a specification stop before the method's **required**
parameters run out:

```php
when(fn () => $storage->recordOutcome('svc', Arg::rest()))
->throws(new RuntimeException('storage unavailable'));
```

A specification that stops early *without* ending in `Arg::rest()` is refused
with the reason, rather than becoming a stub that silently never matches. A
A specification that stops before a required parameter *without* ending in
`Arg::rest()` is refused with the reason, rather than becoming a stub that
silently never matches. A
later, narrower specification for the same call still wins over the broad
prefix stub. A static analyser reads the shortened call against the contract's
arity; the [understudy-psalm](https://github.com/rasuvaeff/understudy-psalm)
Expand All @@ -328,6 +342,13 @@ The pattern is yours and is used as written, PCRE semantics included: `$`
matches before a trailing newline, so `Arg::string('/^ord-\d+$/')` accepts
`"ord-1\n"`. Anchor with `\z` (or add the `D` modifier) where that matters.

A matcher inside a plain array argument is refused for the same reason: an
array is compared by identity, so `find(['id' => Arg::any()])` would match
nothing and say nothing about it. `Arg::containing()` is the matcher that
describes part of an array, and it reads matchers in its own entries —
`Arg::containing(['id' => Arg::int(min: 1)])`, nested as deep as the payload
goes.

`Arg::which()` calls only a public, non-static method that needs no arguments.
A getter that throws counts as a mismatch, never as an error — matching runs
while the code under test is executing, and a matcher must not be the thing
Expand Down Expand Up @@ -356,7 +377,11 @@ call the other arguments rejected captures nothing. It works in `when()`,
`expect()` and `verify()` alike; a `verify()` captures from the calls it just
claimed, the Mockito reading. A `capture()` inside an `expectSequence()` step
matches but does not record — capture at declaration or at verification, not
in a protocol. `last()` on a captor that captured nothing
in a protocol. A captor inside `Arg::allOf()`, `anyOf()`, `not()` or
`containing()` is refused where it is written: a combinator asks its operands
whether they match, and a captor there would accept the call and record
nothing — correct in every observable way except the one it was written for.
`last()` on a captor that captured nothing
raises `NothingCaptured`; `all()` answers an empty list. Captured values live
exactly as long as the call log: `reset()` and a closing `Understudy::scope()`
drop them, and the captor object is then simply empty again.
Expand Down Expand Up @@ -411,6 +436,21 @@ when(fn () => $breaker->call($operation))

One link per call, and the last link keeps answering once the chain runs out.

`throwsWith()` is `throws()` for an exception that has to carry what the call
was made with — `throws()` takes an instance, which cannot know:

```php
when(fn () => $publisher->publish(Arg::any()))
->throwsWith(fn (Invocation $call) => new PublishException(
message: 'Publish failed',
outboxMessage: $call->arg('message'),
));
```

One exception per call, where `throws()` is one instance for all of them. A
throwing `answers()` closure does the same thing and keeps working; this reads
as what it is at the call site.

### Verifying

```php
Expand Down Expand Up @@ -568,13 +608,18 @@ use Rasuvaeff\Understudy\Arg;
$calls = Understudy::calls(fn () => $repository->find(Arg::any()));

$calls[0]->args; // [123]
$calls[0]->arg('id'); // 123 — by the contract's own parameter name
$calls[0]->didReturn(); // true
$calls[0]->returned(); // the value it answered with
$calls[1]->thrown(); // the throwable, if it threw
```

`null` is a valid return value, which is why the outcome is asked about
(`didReturn()`) rather than inferred from the value.
(`didReturn()`) rather than inferred from the value. `arg()` takes a position
or the contract's parameter name and refuses a name the method does not
declare, rather than answering `null` — which is a value an argument can
legitimately have. An argument the caller omitted reads as the contract's
default, exactly as it does in `args`.

```php
$last = Understudy::lastCall(fn () => $repository->find(Arg::any()));
Expand Down Expand Up @@ -607,8 +652,18 @@ One-way, like every other form of forgetting here.
|---|---|
| Loose (default) | a type-safe default: `null`, `0`, `''`, `[]`, an empty generator … |
| Strict (`Understudy::strict($double)`) | an immediate failure naming the method, the call, and what did not accept it |

| Forwarding (`Understudy::forwarding($double, $real)`) | whatever the real instance answers, recorded like any other call |

`Understudy::strict()` and `Understudy::label()` answer with the double they
configured, so the mode can be chosen where the double is handed over:

```php
$definitions = [
ClientInterface::class => Understudy::strict(Understudy::for(ClientInterface::class)),
];
```

A loose double never invents a value by running someone else's constructor, and
never hands back an unconstructed instance of a real class. What it can hand
back is another understudy: a return type that can itself be doubled becomes
Expand Down
Loading
Loading