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
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,18 @@ 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
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
`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
`ReturnContract::acceptsType()` (falling through lands on the `match` default,
which accepts a name `class_exists()` does not know).

## When you finish

- If the change touches the public API, run `make docs-api` and commit the
Expand Down
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,51 @@
# Changelog

## Unreleased

- **Fixed.** An interface extending `Throwable`, `DateTimeInterface`,
`UnitEnum`/`BackedEnum` or `Traversable` (without `Iterator`/
`IteratorAggregate`), and an interface declaring `__construct`, are refused
with `UnsupportedTarget` before generation. The guard compared the
contract's own name, so `Psr\Http\Client\ClientExceptionInterface` and
its kin walked past it into `eval()` and died there as a fatal error no
test, adapter or `try` can catch — ending the whole suite run. The
`Throwable` advice no longer points at doubling an exception class, which
is refused too; construct a real exception for `throws()`. (#133)
- **Fixed.** A specification closure that calls one double method while
evaluating the arguments of another — `when(fn () => $r->find($r->count()))`
— is refused with `InvalidCallSpecification` naming both calls. The
recording ends on the first dispatch, which is the innermost call, so the
inner call was specified silently with the outer call's `returns()` and the
outer one not at all. The closure is now re-run once with calls answered
instead of signalled to see past the first; where that probe cannot finish
(a `: never` method, a return type with no default, code after the call that
does not survive a default) the specification stands as before. (#134)
- **Fixed.** `returns()` is checked against the declared return type where it
is written: a value on a `: void` method (`returns(null)` stays allowed as
the idiom for "answer nothing"), anything on a `: never` method, and a value
the type cannot hold — `null` where the type is not nullable, an array or an
object where a scalar is declared, an object of the wrong class — raise
`InvalidCallSpecification`/`InvalidSpecificationArgument` naming the double,
instead of a `TypeError` naming the generated class from inside the code
under test. No stricter than the engine: a generated method is not under
`strict_types`, so `returns('5')` on `: int` still answers `5`.
`WhenBuilder::__construct()` gained an optional trailing parameter. (#135)
- **Changed.** The second and later unlabelled doubles of one contract in a
context get a numbered default label — `Repo`, `Repo#2` — so a report says
which one; the first keeps the bare name and an explicit `label()` still
wins. (#137)
- **Changed.** `ForgottenDouble` after a `scope()` closed says so, instead of
blaming a `reset()` the test never wrote. (#138)
- **Documentation.** Written down rather than changed: a catch-all stub
registered after a specific one is not diagnosed (unreachable is not
uncalled, so `strictStubs` stays quiet — register the broad stub first,
#136); the depth-1 default double is a fresh instance per call; a class
double is never a partial double of an abstract class; a `readonly`
promoted property of a class target stays uninitialized where a plain typed
one starts empty; `MatcherLeaked` is raised only by a double, a matcher
handed to a real object is out of the engine's sight; `Arg::rest()` is the
way to stop spelling a wide signature.

## 0.10.0 — 2026-09-06

The wave the 1.0 candidate turned out to still need: ten defects found by
Expand Down
52 changes: 47 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,21 @@ that is not the first target. A double that cannot intercept every method would
run the target's real code against an object whose constructor never ran, which
is worse than not building it at all.

The same goes for what PHP itself forbids a class to implement, checked on the
contract and on everything it extends: an interface extending `Throwable`
(`Psr\Http\Client\ClientExceptionInterface`, `Psr\Cache\CacheException`),
`DateTimeInterface`, `UnitEnum`, or `Traversable` without `Iterator` or
`IteratorAggregate` — and an interface declaring a constructor, which a double
never runs. Each is refused with `UnsupportedTarget` naming the ancestor,
rather than dying in the compiler as a fatal error no test can catch. A double
cannot be thrown: construct a real exception and hand it to `throws()`.

A class double is never a partial double. A concrete method of an abstract
class target is dispatched like every other — its body does not run, so a
template method returns the mode's default rather than calling the abstract
step you stubbed. Running real code takes a real instance and
`Understudy::forwarding()`; `Invocation::callOriginal()` needs the same.

### Property hooks (PHP 8.4+)

A contract declaring a property is doublable:
Expand Down Expand Up @@ -268,9 +283,31 @@ when(fn () => $repository->mode())->returns('fast', 'slow');
```

A later stub for the same call wins; earlier ones stay reachable as fallbacks
when their arguments do not match. An exhausted call-count expectation keeps
answering the matching call, so use a non-overlapping matcher when a broad
fallback should handle later calls.
when their arguments do not match. Register the broad stub first and the
specific one after it: a catch-all registered *after* a specific stub takes
every call, and the specific one is not diagnosed — unreachable is not the
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.

`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
"answer nothing"), anything on a `: never` method, and a value the type cannot
hold — `null` where the type is not nullable, an array or an object where a
scalar is declared, an object of the wrong class — are refused with the
double's label instead of surfacing later as a `TypeError` naming the generated
class from inside the code under test. The check is no stricter than the
engine: a generated method is not under `strict_types`, so `returns('5')` on
`: int` still answers `5`. What `answers()` produces can only be judged at the
call, and there a wrong type is still PHP's own `TypeError`.

| Matcher | Matches |
|---|---|
Expand Down Expand Up @@ -668,7 +705,10 @@ 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
one, one level deep, which the same test can configure. That double is a
generated stand-in, not the target with its constructor skipped.
generated stand-in, not the target with its constructor skipped — and a fresh
one on every call: `$outer->inner() === $outer->inner()` is false. A test that
takes one and verifies against it sees only the calls made on that instance;
stub the method with `returns()` when the collaborator has to be stable.

A built-in interface is the exception: a method declared `: Stringable`,
`: Countable`, `: JsonSerializable`, `: ArrayAccess` or `: IteratorAggregate`
Expand Down Expand Up @@ -826,7 +866,9 @@ The following calls to `tag` were made during this test:

The asterisks mark the argument that differed — borrowed from
[NSubstitute](https://nsubstitute.github.io). `Understudy::label($double, '…')`
names a double when several of the same contract are in play.
names a double when several of the same contract are in play; without a label
the second and later doubles of one contract are numbered — `BookRepository`,
`BookRepository#2` — so a report still says which one.

An object argument is matched by identity, so two instances never match however
equally they read — and the message has to be able to show which of the two
Expand Down
49 changes: 46 additions & 3 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,21 @@ trait, internal-класс, анонимный класс и любой клас
который не может перехватить все методы, исполнил бы настоящий код цели над
объектом с невыполненным конструктором — это хуже, чем не собраться вовсе.

То же с тем, что PHP запрещает реализовывать классу, — проверяется и на самом
контракте, и на всём, что он расширяет: интерфейс, расширяющий `Throwable`
(`Psr\Http\Client\ClientExceptionInterface`, `Psr\Cache\CacheException`),
`DateTimeInterface`, `UnitEnum` или `Traversable` без `Iterator`/
`IteratorAggregate`, а также интерфейс, объявляющий конструктор, которого дубль
никогда не выполняет. Каждый отвергается `UnsupportedTarget` с именем предка,
а не умирает в компиляторе fatal-ошибкой, которую не поймать ни одним тестом.
Дубль нельзя бросить: соберите настоящее исключение и отдайте его в `throws()`.

Класс-дубль никогда не бывает частичным. Конкретный метод абстрактной цели
диспатчится как любой другой — его тело не выполняется, поэтому
template-метод отвечает дефолтом режима, а не вызывает застабленный
абстрактный шаг. Для настоящего кода нужен настоящий экземпляр и
`Understudy::forwarding()`; `Invocation::callOriginal()` требует того же.

### Property hooks (PHP 8.4+)

Контракт, объявляющий свойство, дублируется:
Expand Down Expand Up @@ -268,7 +283,29 @@ when(fn () => $repository->mode())->returns('fast', 'slow');
```

Более поздний стаб на тот же вызов выигрывает, а ранние остаются запасными —
поэтому широкий стаб с `Arg::any()` может лежать под точечным.
поэтому широкий стаб с `Arg::any()` может лежать под точечным. Именно под:
catch-all, зарегистрированный *после* точечного, забирает каждый вызов, а
точечный не диагностируется — недостижимый не значит невызванный, и даже
`strictStubs` его не покажет.

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

`returns()` сверяется с объявленным типом возврата там, где написан: значение
на `: void`-методе (`returns(null)` разрешён — это идиома «ничего не
отвечать»), что угодно на `: never`, и значение, которое тип не вмещает —
`null` на ненуллабельном типе, массив или объект там, где объявлен скаляр,
объект не того класса, — отвергаются с меткой дубля вместо того, чтобы позже
всплыть `TypeError` с именем сгенерированного класса из глубины тестируемого
кода. Проверка не строже движка: сгенерированный метод не под `strict_types`,
поэтому `returns('5')` на `: int` по-прежнему отвечает `5`. Что произведёт
`answers()`, судить можно только на вызове, и там неверный тип остаётся
`TypeError` самого PHP.

| Матчер | Что принимает |
|---|---|
Expand Down Expand Up @@ -668,7 +705,11 @@ Loose-дубль никогда не выдумывает значение, за
никогда не отдаёт экземпляр настоящего класса с пропущенным конструктором.
Отдать он может другой дубль: возвращаемый тип, который сам можно дублировать,
становится дублем — на один уровень, и тот же тест может его настроить. Это
сгенерированный заменитель, а не цель с пропущенным конструктором.
сгенерированный заменитель, а не цель с пропущенным конструктором — и новый
на каждый вызов: `$outer->inner() === $outer->inner()` ложно. Тест, который
взял такой дубль и верифицирует по нему, видит только вызовы на этом
экземпляре; когда коллаборатор должен быть стабильным, застабьте метод через
`returns()`.

Исключение — встроенные интерфейсы: метод, объявленный `: Stringable`,
`: Countable`, `: JsonSerializable`, `: ArrayAccess` или `: IteratorAggregate`,
Expand Down Expand Up @@ -822,7 +863,9 @@ The following calls to `tag` were made during this test:

Звёздочками помечен разошедшийся аргумент — приём заимствован у
[NSubstitute](https://nsubstitute.github.io). `Understudy::label($double, '…')`
даёт дублю имя, когда их в тесте несколько на один контракт.
даёт дублю имя, когда их в тесте несколько на один контракт; без метки второй
и последующие дубли одного контракта нумеруются — `BookRepository`,
`BookRepository#2`, — и отчёт всё равно говорит, какой именно.

Объектный аргумент сравнивается по идентичности, поэтому два экземпляра не
совпадут, как бы одинаково они ни читались, — и сообщение обязано показать,
Expand Down
25 changes: 19 additions & 6 deletions bin/consumer-smoke
Original file line number Diff line number Diff line change
Expand Up @@ -1394,7 +1394,7 @@ PHP
check_local phpunit phpunit || return 1
local out
out="$(run phpunit './vendor/bin/phpunit --no-progress 2>&1' || true)"
if printf '%s' "$out" | grep -q 'Tests: 4' && printf '%s' "$out" | grep -q 'Failures: 2' \
if printf '%s' "$out" | grep -q 'Tests: 4' && printf '%s' "$out" | grep -qE 'Failures: [23]' \
&& printf '%s' "$out" | grep -q 'open(2)'; then
printf ' ok the trait turns an unmet expectation into a runner failure\n'
else
Expand All @@ -1404,13 +1404,26 @@ PHP
fi

# `open(40)` is the class that composed explicitly: its unmet expectation
# is one of the two failures. `open(30)` is the class that overrode
# without composing: it must NOT appear, because nothing reported it —
# which is the hazard, stated as a fact rather than as a warning.
# is one of the failures. `open(30)` is the class that overrode without
# composing: its expectation is never verified, so `open(30)` must NOT
# appear. What the adapter does about that depends on its version — since
# understudy-phpunit 0.5 the `#[After]` hook notices the swallowed
# verification and fails the test with the aliasing recipe (the third
# failure); before it, the hazard was silent, and both readings are pinned
# here because a published adapter behind that release still installs.
if printf '%s' "$out" | grep -q 'open(40)' && ! printf '%s' "$out" | grep -q 'open(30)'; then
printf ' ok the documented alias recipe verifies, and a bare override loses it silently\n'
if printf '%s' "$out" | grep -q 'Failures: 3' \
&& printf '%s' "$out" | grep -q 'SilentOverrideTest' \
&& printf '%s' "$out" | grep -q 'expectations were never verified'; then
printf ' ok the documented alias recipe verifies, and a bare override is reported as unverified\n'

return 0
return 0
fi
if printf '%s' "$out" | grep -q 'Failures: 2'; then
printf ' ok the documented alias recipe verifies, and a bare override loses it silently (adapter predates the guard)\n'

return 0
fi
fi
printf ' FAIL the override composition did not behave as documented:\n%s\n' "$out"

Expand Down
Loading
Loading