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
32 changes: 32 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,18 @@ jobs:
# subsequent PR regardless of content. A single genuine
# "[BC] CHANGED:"/"[BC] REMOVED:"/"[BC] ADDED:" line anywhere in the
# report still fails the build.
#
# A report made up ENTIRELY of return-type widenings from void is a
# pass when the top version heading in CHANGELOG.md is a MINOR above
# the latest tag and every named class is final: that is the widening
# the compatibility policy in README.md admits in a minor release.
# Roave's TypeIsCovariant hard-codes "nothing is covariant to void"
# from subclass-override semantics and does not look at finality, so
# it reports void -> int even where no subclass can exist and no
# caller could have read a return value that never was one (observed
# on verifyAll(): void -> int in the 1.1.0 line). Finality is not
# trusted from the finding's wording - it is reflected against this
# checkout - and any other finding in the same report still fails.
run: |
LATEST="$(git describe --tags --abbrev=0 2>/dev/null || true)"
if [ -z "$LATEST" ]; then
Expand Down Expand Up @@ -499,5 +511,25 @@ jobs:
echo "All $TOTAL finding(s) are roave/better-reflection SKIPPED entries (a static-analysis limitation, not a detected break); treating as pass."
exit 0
fi
WIDENED="$(printf '%s\n' "$OUTPUT" | { grep -cE '^\[BC\] CHANGED: The return type of .+::.+\(\) changed from void to the non-covariant ' || true; })"
if [ "$TOTAL" -gt 0 ] && [ "$TOTAL" -eq "$WIDENED" ] \
&& [ "$CODE" -eq 3 ] && [ -n "$NEXT_MAJOR" ] \
&& [ "$NEXT_MAJOR" -eq "$LATEST_MAJOR" ] \
&& [ "$NEXT_MINOR" -gt "$LATEST_MINOR" ]; then
PASS=1
while IFS= read -r FINDING; do
CLASS="$(printf '%s\n' "$FINDING" | sed -E 's/^\[BC\] CHANGED: The return type of (.+)::.+$/\1/')"
if ! php -r 'require "vendor/autoload.php"; exit(class_exists($argv[1]) && (new ReflectionClass($argv[1]))->isFinal() ? 0 : 1);' "$CLASS"; then
echo "Finding is a void widening but $CLASS is not final (or not loadable):"
printf '%s\n' "$FINDING"
PASS=0
break
fi
done <<< "$(printf '%s\n' "$OUTPUT" | grep -E '^\[BC\] ')"
if [ "$PASS" -eq 1 ]; then
echo "All $TOTAL finding(s) are return-type widenings from void on final classes, and CHANGELOG declares the minor $NEXT the compatibility policy admits them in; treating as pass."
exit 0
fi
fi
fi
exit "$CODE"
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## 1.1.0 — Unreleased

