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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/.gitignore export-ignore
/.github export-ignore
/.php-cs-fixer.php export-ignore
/.vale.ini export-ignore
/AGENTS.md export-ignore
/CLAUDE.md export-ignore
/examples export-ignore
Expand Down
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ service methods on the double itself.

Public API lives in `Rasuvaeff\Understudy`: the `Understudy` facade, the free
functions `when()`/`expect()`/`expectSequence()`/`verify()` in
`src/functions.php`, `Arg`, `Invocation`, `Outcome`, `WhenBuilder`, and the
exceptions under `Exception\`. Everything
under `Codegen\`, `Runtime\`, `Expectation\`, `Defaults\` and `Matcher\` is
`@internal`.
`src/functions.php`, `Arg`, `Captor`, `Invocation`, `WhenBuilder`/`ExpectBuilder`,
`FailureKind`, `VerificationFailure`, and the exceptions under `Exception\`.
`Outcome` and `Cardinality` are `@internal` since 0.9 — no public path accepts
or returns either — like everything under `Codegen\`, `Runtime\`,
`Expectation\`, `Defaults\` and `Matcher\`.

The design plan that drove milestones 0-7 shipped in full and was retired
from the monorepo on 2026-08-28; what it decided lives in this file, the
Expand Down
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,80 @@
# Changelog

## 0.9.0 — 2026-09-05

The 1.0 candidate. A minor rather than a patch, and the last one before the
contract freezes: every decision of the 1.0-readiness review lands here, so
that the tag after this one can be `1.0.0` without a change to the surface.
Two of them are breaking on 0.x and Composer's caret already treats them so.

- **`forget()` answers `ForgottenDouble` for a double that is gone.** Called a
second time on the same double, or on one a `reset()` already dropped, it
said «Understudy::forget() expects an understudy created by Understudy::for().
This object is not one» — an `InvalidCallSpecification` about an object that
was one. It now goes through the same door as every other facade, so the
answer is `ForgottenDouble` (retired, or gone with a reset), a stranger is
still refused by name, and a foreign context is still
`ContextOwnershipViolation`.
- **One rule for the two specification exceptions, and two factories move.**
`InvalidCallSpecification` is about the SHAPE of a specification — what is
called, where, how many times — and `InvalidSpecificationArgument` about a
VALUE inside it that no run could act on. An inverted `Arg::int/float/count()`
range and an `Arg::string()` pattern PCRE cannot compile are values, so
`invertedBounds()` and `invalidPattern()` move to `InvalidSpecificationArgument`
beside `Arg::instanceOf()`'s unloadable type, `times(5, 2)` and `returns()`.
A `catch (InvalidCallSpecification)` around those two paths has to become
`catch (InvalidSpecificationArgument)` — or `catch (UnderstudyError)`, which
both implement. Both classes say the rule in their docblocks.
- **`Outcome` and `Cardinality` are `@internal`.** No public path accepted or
returned either: dispatch records outcomes through `Invocation`'s scalar
recorders and `Invocation::$outcome` stayed `null` for every call the engine
made, while `times()` and `verify()` take integers and a `VerificationFailure`
carries bounds as integers. Both were `@api` by inheritance from the first
design. `Invocation::recordOutcome()` and `Invocation::__construct()` are
`@internal` too — the dispatcher's bookkeeping, not a way to build a call by
hand.
- **`WhenBuilder` is closed by contract.** Its docblock now says that
subclassing is not supported and that the `protected` expectation it carries
is an `@internal` type; the keyword stays off only because `ExpectBuilder` is
the subclass, and Psalm refuses a `@final` tag for the same reason.
- **How `FailureKind` grows is written down.** The readonly fields of
`VerificationFailure` and every existing case are stable; a NEW case may
arrive in a minor, so a `match` over the enum needs a `default` arm. The
docblock, both READMEs, `llms.txt`, the skill file and the site say the same
thing — the 0.2.0 entry implied the opposite.
- Docblocks that the API reference renders: `checkpoint()` said «the current
context» and spans every context the test used; `verify()` as a free
function did not document `$never`; `Arg::bool()`, `WhenBuilder::throws()`,
`Invocation::didReturn()/didThrow()/returned()/thrown()` and seven exception
factories rendered as bare signatures.
- **The analyser parity matrix gains five idioms** (`bin/consumer-smoke`):
cardinality written past the first link, named bounds in either order, a
double reached through a helper, a first-class callable, and `returns()` on a
void method past the first link. The 0.8.0 wave fixed the first four in both
plugins without a row for any of them; the fifth is the same blindness found
a week later in `understudy-phpstan` 0.5.1.
- **The skill file caught up with the engine.** `resources/skills/…/SKILL.md`
— the one document that ships in the archive — said a nullable return answers
`null` before the registry is consulted (the opposite has been true since
0.1.0), counted three free functions, listed no `allOf`/`anyOf`/`rest`/
`captor` and mentioned neither `expectSequence()` nor `lean()`.
- Documentation catch-ups: seven 0.8.0 corrections reached the READMEs and
never the site (property defaults on class doubles, built-in interfaces as
return types, what `checkpoint()` clears, verbatim arguments and
`#[\SensitiveParameter]`, `instanceOf()` and PCRE `$` on the matchers page);
the Psalm page did not say that a leaked matcher is reported only at
`errorLevel="1"`; the adapter pages missed the 0.3.0/0.2.0 changes; the
Mockery table mapped `shouldNotHaveReceived()` to `unused()`, which asserts
no call at all; the README still told readers to expect a «too few
arguments» report on `Arg::rest()` that both analyser packages have handled
since 0.2.0.
- Rector is green again — red since v0.7.0, under four releases with green
builds. `infection/infection` `^0.35`, `rasuvaeff/property-testing-testo`
`^0.9` (it was three minors behind), `.vale.ini` is `export-ignore`. The
mutation gate stays at 92 with the reason beside the number: the run
measures 94.1%, three mutants above 94, which is less than the CI count
moves between runs.

## 0.8.0 — 2026-09-05

- **`Understudy::for()` no longer kills the process on five built-in
Expand Down
23 changes: 13 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ here. Two rows are traps, marked ⚠.
| `->andThrow(new NotFound())` | `->throws(new NotFound())` | |
| `->with(123, Mockery::any())` | inside the closure: `find(123, Arg::any())` | |
| `Mockery::on(fn ($x) => ...)` | `Arg::satisfies(fn ($x) => ...)` | |
| `$mock->shouldNotHaveReceived('save')` | `Understudy::unused($mock)` | |
| `$mock->shouldNotHaveReceived('save')` | `verify(fn () => $mock->save(...), never: true)` | `Understudy::unused($mock)` when the double must not have been touched at all |
| `$mock->shouldHaveReceived('save')` | `verify(fn () => $mock->save(...))` | after the fact; add `nothingElse()` — see below |
| `Mockery::close()` | adapter's `reset()`, or your own teardown | |
| `->makePartial()` / `Mockery::spy($real)` | `Understudy::delegate(Contract::class, $real)` + stubs on top | a stub wins, everything else runs for real — and is recorded |
Expand Down Expand Up @@ -306,16 +306,18 @@ when(fn () => $storage->recordOutcome('svc', Arg::rest()))
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
later, narrower specification for the same call still wins over the broad
prefix stub. One thing to know: a static analyser reads the shortened call
against the contract's arity, so expect a "too few arguments" diagnostic on
that line until your analyser knows the idiom.
prefix stub. A static analyser reads the shortened call against the contract's
arity; the [understudy-psalm](https://github.com/rasuvaeff/understudy-psalm)
plugin and the [understudy-phpstan](https://github.com/rasuvaeff/understudy-phpstan)
extension know the idiom and keep that line quiet.

The type matchers are deliberately strict: `Arg::int()` rejects `'5'`, and
`Arg::float()` rejects `1`. A matcher pins the declared type as much as the
value, which is the point in a codebase that runs with `strict_types`.

A matcher that could never match anything is refused where it is written, not
left to fail an expectation in teardown: `Arg::int(min: 5, max: 1)` and its
A matcher that could never match anything is refused where it is written —
with `InvalidSpecificationArgument` — rather than left to fail an expectation
in teardown: `Arg::int(min: 5, max: 1)` and its
`float`/`count` siblings describe an empty range, `Arg::string('/[unclosed')`
is not a pattern PCRE compiles — the latter would also raise a warning inside
the code under test on every call — and `Arg::instanceOf()` needs a class or
Expand Down Expand Up @@ -821,10 +823,11 @@ it.
A message is written for a reader, and its wording is not part of the package's
public contract: a patch release may reword one. Anything that acts on a
failure rather than printing it — a runner adapter, an IDE plugin, a report
aggregator — reads `VerificationFailed::failures()` instead, whose
`FailureKind` cases and `VerificationFailure` readonly fields are frozen API
from v0.1.0. A test asserting on the exact text of a message is asserting on
prose.
aggregator — reads `VerificationFailed::failures()` instead: the readonly
fields of `VerificationFailure` and every existing `FailureKind` case are
stable, and a **new** kind may arrive in a minor release, so match on the enum
with a `default` arm rather than exhaustively. A test asserting on the exact
text of a message is asserting on prose.

### Cleaning up

Expand Down
22 changes: 13 additions & 9 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ when(fn () => $repository->find(123))->returns($book);
| `->andThrow(new NotFound())` | `->throws(new NotFound())` | |
| `->with(123, Mockery::any())` | в замыкании: `find(123, Arg::any())` | |
| `Mockery::on(fn ($x) => ...)` | `Arg::satisfies(fn ($x) => ...)` | |
| `$mock->shouldNotHaveReceived('save')` | `Understudy::unused($mock)` | |
| `$mock->shouldNotHaveReceived('save')` | `verify(fn () => $mock->save(...), never: true)` | `Understudy::unused($mock)` — когда дубль не должны были трогать вообще |
| `$mock->shouldHaveReceived('save')` | `verify(fn () => $mock->save(...))` | пост-фактум; добавьте `nothingElse()` — см. ниже |
| `Mockery::close()` | reset() адаптера или свой teardown | |
| `->makePartial()` / `Mockery::spy($real)` | `Understudy::delegate(Contract::class, $real)` + стабы поверх | стаб побеждает, остальное выполняется по-настоящему — и записывается |
Expand Down Expand Up @@ -304,16 +304,19 @@ when(fn () => $storage->recordOutcome('svc', Arg::rest()))
Спецификация, остановившаяся раньше *без* `Arg::rest()` в конце, отвергается с
объяснением — вместо того чтобы стать стабом, который молча никогда не
совпадёт. Более поздняя узкая спецификация того же вызова по-прежнему
побеждает широкий префиксный стаб. Один нюанс: статанализатор читает
укороченный вызов против арности контракта и сообщит «too few arguments» на
этой строке, пока не знает идиому.
побеждает широкий префиксный стаб. Статанализатор читает укороченный вызов
против арности контракта; плагин
[understudy-psalm](https://github.com/rasuvaeff/understudy-psalm) и расширение
[understudy-phpstan](https://github.com/rasuvaeff/understudy-phpstan) знают
идиому и эту строку не краснят.

Типовые матчеры намеренно строгие: `Arg::int()` отвергает `'5'`, а
`Arg::float()` отвергает `1`. Матчер закрепляет объявленный тип не меньше, чем
значение, — ради этого он и нужен в коде со `strict_types`.

Матчер, который не смог бы совпасть ни с чем, отвергается там, где написан, а
не проваливает ожидание в teardown: `Arg::int(min: 5, max: 1)` и его собратья
Матчер, который не смог бы совпасть ни с чем, отвергается там, где написан —
исключением `InvalidSpecificationArgument`, — а не проваливает ожидание в
teardown: `Arg::int(min: 5, max: 1)` и его собратья
`float`/`count` описывают пустой диапазон, `Arg::string('/[unclosed')` — не
компилируемый PCRE-паттерн, который вдобавок поднимал бы warning внутри
тестируемого кода на каждом вызове, а `Arg::instanceOf()` требует загружаемый
Expand Down Expand Up @@ -820,9 +823,10 @@ Configure it first: when(fn () => $double->tag(...))->returns(...)
Сообщение написано для человека, и его формулировка в публичный контракт
пакета не входит: патч-релиз вправе переписать текст. Всё, что действует по
ошибке, а не печатает её — адаптер раннера, плагин IDE, агрегатор отчётов, —
читает `VerificationFailed::failures()`: кейсы `FailureKind` и readonly-поля
`VerificationFailure` заморожены как публичный API с v0.1.0. Тест, который
ассертит точный текст сообщения, ассертит прозу.
читает `VerificationFailed::failures()`: readonly-поля `VerificationFailure` и
каждый существующий кейс `FailureKind` стабильны, а **новый** кейс может
приехать в миноре — поэтому `match` по enum пишите с ветвью `default`, а не
исчерпывающим. Тест, который ассертит точный текст сообщения, ассертит прозу.

### Очистка

Expand Down
136 changes: 134 additions & 2 deletions bin/consumer-smoke
Original file line number Diff line number Diff line change
Expand Up @@ -532,8 +532,16 @@ PHP
# Both are shapes an analyser can only get right by resolving the class, and
# both were got wrong by reading the source text instead — in Psalm, in two
# separate places, each found only because the other analyser disagreed.
PARITY_SILENT='OwnUnqualified OwnAliased OwnQualified StaticVerb RestShort CaptorSpec'
PARITY_REPORTED='ForeignUnqualified ForeignAliased ForeignCapture RealCall RestLeak CaptorLeak'
#
# The second group is the misuse rules, added after the 2026-09-05 review found
# both plugins wrong on the same four shapes and the matrix silent about all
# of them: cardinality read only on the first link of the chain, named bounds
# read positionally, a helper call counted as a second specified call, and a
# first-class callable that crashed one analyser outright. `VoidReturnsChain`
# is the same blindness on `returns()` a week later. A shape the plugins have
# an opinion about gets a file here the day the opinion is formed.
PARITY_SILENT='OwnUnqualified OwnAliased OwnQualified StaticVerb RestShort CaptorSpec NamedBoundsSwapped HelperReceiver FirstClassCallable'
PARITY_REPORTED='ForeignUnqualified ForeignAliased ForeignCapture RealCall RestLeak CaptorLeak CardinalityAfterVerb VoidReturnsChain'

# Writes the matrix into a leg's `src/`, beside whatever that leg already has.
parity_fixtures() { # dir
Expand Down Expand Up @@ -862,6 +870,130 @@ final class CaptorLeak
return $gate->open($code->capture());
}
}
PHP

cat > "$src/Log.php" <<'PHP'
<?php

declare(strict_types=1);

namespace Consumer;

interface Log
{
public function write(string $line): void;
}
PHP

cat > "$src/CardinalityAfterVerb.php" <<'PHP'
<?php

declare(strict_types=1);

namespace Consumer;

use Rasuvaeff\Understudy\Understudy;

use function Rasuvaeff\Understudy\expect;

final class CardinalityAfterVerb
{
public function setUp(): void
{
$gate = Understudy::for(Gate::class);

expect(fn(): bool => $gate->open(1))->returns(true)->times(5, 2);
}
}
PHP

cat > "$src/NamedBoundsSwapped.php" <<'PHP'
<?php

declare(strict_types=1);

namespace Consumer;

use Rasuvaeff\Understudy\Understudy;

use function Rasuvaeff\Understudy\expect;

final class NamedBoundsSwapped
{
public function setUp(): void
{
$gate = Understudy::for(Gate::class);

expect(fn(): bool => $gate->open(1))->times(maximum: 5, minimum: 1);
}
}
PHP

cat > "$src/HelperReceiver.php" <<'PHP'
<?php

declare(strict_types=1);

namespace Consumer;

use Rasuvaeff\Understudy\Understudy;

use function Rasuvaeff\Understudy\when;

final class HelperReceiver
{
public function setUp(): void
{
when(fn(): bool => $this->gate()->open(1));
}

private function gate(): Gate
{
return Understudy::for(Gate::class);
}
}
PHP

cat > "$src/FirstClassCallable.php" <<'PHP'
<?php

declare(strict_types=1);

namespace Consumer;

final class FirstClassCallable
{
public function length(): int
{
$length = strlen(...);

return $length('abc');
}
}
PHP

cat > "$src/VoidReturnsChain.php" <<'PHP'
<?php

declare(strict_types=1);

namespace Consumer;

use Rasuvaeff\Understudy\Understudy;

use function Rasuvaeff\Understudy\when;

final class VoidReturnsChain
{
public function setUp(): void
{
$log = Understudy::for(Log::class);

when(static function () use ($log): void {
$log->write('x');
})->times(2)->returns('done');
}
}
PHP
}

Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@
"require-dev": {
"ergebnis/composer-normalize": "^2.51",
"friendsofphp/php-cs-fixer": "^3.95",
"infection/infection": "^0.33",
"infection/infection": "^0.35",
"maglnet/composer-require-checker": "^4.17",
"rasuvaeff/property-testing-testo": "^0.6",
"rasuvaeff/property-testing-testo": "^0.9",
"rasuvaeff/rector-named-literals": "^1.0",
"rector/rector": "^2.4",
"roave/backward-compatibility-check": "^8.0",
Expand Down
Loading
Loading