- **Added.** `Understudy::verifyAll()` returns `int<0, max>` — how many checks
it verified — instead of `void`, so runner adapters count real assertions
instead of a flat one per test. One check is: an expectation carrying a
claim (`expect()`, or `times()` on a stub); a plain stub under
`strictStubs`, where its use is what is checked; an ordering constraint
over expectations that declared one; an armed protocol, whose completeness
is checked here. Checks `verify()` made earlier answered at their own call
sites and are not recounted. Source-compatible for every existing caller:
the method answered nothing before, and `Understudy` is final. The
compatibility policy now says a return type may be widened in a minor
release where no caller can observe the change. (#153)

## 1.0.0 — 2026-09-27

The stability release. The public `@api` surface is the same as `0.12.0`:
Expand Down
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ discovered:
| `final` methods stay | a final method cannot be overridden either way, so a class carrying one is still refused |
| PHAR and preloaded classes | their source arrives as `phar://`, or before any bootstrap ran, so it never passes through the `file://` wrapper |
| The opcode cache is not a way back | however warm the cache is, and whether or not it holds the bypassed file — Linux keeps it out, Windows does not — the class stays open. Not being cached is a cost where it happens, not a guarantee to rely on |
| Another source transformer | if something else is already rewriting PHP source, understudy refuses rather than replacing it silently; a wrapper that leaves source alone composes and is accepted |
| Another `file://` wrapper | if a user-space wrapper already owns `file://`, understudy refuses rather than replacing it silently, whether or not that wrapper transforms PHP source |

When a class is still final at `for()`, the refusal says which of these it was —
bypass never asked for, asked for other classes but not this one, or asked for
Expand Down Expand Up @@ -450,6 +450,19 @@ open range. Write the second argument for that.
one. `verifyAll(strictStubs: true)` additionally fails a stub that was never
used.

`verifyAll()` returns how many checks it verified, so a runner adapter counts
real assertions instead of a flat one per test. One check is: an expectation
carrying a claim (`expect()`, or `times()` on a stub); a plain stub under
`strictStubs`, where its use is what is checked; an ordering constraint over
expectations that declared one; an armed protocol, whose completeness is
checked here. A failed check throws rather than returns, so the number only
ever counts checks that passed — and checks `verify()` made earlier are not
part of it: those answered at their own call sites.

```php
$checks = Understudy::verifyAll(); // int: claims + strict stubs + order + protocol
```

An expectation needs no `returns()`: counting and answering are separate
concerns, so the mode's type-safe default supplies the value, and a matched
expectation satisfies a strict double because the call was expected.
Expand Down Expand Up @@ -988,9 +1001,16 @@ The public contract is every class, interface, enum, exception, and free
function marked `@api`. Types marked `@internal` are implementation details and
may change in any release. The `1.x` line keeps the names, signatures, and
readonly fields of the public API stable; constructors only gain optional
parameters at the end. Removing, renaming, or retyping an existing public
parameters at the end. Removing, renaming, or narrowing an existing public
member is a major-version change.

A return type may be **widened** in a minor release where no caller can
observe the change: `verifyAll()` went from `void` to `int` in a minor,
because a method that answered nothing cannot have had a caller reading the
answer, and `Understudy` is `final`, so no override shadows it. The same
reasoning admits a parameter widened to a supertype every existing argument
already satisfies. Narrowing is never minor.

`VerificationFailure` fields and existing `FailureKind` cases are stable. A
minor release may add a new failure kind or populate a new field, so code that
matches `FailureKind` must include a default branch. Failure messages are prose
Expand Down
24 changes: 22 additions & 2 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ Understudy::bypassFinals(); // все, что процесс
| `final`-методы остаются | такой метод не переопределить в любом случае, поэтому класс с ним по-прежнему отвергается |
| PHAR и preload | исходник приходит как `phar://` либо раньше любого bootstrap, поэтому через `file://`-враппер не проходит вовсе |
| Opcode-кэш не возвращает запечатанный класс | насколько бы тёплым кэш ни был и держит ли он bypass-нутый файл — Linux не держит, Windows держит — класс остаётся открытым. Отсутствие в кэше это цена там, где оно есть, а не гарантия, на которую стоит опираться |
| Чужой трансформер исходника | если PHP-исходник уже кто-то переписывает, understudy откажется, а не подменит молча; враппер, который исходник не трогает, композируется и принимается |
| Чужой `file://`-враппер | если `file://` уже обслуживает пользовательский враппер, understudy откажется, а не подменит его молча — независимо от того, меняет он PHP-исходник или нет |

Если класс всё ещё final в момент `for()`, отказ говорит, какой именно это
случай — bypass не включали вовсе, включили для других классов, но не для
Expand Down Expand Up @@ -449,6 +449,19 @@ Understudy::verifyAll();
заявлением. `verifyAll(strictStubs: true)` дополнительно валит ни разу не
использованный стаб.

`verifyAll()` возвращает, сколько проверок оно верифицировало, — чтобы адаптер
раннера считал реальные ассерты, а не плоскую единицу на тест. Одна проверка
это: ожидание-заявление (`expect()` или `times()` на стабе); простой стаб под
`strictStubs` — проверяется его использование; ограничение порядка на
объявленных `ordered()` ожиданиях; армированный протокол, чью полноту здесь и
проверяют. Проваленная проверка бросает исключение, а не возвращается, поэтому
число считает только прошедшие проверки — и проверки, сделанные `verify()`
раньше, в него не входят: они ответили в своих местах вызова.

```php
$checks = Understudy::verifyAll(); // int: заявления + strict-стабы + порядок + протокол
```

`returns()` при этом не обязателен: счёт и ответ — разные вещи, значение даёт
типобезопасный дефолт режима, а совпавшее ожидание удовлетворяет и строгий
дубль — вызов ведь ожидался.
Expand Down Expand Up @@ -987,9 +1000,16 @@ SensitiveParameter)`, — так же, как PHP редактирует так
функция с пометкой `@api`. Типы с пометкой `@internal` остаются деталями
реализации и могут меняться в любом релизе. В линии `1.x` имена, сигнатуры и
readonly-поля публичного API стабильны; конструкторы могут получать только
необязательные параметры в конце. Удаление, переименование или изменение типа
необязательные параметры в конце. Удаление, переименование или сужение типа
существующего публичного члена требует мажорной версии.

**Расширить** тип возврата в минорной версии можно там, где ни один вызывающий
не заметит изменения: `verifyAll()` в миноре стал из `void` возвращать `int` —
метод, который ничего не отвечал, не мог иметь вызывающего, читающего ответ, а
класс `Understudy` `final`, поэтому никакой override его не заслоняет. То же
рассуждение допускает расширение параметра до супертипа, которому удовлетворяют
все существующие аргументы. Сужение минором не бывает никогда.

Поля `VerificationFailure` и существующие кейсы `FailureKind` стабильны. В
минорной версии может появиться новый кейс или новое заполненное поле, поэтому
в `match` по `FailureKind` нужен `default`. Тексты ошибок являются прозой и
Expand Down
14 changes: 7 additions & 7 deletions docs/.api-workspace/composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading