diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8a46dce..a718fa9 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,6 +1,6 @@ name: docs -# The family documentation site (_plans/UNDERSTUDY-DOCS-SITE-PLAN.md). +# The family documentation site. # One site for five packages: this repository holds it, and the API reference # is reflected out of all five `src/` trees at build time. # diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ed5fe..014a1b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # Changelog +## Unreleased + +- **`Understudy::for()` no longer kills the process on five built-in + interfaces.** `Throwable`, `UnitEnum`, `BackedEnum`, `DateTimeInterface` and + `Traversable` walked past every refusal in the factory and were answered by + the compiler instead — a fatal out of `eval()`, uncatchable by `try` or by an + adapter, and fatal to the whole suite run rather than to one test. Each is now + an `UnsupportedTarget` naming the way through. `Iterator`, + `IteratorAggregate`, `Stringable` and `Countable` keep doubling: it was never + about being built in. +- **A duplicated contract is accepted rather than fatal.** + `Understudy::for(A::class, A::class)` — a list assembled programmatically — + produced the same uncatchable fatal, because `implements A, A` does not + compile. +- **`#[\SensitiveParameter]` is honoured.** The value of such a parameter is + rendered as its type — `login('user', string SensitiveParameter)` — in + failure messages and in `transcript()`, the way PHP redacts it in its own + stack traces. It used to go into both verbatim, which is to say into a CI log. +- **Three public paths now throw an `UnderstudyError`.** `times(5, 2)`, a + negative count and `returns()` with no values threw a bare + `\InvalidArgumentException`, while `UnderstudyError` declares itself + implemented by every exception this library throws — so a `catch + (UnderstudyError $e)`, which `llms.txt` recommends, walked past them. The new + `InvalidSpecificationArgument` extends `\InvalidArgumentException`, so a catch + by the SPL type keeps working. +- **`Arg::instanceOf()` refuses a class that is not loadable.** It used to + match nothing, forever, and say so nowhere — the reader saw only "expected … + but it was never called" and looked for the cause in the subject under test. +- **`NAN` no longer raises a PHP warning while a failure message is rendered.** + On PHP 8.5 `(string) NAN` warns, from inside the library, during the render of + a report about a failure — which under `failOnWarning` turns the report into a + different failure. +- **Control bytes and binary strings are escaped in messages.** A NUL or half a + broken UTF-8 sequence travelled into the failure text and the transcript as + the raw byte, breaking the single line the escaping exists to keep. Valid + multibyte text is untouched. +- `CannotWire` and `InvalidDefaultValue` say "has type `array`" and "produced a + value of type `array`" instead of "is a `array`". +- Documentation: `checkpoint()` clears only the **settled** calls, not the call + log (the text promised otherwise, and the code was right); a declared property + default is kept while a promoted one is not; a built-in interface as a return + type does not become a nested double; `Arg::string()` uses PCRE semantics for + `$`; both Security sections say that arguments are printed verbatim except + sensitive ones. A broken cookbook link in `examples/README.md` and a dead plan + reference in `docs.yml` are gone, and the committed API pages are regenerated + from the current `src/` (they were built from a v0.5.0 snapshot). + ## 0.7.2 — 2026-09-04 - **Documentation review fixes.** llms.txt no longer claims `bypassFinals()` diff --git a/README.md b/README.md index fdbcfc0..104be05 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ What a class double does and does not do: | public and protected methods | overridden and dispatched; a protected one shows up in the transcript and under strict mode, but PHP's own visibility keeps it out of a setup closure | | private and static methods | untouched — the target keeps them, because there is no instance state to intercept | | the destructor | replaced with an empty one, so nothing is torn down that was never built | -| writable public properties | start at an empty value of their type; object-typed, hooked, `final`, `readonly` and `private(set)` ones are left uninitialized, and reading one raises PHP's own error | +| writable public properties | a declared default is kept; everything else starts at an empty value of its type — including a property **promoted** through the constructor, because the constructor is skipped, so `$double->promoted` differs from the real object. Object-typed, hooked, `final`, `readonly` and `private(set)` ones are left uninitialized, and reading one raises PHP's own error | | `clone` | produces a double of its own: same contracts, no expectations, no call log, owned by the context that cloned it | A `readonly` target produces a `readonly` double, which PHP requires and which @@ -316,9 +316,15 @@ 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 -`float`/`count` siblings describe an empty range, and `Arg::string('/[unclosed')` +`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. +the code under test on every call — and `Arg::instanceOf()` needs a class or +interface that is loadable, because a name that is not would simply never +match and say so nowhere. + +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. `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 @@ -503,7 +509,13 @@ echo Understudy::transcript($repository); // every call and its outcome Understudy::idle(); // true when the test holds no doubles, in any context ``` -`transcript()` retains every invocation until `reset()` or `checkpoint()`. +`transcript()` retains every invocation until `reset()`. `checkpoint()` clears +only the calls it **settled** — the ones a matching `expect()` or a successful +`verify()` claimed — because an unclaimed call is still what `nothingElse()` +reads. A call covered by a `when()` stub alone is never claimed, so it, and the +value it holds, survive every checkpoint of a long test. Use `reset()`, +`scope()` or `lean()` to let go of everything. + Avoid unbounded hot loops through a double when the arguments or results hold large object graphs; use a real fake for load-sized workloads. @@ -601,6 +613,14 @@ 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. +A built-in interface is the exception: a method declared `: Stringable`, +`: Countable`, `: JsonSerializable`, `: ArrayAccess` or `: IteratorAggregate` +answers `NoDefaultValue` and names the way out, even though `Understudy::for()` +doubles all five. Such a return type almost always means a concrete +implementation, and standing a stub in for it would answer a question the test +did not ask. An interface of your own that extends one of them does get a +nested double. + One level, and no further — a double created this way refuses to produce another, so `$a->b()->c()` says so rather than inventing a third collaborator the test never asked for. Registering a factory for `C` is how you say you meant @@ -848,6 +868,15 @@ process. It never loads code from user input, never touches the filesystem, and holds all state in `WeakMap`s keyed by the double object — never by `spl_object_id()`, which PHP reuses after collection. +**Arguments are printed verbatim in failure messages and in `transcript()`, +with one exception.** A parameter the contract marks `#[\SensitiveParameter]` +is rendered as its type and nothing else — `login('user', string +SensitiveParameter)` — the way PHP redacts such a parameter in its own stack +traces. Everything else goes into the message as written, and a failure message +is read from a CI log: mark the parameter, or keep the secret out of the +argument. The literals your own specification passes are not redacted; they are +in your test file already. + It is a development dependency. Do not install it in production. ## Examples diff --git a/README.ru.md b/README.ru.md index 6ebe08d..2c49245 100644 --- a/README.ru.md +++ b/README.ru.md @@ -162,7 +162,7 @@ $repository = Understudy::for(DoctrineBookRepository::class, Countable::class); | public- и protected-методы | переопределяются и диспетчеризуются; protected виден в transcript и в strict-режиме, но нативная видимость PHP не пускает его в setup-замыкание | | private- и static-методы | не трогаются — у них нет instance-состояния, которое можно перехватить | | деструктор | заменяется пустым, чтобы не сносить то, что никогда не создавалось | -| записываемые public-свойства | стартуют с пустого значения своего типа; объектные, hooked, `final`, `readonly` и `private(set)` остаются неинициализированными, и чтение даёт нативную ошибку PHP | +| записываемые public-свойства | объявленное умолчание сохраняется; всё остальное стартует с пустого значения своего типа — в том числе свойство, **продвинутое** через конструктор: конструктор пропущен, поэтому `$double->promoted` отличается от настоящего объекта. Объектные, hooked, `final`, `readonly` и `private(set)` остаются неинициализированными, и чтение даёт нативную ошибку PHP | | `clone` | даёт самостоятельный дубль: те же контракты, без ожиданий и без журнала вызовов, владелец — контекст, который клонировал | `readonly`-цель даёт `readonly`-дубль — этого требует PHP, и это ничего не @@ -314,9 +314,16 @@ when(fn () => $storage->recordOutcome('svc', Arg::rest())) Матчер, который не смог бы совпасть ни с чем, отвергается там, где написан, а не проваливает ожидание в teardown: `Arg::int(min: 5, max: 1)` и его собратья -`float`/`count` описывают пустой диапазон, а `Arg::string('/[unclosed')` — не +`float`/`count` описывают пустой диапазон, `Arg::string('/[unclosed')` — не компилируемый PCRE-паттерн, который вдобавок поднимал бы warning внутри -тестируемого кода на каждом вызове. +тестируемого кода на каждом вызове, а `Arg::instanceOf()` требует загружаемый +класс или интерфейс: имя, которого нет, просто не совпало бы никогда и нигде +об этом не сказало. + +Паттерн — ваш и используется как написан, вместе с семантикой PCRE: `$` +совпадает и перед завершающим переводом строки, поэтому +`Arg::string('/^ord-\d+$/')` принимает `"ord-1\n"`. Где это важно — якорить +`\z` (или добавить модификатор `D`). `Arg::which()` вызывает только публичный нестатический метод без обязательных аргументов. Геттер, бросивший исключение, считается несовпадением, а не @@ -504,8 +511,14 @@ echo Understudy::transcript($repository); // все вызовы и их Understudy::idle(); // true, когда тест не держит дублей ни в одном контексте ``` -`transcript()` хранит каждый вызов до `reset()` или `checkpoint()`. Не -используйте дубль в неограниченном горячем цикле, если аргументы или ответы +`transcript()` хранит каждый вызов до `reset()`. `checkpoint()` очищает только +**учтённые** вызовы — те, что забрал сработавший `expect()` или успешный +`verify()`, — потому что неучтённый вызов всё ещё нужен `nothingElse()`. Вызов, +покрытый только `when()`-заглушкой, не учитывается никогда, так что он и +удержанное им значение переживают любой checkpoint длинного теста. Отпустить +всё — это `reset()`, `scope()` или `lean()`. + +Не используйте дубль в неограниченном горячем цикле, если аргументы или ответы удерживают большие графы объектов; для нагрузочных сценариев лучше fake. Удержание распространяется на **возвращённые значения**, а `reset()` у @@ -602,6 +615,13 @@ Loose-дубль никогда не выдумывает значение, за становится дублем — на один уровень, и тот же тест может его настроить. Это сгенерированный заменитель, а не цель с пропущенным конструктором. +Исключение — встроенные интерфейсы: метод, объявленный `: Stringable`, +`: Countable`, `: JsonSerializable`, `: ArrayAccess` или `: IteratorAggregate`, +отвечает `NoDefaultValue` и называет выход, хотя сам `Understudy::for()` +дублирует все пять. Такой возвращаемый тип почти всегда означает конкретную +реализацию, и подстановка заглушки ответила бы на вопрос, которого тест не +задавал. Свой интерфейс, расширяющий один из них, вложенный дубль получает. + Ровно один уровень: созданный так дубль откажется породить следующий, поэтому `$a->b()->c()` скажет об этом, а не выдумает третью зависимость, о которой тест не просил. Зарегистрировать фабрику для `C` — способ сказать, что вы этого @@ -847,6 +867,15 @@ Understudy генерирует по классу на набор контрак файловой системе и держит всё состояние в `WeakMap` по ключу-объекту — а не по `spl_object_id()`, который PHP переиспользует после сборки мусора. +**Аргументы попадают в сообщение о падении и в `transcript()` дословно, с +одним исключением.** Параметр, помеченный контрактом как +`#[\SensitiveParameter]`, печатается одним своим типом — `login('user', string +SensitiveParameter)`, — так же, как PHP редактирует такой параметр в +собственных трассах. Всё остальное уходит в сообщение как есть, а сообщение о +падении читают из CI-лога: помечайте параметр или не кладите секрет в аргумент. +Литералы, которые передаёт ваша собственная спецификация, не редактируются — +они и так лежат в файле теста. + Это dev-зависимость. В production её ставить не нужно. ## Примеры diff --git a/docs/scripts/api-snapshot.json b/docs/scripts/api-snapshot.json index c941fd4..c9b6b77 100644 --- a/docs/scripts/api-snapshot.json +++ b/docs/scripts/api-snapshot.json @@ -2,7 +2,7 @@ "classes": [ { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Arg", "kind": "class", @@ -437,7 +437,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Bypass\\FileWrapper", "kind": "class", @@ -503,14 +503,14 @@ ], "returnType": "void", "summary": "Installs the wrapper, or widens what it already covers.", - "description": "", + "description": "Widening only, in both directions: a list added to the global mode\nchanges nothing, because global already reaches every class the list\nnames. That is why the branch below looks like it drops the argument —\nit does, and the coverage it would have asked for is already there.\nNarrowing is `targetOnly()`, which says so in its name.", "throws": [], "throwsInBody": false, "inheritedFrom": null, "see": [], "deprecated": null, "attributes": [], - "startLine": 56 + "startLine": 62 }, { "name": "isInstalled", @@ -525,7 +525,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 73 + "startLine": 79 }, { "name": "covers", @@ -548,7 +548,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 85 + "startLine": 91 }, { "name": "uninstall", @@ -563,7 +563,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 109 + "startLine": 115 }, { "name": "stream_open", @@ -607,7 +607,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 120 + "startLine": 126 }, { "name": "stream_read", @@ -630,7 +630,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 159 + "startLine": 165 }, { "name": "stream_write", @@ -653,7 +653,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 166 + "startLine": 172 }, { "name": "stream_tell", @@ -668,7 +668,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 173 + "startLine": 179 }, { "name": "stream_eof", @@ -683,7 +683,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 180 + "startLine": 186 }, { "name": "stream_seek", @@ -713,7 +713,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 187 + "startLine": 193 }, { "name": "stream_flush", @@ -728,7 +728,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 194 + "startLine": 200 }, { "name": "stream_lock", @@ -751,7 +751,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 201 + "startLine": 207 }, { "name": "stream_truncate", @@ -774,7 +774,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 220 + "startLine": 226 }, { "name": "stream_cast", @@ -789,7 +789,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 231 + "startLine": 237 }, { "name": "stream_stat", @@ -804,7 +804,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 246 + "startLine": 252 }, { "name": "stream_close", @@ -819,7 +819,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 253 + "startLine": 259 }, { "name": "stream_set_option", @@ -834,7 +834,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 266 + "startLine": 272 }, { "name": "url_stat", @@ -864,7 +864,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 276 + "startLine": 282 }, { "name": "unlink", @@ -887,7 +887,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 287 + "startLine": 293 }, { "name": "rename", @@ -917,7 +917,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 292 + "startLine": 298 }, { "name": "mkdir", @@ -954,7 +954,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 297 + "startLine": 303 }, { "name": "rmdir", @@ -977,7 +977,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 302 + "startLine": 308 }, { "name": "dir_opendir", @@ -1000,7 +1000,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 307 + "startLine": 313 }, { "name": "dir_readdir", @@ -1015,7 +1015,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 321 + "startLine": 327 }, { "name": "dir_rewinddir", @@ -1030,7 +1030,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 328 + "startLine": 334 }, { "name": "dir_closedir", @@ -1045,7 +1045,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 336 + "startLine": 342 }, { "name": "stream_metadata", @@ -1082,7 +1082,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 349 + "startLine": 355 } ], "constants": [ @@ -1099,7 +1099,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Bypass\\FinalStripper", "kind": "class", @@ -1159,7 +1159,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Captor", "kind": "class", @@ -1287,7 +1287,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Cardinality", "kind": "class", @@ -1345,7 +1345,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 32 + "startLine": 30 }, { "name": "atLeast", @@ -1368,7 +1368,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 39 + "startLine": 37 }, { "name": "between", @@ -1398,7 +1398,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 44 + "startLine": 42 }, { "name": "any", @@ -1413,7 +1413,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 70 + "startLine": 68 }, { "name": "never", @@ -1428,7 +1428,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 75 + "startLine": 73 }, { "name": "allows", @@ -1451,7 +1451,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 83 + "startLine": 81 }, { "name": "describe", @@ -1466,17 +1466,17 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 92 + "startLine": 90 } ], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Cardinality.php#L13", + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Cardinality.php#L15", "implementedBy": [] }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\Blueprint", "kind": "class", @@ -1613,7 +1613,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\DoubleFactory", "kind": "class", @@ -1635,6 +1635,29 @@ "constructorParams": [], "publicProperties": [], "publicMethods": [ + { + "name": "isGenerated", + "static": true, + "params": [ + { + "name": "double", + "type": "object", + "description": "", + "default": null, + "variadic": false + } + ], + "returnType": "bool", + "summary": "Whether this object is a double this factory made — asked by the facade\nmethods, which are handed an object rather than a closure and otherwise\ncannot tell \"never was a double\" from \"was one before a reset\".", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 27 + }, { "name": "instantiate", "static": true, @@ -1656,7 +1679,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 35 + "startLine": 48 }, { "name": "blueprintFor", @@ -1679,7 +1702,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 46 + "startLine": 59 }, { "name": "blueprintOfGenerated", @@ -1702,17 +1725,24 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 61 + "startLine": 80 + } + ], + "constants": [ + { + "name": "GENERATED_NAMESPACE", + "type": "string", + "summary": "Where every generated double's class lives, and nothing else does.", + "value": "Rasuvaeff\\Understudy\\Codegen\\Generated\\" } ], - "constants": [], "enumCases": [], "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Codegen/DoubleFactory.php#L17", "implementedBy": [] }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\MethodSignature", "kind": "class", @@ -1821,6 +1851,15 @@ "promoted": true, "promotedVisibility": "public", "readonly": true + }, + { + "name": "sensitiveParameters", + "type": "list", + "description": "positions the contract marked `#[\\SensitiveParameter]`.\nResolved here, with the rest of the reflection, because a\nfailure message is rendered on the hot path of a failing\ntest and PHP redacts these in its own traces", + "default": "[]", + "promoted": true, + "promotedVisibility": "public", + "readonly": true } ], "publicProperties": [], @@ -1832,7 +1871,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\PropertyDefaults", "kind": "class", @@ -1885,7 +1924,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\PropertySignature", "kind": "class", @@ -1975,7 +2014,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\TargetUnifier", "kind": "class", @@ -2028,7 +2067,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\TypeRenderer", "kind": "class", @@ -2148,7 +2187,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Defaults\\DefaultFactories", "kind": "class", @@ -2253,7 +2292,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Defaults\\TypeDefaultResolver", "kind": "class", @@ -2313,6 +2352,13 @@ "description": "", "default": "false", "variadic": false + }, + { + "name": "double", + "type": "?object", + "description": "", + "default": "NULL", + "variadic": false } ], "returnType": "mixed", @@ -2375,7 +2421,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 52 + "startLine": 53 } ], "constants": [], @@ -2385,7 +2431,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\AmbiguousDefaultFactory", "kind": "class", @@ -2445,7 +2491,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\BypassUnavailable", "kind": "class", @@ -2551,7 +2597,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\CannotWire", "kind": "class", @@ -2796,7 +2842,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ConflictingExpectation", "kind": "class", @@ -2916,7 +2962,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ContextOwnershipViolation", "kind": "class", @@ -2961,7 +3007,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ForgottenDouble", "kind": "class", @@ -3098,7 +3144,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ForwardingTargetMismatch", "kind": "class", @@ -3188,7 +3234,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\InvalidCallSpecification", "kind": "class", @@ -3228,6 +3274,52 @@ { "name": "notADouble", "static": true, + "params": [ + { + "name": "facade", + "type": "non-empty-string", + "description": "the method that was given the object", + "default": null, + "variadic": false + } + ], + "returnType": "Rasuvaeff\\Understudy\\Exception\\InvalidCallSpecification", + "summary": "Handed the object directly, so there is no closure to blame — naming\none sends the reader looking for a mistake they have not made.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 32 + }, + { + "name": "neverBesideACount", + "static": true, + "params": [ + { + "name": "bound", + "type": "non-empty-string", + "description": "the count argument written beside `never`", + "default": null, + "variadic": false + } + ], + "returnType": "Rasuvaeff\\Understudy\\Exception\\InvalidCallSpecification", + "summary": "The wording is the analysers' word for word, and deliberately so: a\nuser who saw the report before running the suite must not have to\nrecognise a second phrasing of the same mistake afterwards.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 48 + }, + { + "name": "exactCountBesideABound", + "static": true, "params": [], "returnType": "Rasuvaeff\\Understudy\\Exception\\InvalidCallSpecification", "summary": "", @@ -3238,7 +3330,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 26 + "startLine": 57 }, { "name": "misplacedTailMatcher", @@ -3275,7 +3367,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 38 + "startLine": 69 }, { "name": "emptyCombinator", @@ -3298,7 +3390,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 53 + "startLine": 84 }, { "name": "invertedBounds", @@ -3335,7 +3427,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 71 + "startLine": 102 }, { "name": "invalidPattern", @@ -3365,7 +3457,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 89 + "startLine": 120 }, { "name": "tailMatcherInCombinator", @@ -3395,7 +3487,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 104 + "startLine": 135 }, { "name": "emptySequence", @@ -3410,7 +3502,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 115 + "startLine": 146 }, { "name": "protocolAlreadyArmed", @@ -3440,7 +3532,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 127 + "startLine": 158 }, { "name": "incompleteSpecification", @@ -3477,7 +3569,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 143 + "startLine": 174 }, { "name": "omittedBeforeSpecified", @@ -3514,7 +3606,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 159 + "startLine": 190 }, { "name": "omittedTailNeedsRest", @@ -3544,7 +3636,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 174 + "startLine": 205 }, { "name": "closureFailed", @@ -3567,7 +3659,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 185 + "startLine": 216 }, { "name": "staticMethodCalled", @@ -3590,7 +3682,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 197 + "startLine": 228 } ], "constants": [], @@ -3600,7 +3692,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\InvalidDefaultValue", "kind": "class", @@ -3660,7 +3752,128 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", + "rootReference": null, + "class": "Rasuvaeff\\Understudy\\Exception\\InvalidSpecificationArgument", + "kind": "class", + "isApi": true, + "isAbstract": false, + "isThrowable": true, + "summary": "An argument to a specification that no run could act on: a maximum call\ncount below its minimum, a negative count, `returns()` with nothing to\nreturn.", + "description": "Extends `\\InvalidArgumentException` because that is what these three paths\nhave always thrown, and a user's `catch (\\InvalidArgumentException $e)`\naround them must keep working. It implements `UnderstudyError` because that\ninterface says it is implemented by every exception this library throws,\nand these three were the exceptions to that — so a `catch (UnderstudyError\n$e)`, which the documentation recommends for catching misuse of Understudy\nitself, walked straight past them.", + "deprecated": null, + "see": [], + "extensionTags": [], + "extends": "InvalidArgumentException", + "implements": [ + "Stringable", + "Throwable", + "Rasuvaeff\\Understudy\\Exception\\UnderstudyError" + ], + "attributes": [], + "constructorParams": [], + "publicProperties": [], + "publicMethods": [ + { + "name": "maximumBelowMinimum", + "static": true, + "params": [ + { + "name": "minimum", + "type": "int", + "description": "the lower bound as written", + "default": null, + "variadic": false + }, + { + "name": "maximum", + "type": "int", + "description": "the upper bound as written, below the lower one", + "default": null, + "variadic": false + } + ], + "returnType": "Rasuvaeff\\Understudy\\Exception\\InvalidSpecificationArgument", + "summary": "A cardinality whose upper bound is below its lower one: no number of\ncalls satisfies both.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 31 + }, + { + "name": "negativeCount", + "static": true, + "params": [ + { + "name": "count", + "type": "int", + "description": "the count as written", + "default": null, + "variadic": false + } + ], + "returnType": "Rasuvaeff\\Understudy\\Exception\\InvalidSpecificationArgument", + "summary": "A call count below zero, which no run can produce.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 45 + }, + { + "name": "noReturnValues", + "static": true, + "params": [], + "returnType": "Rasuvaeff\\Understudy\\Exception\\InvalidSpecificationArgument", + "summary": "`returns()` with no arguments: there is nothing for the double to\nreturn, and the chain would answer the next call with nothing at all.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 54 + }, + { + "name": "unknownType", + "static": true, + "params": [ + { + "name": "type", + "type": "string", + "description": "the name as written", + "default": null, + "variadic": false + } + ], + "returnType": "Rasuvaeff\\Understudy\\Exception\\InvalidSpecificationArgument", + "summary": "`Arg::instanceOf()` naming a class or interface that is not loadable —\na matcher nothing can ever satisfy, which would otherwise report itself\nonly as an expectation that was never met.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 66 + } + ], + "constants": [], + "enumCases": [], + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidSpecificationArgument.php#L22", + "implementedBy": [] + }, + { + "root": "core", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\MatcherLeaked", "kind": "class", @@ -3727,7 +3940,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\NeverMethodCalled", "kind": "class", @@ -3847,7 +4060,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\NoDefaultValue", "kind": "class", @@ -3914,7 +4127,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\NothingCaptured", "kind": "class", @@ -3967,7 +4180,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\OriginalCallUnavailable", "kind": "class", @@ -4050,7 +4263,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\OriginalReturnTypeViolation", "kind": "class", @@ -4117,7 +4330,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\OutcomeUnavailable", "kind": "class", @@ -4200,7 +4413,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\StrictModeViolation", "kind": "class", @@ -4267,7 +4480,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\UnderstudyError", "kind": "interface", @@ -4301,6 +4514,7 @@ "Rasuvaeff\\Understudy\\Exception\\ForwardingTargetMismatch", "Rasuvaeff\\Understudy\\Exception\\InvalidCallSpecification", "Rasuvaeff\\Understudy\\Exception\\InvalidDefaultValue", + "Rasuvaeff\\Understudy\\Exception\\InvalidSpecificationArgument", "Rasuvaeff\\Understudy\\Exception\\MatcherLeaked", "Rasuvaeff\\Understudy\\Exception\\NeverMethodCalled", "Rasuvaeff\\Understudy\\Exception\\NoDefaultValue", @@ -4315,7 +4529,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\UnsupportedTarget", "kind": "class", @@ -4435,7 +4649,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\VerificationFailed", "kind": "class", @@ -4522,7 +4736,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\ExpectBuilder", "kind": "class", @@ -4570,7 +4784,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\Action", "kind": "interface", @@ -4627,7 +4841,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ArgumentFormatter", "kind": "class", @@ -4670,7 +4884,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 57 + "startLine": 60 }, { "name": "format", @@ -4700,7 +4914,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 76 + "startLine": 79 } ], "constants": [], @@ -4710,7 +4924,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ComputeAnswer", "kind": "class", @@ -4777,7 +4991,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\Expectation", "kind": "class", @@ -5235,7 +5449,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ReturnValue", "kind": "class", @@ -5302,7 +5516,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ThrowError", "kind": "class", @@ -5369,7 +5583,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\FailureKind", "kind": "enum", @@ -5458,7 +5672,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\FailureReport", "kind": "class", @@ -5603,7 +5817,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 98 + "startLine": 125 }, { "name": "renderCallLog", @@ -5633,7 +5847,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 131 + "startLine": 158 } ], "constants": [], @@ -5643,7 +5857,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Invocation", "kind": "class", @@ -5686,24 +5900,6 @@ "promotedVisibility": "public", "readonly": true }, - { - "name": "file", - "type": "?string", - "description": "", - "default": "NULL", - "promoted": true, - "promotedVisibility": "public", - "readonly": true - }, - { - "name": "line", - "type": "?int", - "description": "", - "default": "NULL", - "promoted": true, - "promotedVisibility": "public", - "readonly": true - }, { "name": "double", "type": "?object", @@ -5721,6 +5917,15 @@ "promoted": true, "promotedVisibility": "private", "readonly": true + }, + { + "name": "sensitiveArguments", + "type": "list", + "description": "positions the contract marked\n`#[\\SensitiveParameter]`; carried on the call so a\nfailure message and a transcript can redact the value\nthe way PHP redacts it in its own traces", + "default": "[]", + "promoted": true, + "promotedVisibility": "public", + "readonly": true } ], "publicProperties": [], @@ -5738,7 +5943,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 65 + "startLine": 68 }, { "name": "recordFinalArguments", @@ -5761,7 +5966,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 75 + "startLine": 78 }, { "name": "belongsTo", @@ -5784,7 +5989,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 83 + "startLine": 86 }, { "name": "callOriginal", @@ -6003,7 +6208,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AllOf", "kind": "class", @@ -6087,7 +6292,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyArgument", "kind": "class", @@ -6161,7 +6366,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyOf", "kind": "class", @@ -6245,7 +6450,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyRest", "kind": "class", @@ -6345,7 +6550,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyTail", "kind": "class", @@ -6445,7 +6650,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\ArgumentMatcher", "kind": "interface", @@ -6533,7 +6738,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\ArrayContaining", "kind": "class", @@ -6617,7 +6822,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\BooleanValue", "kind": "class", @@ -6691,7 +6896,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Bounds", "kind": "class", @@ -6751,7 +6956,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Capturing", "kind": "class", @@ -6844,7 +7049,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\CountBetween", "kind": "class", @@ -6937,7 +7142,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\EmptyTail", "kind": "class", @@ -7037,7 +7242,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\FloatInRange", "kind": "class", @@ -7130,7 +7335,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\IdenticalTo", "kind": "class", @@ -7214,7 +7419,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\InstanceOfType", "kind": "class", @@ -7271,7 +7476,7 @@ "attributes": [ "Override" ], - "startLine": 18 + "startLine": 31 }, { "name": "describe", @@ -7288,17 +7493,17 @@ "attributes": [ "Override" ], - "startLine": 24 + "startLine": 37 } ], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Matcher/InstanceOfType.php#L10", + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Matcher/InstanceOfType.php#L12", "implementedBy": [] }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\IntInRange", "kind": "class", @@ -7391,7 +7596,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Negated", "kind": "class", @@ -7475,7 +7680,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Operand", "kind": "class", @@ -7558,7 +7763,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\QueryEquals", "kind": "class", @@ -7614,7 +7819,7 @@ } ], "returnType": "bool", - "summary": "", + "summary": "The `ReflectionMethod` is built per call on purpose, and the audit that\nasked about it got an answer rather than a patch: caching the decision\nper class and calling the getter directly was measured at 94 ns against\n104 ns, which is 0.6% of the 1.65 µs a dispatch through this matcher\ncosts. Ten nanoseconds do not buy a process-global cache and the\ninvalidation question that comes with it.", "description": "", "throws": [], "throwsInBody": false, @@ -7624,7 +7829,7 @@ "attributes": [ "Override" ], - "startLine": 31 + "startLine": 39 }, { "name": "describe", @@ -7641,7 +7846,7 @@ "attributes": [ "Override" ], - "startLine": 53 + "startLine": 61 } ], "constants": [], @@ -7651,7 +7856,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Satisfying", "kind": "class", @@ -7744,7 +7949,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\StringMatching", "kind": "class", @@ -7828,7 +8033,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\TailMatcher", "kind": "interface", @@ -7887,7 +8092,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Outcome", "kind": "class", @@ -11251,7 +11456,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\Absent", "kind": "enum", @@ -11294,7 +11499,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\ArmedSequence", "kind": "class", @@ -11462,7 +11667,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\DoubleState", "kind": "class", @@ -11608,7 +11813,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 117 + "startLine": 114 }, { "name": "setForwardingTarget", @@ -11631,7 +11836,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 122 + "startLine": 119 }, { "name": "setLabel", @@ -11654,7 +11859,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 130 + "startLine": 127 }, { "name": "writeProperty", @@ -11684,7 +11889,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 138 + "startLine": 135 }, { "name": "hasPropertyValue", @@ -11707,7 +11912,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 146 + "startLine": 143 }, { "name": "propertyValue", @@ -11730,7 +11935,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 154 + "startLine": 151 }, { "name": "referenceSlot", @@ -11767,7 +11972,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 168 + "startLine": 165 }, { "name": "hasActionFor", @@ -11797,7 +12002,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 190 + "startLine": 187 }, { "name": "addExpectation", @@ -11820,7 +12025,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 209 + "startLine": 206 }, { "name": "expectations", @@ -11835,7 +12040,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 281 + "startLine": 278 }, { "name": "expectationsFor", @@ -11865,7 +12070,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 299 + "startLine": 296 }, { "name": "declaredExpectations", @@ -11880,7 +12085,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 347 + "startLine": 344 }, { "name": "settle", @@ -11895,7 +12100,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 357 + "startLine": 354 }, { "name": "record", @@ -11918,7 +12123,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 386 + "startLine": 383 }, { "name": "callLog", @@ -11933,7 +12138,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 394 + "startLine": 391 } ], "constants": [], @@ -11943,7 +12148,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\InvocationSignal", "kind": "class", @@ -12019,18 +12224,22 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\Mode", "kind": "enum", - "isApi": true, + "isApi": false, "isAbstract": false, "isThrowable": false, "summary": "How an understudy answers a call no expectation matched.", - "description": "", + "description": "`@internal` like the rest of `Runtime\\`, and not by demotion: this enum\nnever appears in a public signature. A user says `Understudy::strict()`,\n`lean()` or `forwarding()`, and the case is what those write down — so an\n`@api` on it promised a contract nobody could reach and no document\ndescribed.", "deprecated": null, "see": [], - "extensionTags": [], + "extensionTags": { + "internal": [ + "" + ] + }, "extends": null, "implements": [ "UnitEnum" @@ -12073,12 +12282,12 @@ "backingValue": null } ], - "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Runtime/Mode.php#L12", + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Runtime/Mode.php#L18", "implementedBy": [] }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\ReferenceSlot", "kind": "class", @@ -12114,7 +12323,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\Runtime", "kind": "class", @@ -12527,7 +12736,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 727 + "startLine": 736 }, { "name": "propertyWrite", @@ -12564,7 +12773,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 763 + "startLine": 772 }, { "name": "referenceSlot", @@ -12601,7 +12810,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 821 + "startLine": 830 }, { "name": "callOriginal", @@ -12638,7 +12847,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 849 + "startLine": 858 }, { "name": "reset", @@ -12653,7 +12862,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1009 + "startLine": 1018 } ], "constants": [], @@ -12663,7 +12872,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\RuntimeContext", "kind": "class", @@ -13010,7 +13219,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\SequenceVerdict", "kind": "enum", @@ -13219,7 +13428,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Understudy", "kind": "class", @@ -13408,7 +13617,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 504 + "startLine": 502 }, { "name": "lastCall", @@ -13431,7 +13640,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 526 + "startLine": 524 }, { "name": "strict", @@ -13454,7 +13663,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 544 + "startLine": 542 }, { "name": "lean", @@ -13477,7 +13686,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 567 + "startLine": 565 }, { "name": "forwarding", @@ -13507,7 +13716,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 585 + "startLine": 583 }, { "name": "delegate", @@ -13537,7 +13746,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 636 + "startLine": 634 }, { "name": "wire", @@ -13567,7 +13776,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 668 + "startLine": 666 }, { "name": "bypassFinals", @@ -13595,7 +13804,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 705 + "startLine": 703 }, { "name": "defaults", @@ -13625,7 +13834,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 790 + "startLine": 788 }, { "name": "label", @@ -13655,7 +13864,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 801 + "startLine": 799 }, { "name": "unused", @@ -13678,7 +13887,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 809 + "startLine": 807 }, { "name": "forget", @@ -13701,7 +13910,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 849 + "startLine": 847 }, { "name": "nothingElse", @@ -13731,7 +13940,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 870 + "startLine": 868 }, { "name": "allVerified", @@ -13754,7 +13963,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 913 + "startLine": 911 }, { "name": "expectSequence", @@ -13777,7 +13986,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 998 + "startLine": 996 }, { "name": "verifySequence", @@ -13800,7 +14009,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1038 + "startLine": 1036 }, { "name": "transcript", @@ -13823,7 +14032,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1064 + "startLine": 1062 }, { "name": "scope", @@ -13853,7 +14062,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1200 + "startLine": 1198 }, { "name": "checkpoint", @@ -13876,7 +14085,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1228 + "startLine": 1226 }, { "name": "reset", @@ -13891,7 +14100,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1255 + "startLine": 1253 }, { "name": "idle", @@ -13906,7 +14115,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1267 + "startLine": 1265 } ], "constants": [], @@ -13916,7 +14125,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\VerificationFailure", "kind": "class", @@ -14023,7 +14232,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\WhenBuilder", "kind": "class", @@ -14031,7 +14240,7 @@ "isAbstract": false, "isThrowable": false, "summary": "Configures what a stubbed call does.", - "description": "`TReturn` is the return type of the specified method. Plain Psalm and\nPHPStan cannot infer it from the closure on their own — `understudy-psalm`\nfills it in, and until then the parameter stays `mixed`, which is why the\ntemplate is declared here rather than added later: a published signature\ncannot grow one without changing its contract.", + "description": "`TReturn` is the return type of the specified method. Plain Psalm and\nPHPStan cannot infer it from the closure on their own — `understudy-psalm`\nfills it in, and until then the parameter stays `mixed`, which is why the\ntemplate is declared here rather than added later: a published signature\ncannot grow one without changing its contract.\n\nNot `final`, and the one class here that is not: `ExpectBuilder` extends it\nto add the cardinality verbs, and the two are one fluent vocabulary rather\nthan two. The cost is that `protected readonly Expectation` — an\n`@internal` type — is reachable from a subclass a consumer could write. That\nis tolerated rather than intended: nothing in the contract invites it, and\nclosing it would mean duplicating every action verb.", "deprecated": null, "see": [], "extensionTags": { @@ -14076,7 +14285,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 45 + "startLine": 53 }, { "name": "throws", @@ -14099,7 +14308,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 67 + "startLine": 75 }, { "name": "answers", @@ -14122,7 +14331,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 77 + "startLine": 85 }, { "name": "then", @@ -14137,7 +14346,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 96 + "startLine": 104 }, { "name": "times", @@ -14167,17 +14376,17 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 113 + "startLine": 121 } ], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/WhenBuilder.php#L25", + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/WhenBuilder.php#L33", "implementedBy": [] }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Wiring\\Wire", "kind": "class", @@ -14242,7 +14451,7 @@ "functions": [ { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\expect", @@ -14266,7 +14475,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\expectSequence", @@ -14290,7 +14499,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\verify", @@ -14342,7 +14551,7 @@ }, { "root": "core", - "rootVersion": "v0.5.0", + "rootVersion": "v0.7.2", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\when", diff --git a/docs/scripts/check-integrity.mjs b/docs/scripts/check-integrity.mjs index 494025b..a857b35 100644 --- a/docs/scripts/check-integrity.mjs +++ b/docs/scripts/check-integrity.mjs @@ -41,8 +41,8 @@ const COMPLETENESS_BUDGET = { 'type without a summary': 0, 'method without a summary': 76, 'parameter without a description': 162, - 'constructor parameter without a description': 17, - 'throwing method without @throws': 12, + 'constructor parameter without a description': 15, + 'throwing method without @throws': 11, } const errors = [] diff --git a/docs/src/api/classes/Arg.md b/docs/src/api/classes/Arg.md index 780c4e8..c64a072 100644 --- a/docs/src/api/classes/Arg.md +++ b/docs/src/api/classes/Arg.md @@ -9,7 +9,7 @@ description: "Argument matchers, usable only inside a specification closure:" `Rasuvaeff\Understudy\Arg` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Arg.php#L48) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Arg.php#L48) — **Version:** v0.7.2 Argument matchers, usable only inside a specification closure: diff --git a/docs/src/api/classes/Captor.md b/docs/src/api/classes/Captor.md index 743a51e..6e8049e 100644 --- a/docs/src/api/classes/Captor.md +++ b/docs/src/api/classes/Captor.md @@ -9,7 +9,7 @@ description: "A typed argument captor, built by Arg::captor()." `Rasuvaeff\Understudy\Captor` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Captor.php#L38) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Captor.php#L38) — **Version:** v0.7.2 **Type parameters:** diff --git a/docs/src/api/classes/Cardinality.md b/docs/src/api/classes/Cardinality.md index f263cd1..4f36ea9 100644 --- a/docs/src/api/classes/Cardinality.md +++ b/docs/src/api/classes/Cardinality.md @@ -9,7 +9,7 @@ description: "How many times a call is allowed to happen." `Rasuvaeff\Understudy\Cardinality` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Cardinality.php#L13) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Cardinality.php#L15) — **Version:** v0.7.2 How many times a call is allowed to happen. `null` as the maximum means no upper bound. diff --git a/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md b/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md index 7065e3c..084d32a 100644 --- a/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md +++ b/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md @@ -9,7 +9,7 @@ description: "Two registered default factories are equally close to the requeste `Rasuvaeff\Understudy\Exception\AmbiguousDefaultFactory` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/AmbiguousDefaultFactory.php#L12) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/AmbiguousDefaultFactory.php#L12) — **Version:** v0.7.2 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/BypassUnavailable.md b/docs/src/api/classes/Exception/BypassUnavailable.md index de9c4b4..a48c4f5 100644 --- a/docs/src/api/classes/Exception/BypassUnavailable.md +++ b/docs/src/api/classes/Exception/BypassUnavailable.md @@ -9,7 +9,7 @@ description: "`bypassFinals()` cannot do what was asked of it." `Rasuvaeff\Understudy\Exception\BypassUnavailable` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/BypassUnavailable.php#L12) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/BypassUnavailable.php#L12) — **Version:** v0.7.2 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/CannotWire.md b/docs/src/api/classes/Exception/CannotWire.md index 4dc3ebf..a9af0c6 100644 --- a/docs/src/api/classes/Exception/CannotWire.md +++ b/docs/src/api/classes/Exception/CannotWire.md @@ -9,7 +9,7 @@ description: "`wire()` cannot build the subject, or cannot decide what to pass i `Rasuvaeff\Understudy\Exception\CannotWire` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/CannotWire.php#L12) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/CannotWire.php#L12) — **Version:** v0.7.2 **Extends:** `InvalidArgumentException` diff --git a/docs/src/api/classes/Exception/ConflictingExpectation.md b/docs/src/api/classes/Exception/ConflictingExpectation.md index 6e64a04..5e8461f 100644 --- a/docs/src/api/classes/Exception/ConflictingExpectation.md +++ b/docs/src/api/classes/Exception/ConflictingExpectation.md @@ -9,7 +9,7 @@ description: "A `when()` or `expect()` names a call another registration already `Rasuvaeff\Understudy\Exception\ConflictingExpectation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ConflictingExpectation.php#L21) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ConflictingExpectation.php#L21) — **Version:** v0.7.2 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/ContextOwnershipViolation.md b/docs/src/api/classes/Exception/ContextOwnershipViolation.md index f5f01f4..ed2d584 100644 --- a/docs/src/api/classes/Exception/ContextOwnershipViolation.md +++ b/docs/src/api/classes/Exception/ContextOwnershipViolation.md @@ -9,7 +9,7 @@ description: "Configuration and verification belong to the context that created `Rasuvaeff\Understudy\Exception\ContextOwnershipViolation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ContextOwnershipViolation.php#L14) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ContextOwnershipViolation.php#L14) — **Version:** v0.7.2 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/ForgottenDouble.md b/docs/src/api/classes/Exception/ForgottenDouble.md index fe22438..fe70b5f 100644 --- a/docs/src/api/classes/Exception/ForgottenDouble.md +++ b/docs/src/api/classes/Exception/ForgottenDouble.md @@ -9,7 +9,7 @@ description: "A double outlived the context that created it — almost always a `Rasuvaeff\Understudy\Exception\ForgottenDouble` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForgottenDouble.php#L16) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForgottenDouble.php#L16) — **Version:** v0.7.2 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/ForwardingTargetMismatch.md b/docs/src/api/classes/Exception/ForwardingTargetMismatch.md index c1d858c..20b109d 100644 --- a/docs/src/api/classes/Exception/ForwardingTargetMismatch.md +++ b/docs/src/api/classes/Exception/ForwardingTargetMismatch.md @@ -9,7 +9,7 @@ description: "The instance offered as a forwarding target does not satisfy what `Rasuvaeff\Understudy\Exception\ForwardingTargetMismatch` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForwardingTargetMismatch.php#L13) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForwardingTargetMismatch.php#L13) — **Version:** v0.7.2 **Extends:** `InvalidArgumentException` diff --git a/docs/src/api/classes/Exception/InvalidCallSpecification.md b/docs/src/api/classes/Exception/InvalidCallSpecification.md index aabdb90..92b81d5 100644 --- a/docs/src/api/classes/Exception/InvalidCallSpecification.md +++ b/docs/src/api/classes/Exception/InvalidCallSpecification.md @@ -9,7 +9,7 @@ description: "The closure handed to when()/verify()/calls() did not describe one `Rasuvaeff\Understudy\Exception\InvalidCallSpecification` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidCallSpecification.php#L15) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidCallSpecification.php#L15) — **Version:** v0.7.2 **Extends:** `LogicException` @@ -31,7 +31,30 @@ static noCallRecorded(): Exception\InvalidCallSpecification ### notADouble() ```php -static notADouble(): Exception\InvalidCallSpecification +static notADouble(non-empty-string $facade): Exception\InvalidCallSpecification +``` + +Handed the object directly, so there is no closure to blame — naming +one sends the reader looking for a mistake they have not made. + +- `$facade` — the method that was given the object + +### neverBesideACount() + +```php +static neverBesideACount(non-empty-string $bound): Exception\InvalidCallSpecification +``` + +The wording is the analysers' word for word, and deliberately so: a +user who saw the report before running the suite must not have to +recognise a second phrasing of the same mistake afterwards. + +- `$bound` — the count argument written beside `never` + +### exactCountBesideABound() + +```php +static exactCountBesideABound(): Exception\InvalidCallSpecification ``` ### misplacedTailMatcher() diff --git a/docs/src/api/classes/Exception/InvalidDefaultValue.md b/docs/src/api/classes/Exception/InvalidDefaultValue.md index ad541a1..679b85a 100644 --- a/docs/src/api/classes/Exception/InvalidDefaultValue.md +++ b/docs/src/api/classes/Exception/InvalidDefaultValue.md @@ -9,7 +9,7 @@ description: "A registered default factory produced a value the contract cannot `Rasuvaeff\Understudy\Exception\InvalidDefaultValue` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidDefaultValue.php#L12) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidDefaultValue.php#L12) — **Version:** v0.7.2 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/InvalidSpecificationArgument.md b/docs/src/api/classes/Exception/InvalidSpecificationArgument.md new file mode 100644 index 0000000..8314425 --- /dev/null +++ b/docs/src/api/classes/Exception/InvalidSpecificationArgument.md @@ -0,0 +1,77 @@ +--- +title: "InvalidSpecificationArgument" +description: "An argument to a specification that no run could act on: a maximum call count below its minimum, a negative count, `returns()` with nothing to return." +--- + + + +# `InvalidSpecificationArgument` + +`Rasuvaeff\Understudy\Exception\InvalidSpecificationArgument` + +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidSpecificationArgument.php#L22) — **Version:** v0.7.2 + +**Extends:** `InvalidArgumentException` + +**Implements:** `Stringable`, `Throwable`, [`Exception\UnderstudyError`](/api/classes/Exception/UnderstudyError) + +An argument to a specification that no run could act on: a maximum call +count below its minimum, a negative count, `returns()` with nothing to +return. + +Extends `\InvalidArgumentException` because that is what these three paths +have always thrown, and a user's `catch (\InvalidArgumentException $e)` +around them must keep working. It implements `UnderstudyError` because that +interface says it is implemented by every exception this library throws, +and these three were the exceptions to that — so a `catch (UnderstudyError +$e)`, which the documentation recommends for catching misuse of Understudy +itself, walked straight past them. + +## Methods + +### maximumBelowMinimum() + +```php +static maximumBelowMinimum( + int $minimum, + int $maximum, +): Exception\InvalidSpecificationArgument +``` + +A cardinality whose upper bound is below its lower one: no number of +calls satisfies both. + +- `$minimum` — the lower bound as written +- `$maximum` — the upper bound as written, below the lower one + +### negativeCount() + +```php +static negativeCount(int $count): Exception\InvalidSpecificationArgument +``` + +A call count below zero, which no run can produce. + +- `$count` — the count as written + +### noReturnValues() + +```php +static noReturnValues(): Exception\InvalidSpecificationArgument +``` + +`returns()` with no arguments: there is nothing for the double to +return, and the chain would answer the next call with nothing at all. + +### unknownType() + +```php +static unknownType(string $type): Exception\InvalidSpecificationArgument +``` + +`Arg::instanceOf()` naming a class or interface that is not loadable — +a matcher nothing can ever satisfy, which would otherwise report itself +only as an expectation that was never met. + +- `$type` — the name as written + diff --git a/docs/src/api/classes/Exception/MatcherLeaked.md b/docs/src/api/classes/Exception/MatcherLeaked.md index b0f02de..71228ea 100644 --- a/docs/src/api/classes/Exception/MatcherLeaked.md +++ b/docs/src/api/classes/Exception/MatcherLeaked.md @@ -9,7 +9,7 @@ description: "A matcher reached a real call instead of a specification closure." `Rasuvaeff\Understudy\Exception\MatcherLeaked` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/MatcherLeaked.php#L14) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/MatcherLeaked.php#L14) — **Version:** v0.7.2 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/NeverMethodCalled.md b/docs/src/api/classes/Exception/NeverMethodCalled.md index 51bf495..4ddf24d 100644 --- a/docs/src/api/classes/Exception/NeverMethodCalled.md +++ b/docs/src/api/classes/Exception/NeverMethodCalled.md @@ -9,7 +9,7 @@ description: "A method declared `: never` was called without an expectation that `Rasuvaeff\Understudy\Exception\NeverMethodCalled` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NeverMethodCalled.php#L14) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NeverMethodCalled.php#L14) — **Version:** v0.7.2 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/NoDefaultValue.md b/docs/src/api/classes/Exception/NoDefaultValue.md index 5869b63..48cfb9a 100644 --- a/docs/src/api/classes/Exception/NoDefaultValue.md +++ b/docs/src/api/classes/Exception/NoDefaultValue.md @@ -9,7 +9,7 @@ description: "A loose understudy had to answer a call, but the declared return t `Rasuvaeff\Understudy\Exception\NoDefaultValue` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NoDefaultValue.php#L14) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NoDefaultValue.php#L14) — **Version:** v0.7.2 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/NothingCaptured.md b/docs/src/api/classes/Exception/NothingCaptured.md index 2c1876a..b03620c 100644 --- a/docs/src/api/classes/Exception/NothingCaptured.md +++ b/docs/src/api/classes/Exception/NothingCaptured.md @@ -9,7 +9,7 @@ description: "`Captor::last()` was read before any matched call carried a value `Rasuvaeff\Understudy\Exception\NothingCaptured` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NothingCaptured.php#L13) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NothingCaptured.php#L13) — **Version:** v0.7.2 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/OriginalCallUnavailable.md b/docs/src/api/classes/Exception/OriginalCallUnavailable.md index fe564c0..75e30a1 100644 --- a/docs/src/api/classes/Exception/OriginalCallUnavailable.md +++ b/docs/src/api/classes/Exception/OriginalCallUnavailable.md @@ -9,7 +9,7 @@ description: "`callOriginal()` was asked to delegate, and there is nothing to de `Rasuvaeff\Understudy\Exception\OriginalCallUnavailable` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalCallUnavailable.php#L12) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalCallUnavailable.php#L12) — **Version:** v0.7.2 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md b/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md index 4f65505..175bf00 100644 --- a/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md +++ b/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md @@ -9,7 +9,7 @@ description: "A forwarded call returned an object the double cannot stand in for `Rasuvaeff\Understudy\Exception\OriginalReturnTypeViolation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalReturnTypeViolation.php#L12) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalReturnTypeViolation.php#L12) — **Version:** v0.7.2 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/OutcomeUnavailable.md b/docs/src/api/classes/Exception/OutcomeUnavailable.md index eac1f36..e360c84 100644 --- a/docs/src/api/classes/Exception/OutcomeUnavailable.md +++ b/docs/src/api/classes/Exception/OutcomeUnavailable.md @@ -9,7 +9,7 @@ description: "An invocation's outcome was read as the wrong kind: a returned val `Rasuvaeff\Understudy\Exception\OutcomeUnavailable` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OutcomeUnavailable.php#L14) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OutcomeUnavailable.php#L14) — **Version:** v0.7.2 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/StrictModeViolation.md b/docs/src/api/classes/Exception/StrictModeViolation.md index 561084f..d2e4f83 100644 --- a/docs/src/api/classes/Exception/StrictModeViolation.md +++ b/docs/src/api/classes/Exception/StrictModeViolation.md @@ -9,7 +9,7 @@ description: "A strict understudy received a call no expectation matched." `Rasuvaeff\Understudy\Exception\StrictModeViolation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/StrictModeViolation.php#L12) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/StrictModeViolation.php#L12) — **Version:** v0.7.2 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/UnderstudyError.md b/docs/src/api/classes/Exception/UnderstudyError.md index 82f1ae3..34e7e61 100644 --- a/docs/src/api/classes/Exception/UnderstudyError.md +++ b/docs/src/api/classes/Exception/UnderstudyError.md @@ -9,11 +9,11 @@ description: "Implemented by every exception this library throws, so a test can `Rasuvaeff\Understudy\Exception\UnderstudyError` -**Interface** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnderstudyError.php#L14) — **Version:** v0.5.0 +**Interface** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnderstudyError.php#L14) — **Version:** v0.7.2 **Implements:** `Throwable`, `Stringable` -**Implemented by:** [`Exception\AmbiguousDefaultFactory`](/api/classes/Exception/AmbiguousDefaultFactory), [`Exception\BypassUnavailable`](/api/classes/Exception/BypassUnavailable), [`Exception\CannotWire`](/api/classes/Exception/CannotWire), [`Exception\ConflictingExpectation`](/api/classes/Exception/ConflictingExpectation), [`Exception\ContextOwnershipViolation`](/api/classes/Exception/ContextOwnershipViolation), [`Exception\ForgottenDouble`](/api/classes/Exception/ForgottenDouble), [`Exception\ForwardingTargetMismatch`](/api/classes/Exception/ForwardingTargetMismatch), [`Exception\InvalidCallSpecification`](/api/classes/Exception/InvalidCallSpecification), [`Exception\InvalidDefaultValue`](/api/classes/Exception/InvalidDefaultValue), [`Exception\MatcherLeaked`](/api/classes/Exception/MatcherLeaked), [`Exception\NeverMethodCalled`](/api/classes/Exception/NeverMethodCalled), [`Exception\NoDefaultValue`](/api/classes/Exception/NoDefaultValue), [`Exception\NothingCaptured`](/api/classes/Exception/NothingCaptured), [`Exception\OriginalCallUnavailable`](/api/classes/Exception/OriginalCallUnavailable), [`Exception\OriginalReturnTypeViolation`](/api/classes/Exception/OriginalReturnTypeViolation), [`Exception\OutcomeUnavailable`](/api/classes/Exception/OutcomeUnavailable), [`Exception\StrictModeViolation`](/api/classes/Exception/StrictModeViolation), [`Exception\UnsupportedTarget`](/api/classes/Exception/UnsupportedTarget), [`Exception\VerificationFailed`](/api/classes/Exception/VerificationFailed) +**Implemented by:** [`Exception\AmbiguousDefaultFactory`](/api/classes/Exception/AmbiguousDefaultFactory), [`Exception\BypassUnavailable`](/api/classes/Exception/BypassUnavailable), [`Exception\CannotWire`](/api/classes/Exception/CannotWire), [`Exception\ConflictingExpectation`](/api/classes/Exception/ConflictingExpectation), [`Exception\ContextOwnershipViolation`](/api/classes/Exception/ContextOwnershipViolation), [`Exception\ForgottenDouble`](/api/classes/Exception/ForgottenDouble), [`Exception\ForwardingTargetMismatch`](/api/classes/Exception/ForwardingTargetMismatch), [`Exception\InvalidCallSpecification`](/api/classes/Exception/InvalidCallSpecification), [`Exception\InvalidDefaultValue`](/api/classes/Exception/InvalidDefaultValue), [`Exception\InvalidSpecificationArgument`](/api/classes/Exception/InvalidSpecificationArgument), [`Exception\MatcherLeaked`](/api/classes/Exception/MatcherLeaked), [`Exception\NeverMethodCalled`](/api/classes/Exception/NeverMethodCalled), [`Exception\NoDefaultValue`](/api/classes/Exception/NoDefaultValue), [`Exception\NothingCaptured`](/api/classes/Exception/NothingCaptured), [`Exception\OriginalCallUnavailable`](/api/classes/Exception/OriginalCallUnavailable), [`Exception\OriginalReturnTypeViolation`](/api/classes/Exception/OriginalReturnTypeViolation), [`Exception\OutcomeUnavailable`](/api/classes/Exception/OutcomeUnavailable), [`Exception\StrictModeViolation`](/api/classes/Exception/StrictModeViolation), [`Exception\UnsupportedTarget`](/api/classes/Exception/UnsupportedTarget), [`Exception\VerificationFailed`](/api/classes/Exception/VerificationFailed) Implemented by every exception this library throws, so a test can catch misuse of Understudy itself without catching the errors it reports about diff --git a/docs/src/api/classes/Exception/UnsupportedTarget.md b/docs/src/api/classes/Exception/UnsupportedTarget.md index ace4e5b..20787f8 100644 --- a/docs/src/api/classes/Exception/UnsupportedTarget.md +++ b/docs/src/api/classes/Exception/UnsupportedTarget.md @@ -9,7 +9,7 @@ description: "The requested target cannot be doubled, and no option would make i `Rasuvaeff\Understudy\Exception\UnsupportedTarget` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnsupportedTarget.php#L13) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnsupportedTarget.php#L13) — **Version:** v0.7.2 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/VerificationFailed.md b/docs/src/api/classes/Exception/VerificationFailed.md index f4e7a85..7352b79 100644 --- a/docs/src/api/classes/Exception/VerificationFailed.md +++ b/docs/src/api/classes/Exception/VerificationFailed.md @@ -9,7 +9,7 @@ description: "A verification about what the code under test did (or did not) do `Rasuvaeff\Understudy\Exception\VerificationFailed` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/VerificationFailed.php#L20) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/VerificationFailed.php#L20) — **Version:** v0.7.2 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/ExpectBuilder.md b/docs/src/api/classes/ExpectBuilder.md index 9b3c68b..42b208b 100644 --- a/docs/src/api/classes/ExpectBuilder.md +++ b/docs/src/api/classes/ExpectBuilder.md @@ -9,7 +9,7 @@ description: "Configures a call the code under test is expected to make." `Rasuvaeff\Understudy\ExpectBuilder` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/ExpectBuilder.php#L19) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/ExpectBuilder.php#L19) — **Version:** v0.7.2 **Extends:** [`WhenBuilder`](/api/classes/WhenBuilder) diff --git a/docs/src/api/classes/FailureKind.md b/docs/src/api/classes/FailureKind.md index 09d5444..14dafa6 100644 --- a/docs/src/api/classes/FailureKind.md +++ b/docs/src/api/classes/FailureKind.md @@ -9,7 +9,7 @@ description: "What kind of verification claim a VerificationFailure reports." `Rasuvaeff\Understudy\FailureKind` -**Enum** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/FailureKind.php#L12) — **Version:** v0.5.0 +**Enum** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/FailureKind.php#L12) — **Version:** v0.7.2 **Implements:** `UnitEnum` diff --git a/docs/src/api/classes/Invocation.md b/docs/src/api/classes/Invocation.md index b4945a9..dc8723c 100644 --- a/docs/src/api/classes/Invocation.md +++ b/docs/src/api/classes/Invocation.md @@ -9,7 +9,7 @@ description: "One recorded call on an understudy." `Rasuvaeff\Understudy\Invocation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Invocation.php#L20) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Invocation.php#L20) — **Version:** v0.7.2 One recorded call on an understudy. @@ -24,10 +24,9 @@ __construct( non-empty-string $method, list $args, positive-int $sequence, - ?string $file = NULL, - ?int $line = NULL, ?object $double = NULL, list $liveArgs = [], + list $sensitiveArguments = [], ) ``` @@ -36,10 +35,9 @@ __construct( | `$method` | `non-empty-string` | *required* | | | `$args` | `list` | *required* | | | `$sequence` | `positive-int` | *required* | position in this context's global call order | -| `$file` | `?string` | `NULL` | | -| `$line` | `?int` | `NULL` | | | `$double` | `?object` | `NULL` | | | `$liveArgs` | `list` | `[]` | the arguments as the caller still holds them, references included — what delegation needs, where $args is a reading of them | +| `$sensitiveArguments` | `list` | `[]` | positions the contract marked `#[\SensitiveParameter]`; carried on the call so a failure message and a transcript can redact the value the way PHP redacts it in its own traces | ## Methods diff --git a/docs/src/api/classes/Outcome.md b/docs/src/api/classes/Outcome.md index 00403ff..9ff7de4 100644 --- a/docs/src/api/classes/Outcome.md +++ b/docs/src/api/classes/Outcome.md @@ -9,7 +9,7 @@ description: "How one call ended: with a value or with a throwable." `Rasuvaeff\Understudy\Outcome` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Outcome.php#L16) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Outcome.php#L16) — **Version:** v0.7.2 How one call ended: with a value or with a throwable. Kept as its own type because `null` is a perfectly valid return value and cannot double as diff --git a/docs/src/api/classes/Runtime/Mode.md b/docs/src/api/classes/Runtime/Mode.md deleted file mode 100644 index daef904..0000000 --- a/docs/src/api/classes/Runtime/Mode.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: "Mode" -description: "How an understudy answers a call no expectation matched." ---- - - - -# `Mode` - -`Rasuvaeff\Understudy\Runtime\Mode` - -**Enum** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Runtime/Mode.php#L12) — **Version:** v0.5.0 - -**Implements:** `UnitEnum` - -How an understudy answers a call no expectation matched. - -## Constants - -| Constant | Type | Value | Description | -|---|---|---|---| -| `Loose` | `Runtime\Mode` | | Answer with a type-safe default. | -| `Strict` | `Runtime\Mode` | | Fail immediately, naming the method. | -| `Forwarding` | `Runtime\Mode` | | Delegate to a real instance, recording the call and its outcome. | - -## Cases - -| Case | Backing value | -|---|---| -| `Loose` | — | -| `Strict` | — | -| `Forwarding` | — | - diff --git a/docs/src/api/classes/Understudy.md b/docs/src/api/classes/Understudy.md index 6970213..52ed7e9 100644 --- a/docs/src/api/classes/Understudy.md +++ b/docs/src/api/classes/Understudy.md @@ -9,7 +9,7 @@ description: "The whole public surface, as static methods so that an understudy `Rasuvaeff\Understudy\Understudy` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Understudy.php#L39) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Understudy.php#L39) — **Version:** v0.7.2 The whole public surface, as static methods so that an understudy itself can stay free of service members: every one of them would be a name the doubled diff --git a/docs/src/api/classes/VerificationFailure.md b/docs/src/api/classes/VerificationFailure.md index 23b921a..f2fe965 100644 --- a/docs/src/api/classes/VerificationFailure.md +++ b/docs/src/api/classes/VerificationFailure.md @@ -9,7 +9,7 @@ description: "The structured half of one verification failure — the same facts `Rasuvaeff\Understudy\VerificationFailure` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/VerificationFailure.php#L33) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/VerificationFailure.php#L33) — **Version:** v0.7.2 The structured half of one verification failure — the same facts the rendered message states, addressable by field. diff --git a/docs/src/api/classes/WhenBuilder.md b/docs/src/api/classes/WhenBuilder.md index f62440a..28b21a0 100644 --- a/docs/src/api/classes/WhenBuilder.md +++ b/docs/src/api/classes/WhenBuilder.md @@ -9,7 +9,7 @@ description: "Configures what a stubbed call does." `Rasuvaeff\Understudy\WhenBuilder` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/WhenBuilder.php#L25) — **Version:** v0.5.0 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/WhenBuilder.php#L33) — **Version:** v0.7.2 **Type parameters:** @@ -23,6 +23,13 @@ fills it in, and until then the parameter stays `mixed`, which is why the template is declared here rather than added later: a published signature cannot grow one without changing its contract. +Not `final`, and the one class here that is not: `ExpectBuilder` extends it +to add the cardinality verbs, and the two are one fluent vocabulary rather +than two. The cost is that `protected readonly Expectation` — an +`@internal` type — is reachable from a subclass a consumer could write. That +is tolerated rather than intended: nothing in the contract invites it, and +closing it would mean duplicating every action verb. + ## Constructor ```php diff --git a/docs/src/api/exceptions.md b/docs/src/api/exceptions.md index aa63d36..9b7f261 100644 --- a/docs/src/api/exceptions.md +++ b/docs/src/api/exceptions.md @@ -20,6 +20,7 @@ Every `@api` type across all five packages that implements `Throwable`. | [`ForwardingTargetMismatch`](/api/classes/Exception/ForwardingTargetMismatch) | core | `InvalidArgumentException` | The instance offered as a forwarding target does not satisfy what the double stands in for. | | [`InvalidCallSpecification`](/api/classes/Exception/InvalidCallSpecification) | core | `LogicException` | The closure handed to when()/verify()/calls() did not describe one call the way a specification… | | [`InvalidDefaultValue`](/api/classes/Exception/InvalidDefaultValue) | core | `RuntimeException` | A registered default factory produced a value the contract cannot hold. | +| [`InvalidSpecificationArgument`](/api/classes/Exception/InvalidSpecificationArgument) | core | `InvalidArgumentException` | An argument to a specification that no run could act on: a maximum call count below its minimum, a… | | [`MatcherLeaked`](/api/classes/Exception/MatcherLeaked) | core | `LogicException` | A matcher reached a real call instead of a specification closure. | | [`NeverMethodCalled`](/api/classes/Exception/NeverMethodCalled) | core | `RuntimeException` | A method declared `: never` was called without an expectation that throws. | | [`NoDefaultValue`](/api/classes/Exception/NoDefaultValue) | core | `RuntimeException` | A loose understudy had to answer a call, but the declared return type has no safe default. | diff --git a/docs/src/api/index.md b/docs/src/api/index.md index fd0995e..d65d410 100644 --- a/docs/src/api/index.md +++ b/docs/src/api/index.md @@ -31,6 +31,7 @@ Generated by reflection (`docs/scripts/reflect-api.php`) over all five packages' | [`ForwardingTargetMismatch`](/api/classes/Exception/ForwardingTargetMismatch) | class | The instance offered as a forwarding target does not satisfy what the double stands in for. | | [`InvalidCallSpecification`](/api/classes/Exception/InvalidCallSpecification) | class | The closure handed to when()/verify()/calls() did not describe one call the way a specification… | | [`InvalidDefaultValue`](/api/classes/Exception/InvalidDefaultValue) | class | A registered default factory produced a value the contract cannot hold. | +| [`InvalidSpecificationArgument`](/api/classes/Exception/InvalidSpecificationArgument) | class | An argument to a specification that no run could act on: a maximum call count below its minimum, a… | | [`MatcherLeaked`](/api/classes/Exception/MatcherLeaked) | class | A matcher reached a real call instead of a specification closure. | | [`NeverMethodCalled`](/api/classes/Exception/NeverMethodCalled) | class | A method declared `: never` was called without an expectation that throws. | | [`NoDefaultValue`](/api/classes/Exception/NoDefaultValue) | class | A loose understudy had to answer a call, but the declared return type has no safe default. | @@ -46,7 +47,6 @@ Generated by reflection (`docs/scripts/reflect-api.php`) over all five packages' | [`FailureKind`](/api/classes/FailureKind) | enum | What kind of verification claim a VerificationFailure reports. | | [`Invocation`](/api/classes/Invocation) | class | One recorded call on an understudy. | | [`Outcome`](/api/classes/Outcome) | class | How one call ended: with a value or with a throwable. | -| [`Mode`](/api/classes/Runtime/Mode) | enum | How an understudy answers a call no expectation matched. | | [`Understudy`](/api/classes/Understudy) | class | The whole public surface, as static methods so that an understudy itself can stay free of service… | | [`VerificationFailure`](/api/classes/VerificationFailure) | class | The structured half of one verification failure — the same facts the rendered message states,… | | [`WhenBuilder`](/api/classes/WhenBuilder) | class | Configures what a stubbed call does. | diff --git a/examples/README.md b/examples/README.md index 091e85d..2c7907e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -21,7 +21,7 @@ demonstrate changes. underscore is what marks it an include rather than a script of its own. `case-studies/` holds the cookbook scenarios: each one reproduces a real -failure message quoted on a [cookbook](docs/src/cookbook) page of the +failure message quoted on a [cookbook](../docs/src/cookbook) page of the documentation site. They are gated by `make docs-cookbook`, which diffs their output against the pages — not by `composer build` — and their `_bootstrap.php` is the include, same convention as `_check.php`. diff --git a/llms.txt b/llms.txt index 01f8a59..bd345eb 100644 --- a/llms.txt +++ b/llms.txt @@ -23,7 +23,9 @@ No runtime deps; `ext-mbstring` is not needed. - Interfaces and classes. A class may be the first target only (PHP has single inheritance); interfaces follow it. The target's constructor and destructor never run, private and static methods are left alone, protected ones are - dispatched but unconfigurable, writable public properties start empty, and a + dispatched but unconfigurable, a writable public property keeps a declared + default and otherwise starts empty -- including one PROMOTED through the + constructor, which is skipped, so it differs from the real object -- and a `clone` is a double of its own. - Refused before generation, each with the reason: a `final` class, a class with a non-private `final` instance method, an enum, a trait, an internal class, an @@ -184,7 +186,7 @@ after it. Arguments match by `===`, or by matcher. | `not($v)` | negates a literal or another matcher | | `allOf(...)` | everything every operand accepts; an operand is a matcher or a literal | | `anyOf(...)` | anything at least one operand accepts | -| `instanceOf($class)` | an instance of the class or interface | +| `instanceOf($class)` | an instance of the class or interface; a name that is not loadable is refused, not silently never-matching | | `satisfies($fn, $description)` | whatever the predicate accepts | | `containing($entries)` | an array holding these entries and possibly more | | `count(minimum:, maximum:)` | an array or `Countable` of that size | @@ -360,11 +362,14 @@ created there becomes forgotten when the scope closes. Configuration and verification require the owning context; normal calls from another Fiber are allowed and are routed to the owner's log. `checkpoint()` keeps the understudies, their modes and their labels, and -clears the expectations and calls the current phase has settled. +clears the expectations and the SETTLED calls of the current phase — settled +meaning claimed by a matching `expect()` or a successful `verify()`. A call +covered by a `when()` stub alone is never claimed and survives, because +`nothingElse()` still reads it. -`transcript()` retains every invocation until `reset()` or `checkpoint()`; -avoid unbounded hot loops through doubles when arguments or results hold large -object graphs. +`transcript()` retains every invocation until `reset()` (see `checkpoint()` +above: it clears only the settled ones); avoid unbounded hot loops through +doubles when arguments or results hold large object graphs. Failure raises `VerificationFailed`, whose message names the double, the expectation, the actual count, and marks differing arguments with `*`. @@ -420,6 +425,7 @@ All implement `Rasuvaeff\Understudy\Exception\UnderstudyError`. | Exception | Raised when | |---|---| | `InvalidCallSpecification` | the closure made no call, or threw first | +| `InvalidSpecificationArgument` | an argument no run could act on: `times(5, 2)`, a negative count, `returns()` with nothing to return, `Arg::instanceOf()` naming a class that is not loadable. Extends `\InvalidArgumentException`, which is what these paths threw before they implemented `UnderstudyError` | | `UnsupportedTarget` | target missing, undoublable, or targets conflict | | `StrictModeViolation` | a strict double got an unconfigured call | | `NoDefaultValue` | loose mode has no safe value for the return type | diff --git a/src/Cardinality.php b/src/Cardinality.php index f0e438f..3acd739 100644 --- a/src/Cardinality.php +++ b/src/Cardinality.php @@ -4,6 +4,8 @@ namespace Rasuvaeff\Understudy; +use Rasuvaeff\Understudy\Exception\InvalidSpecificationArgument; + /** * How many times a call is allowed to happen. `null` as the maximum means no * upper bound. @@ -21,11 +23,7 @@ private function __construct( public ?int $maximum, ) { if ($maximum !== null && $maximum < $minimum) { - throw new \InvalidArgumentException(sprintf( - 'A maximum call count cannot be below the minimum, got minimum %d and maximum %d', - $minimum, - $maximum, - )); + throw InvalidSpecificationArgument::maximumBelowMinimum($minimum, $maximum); } } @@ -58,7 +56,7 @@ public static function between(int $minimum, ?int $maximum): self private static function nonNegative(int $count): int { if ($count < 0) { - throw new \InvalidArgumentException('A call count cannot be negative, got ' . $count); + throw InvalidSpecificationArgument::negativeCount($count); } return $count; diff --git a/src/Codegen/DoubleFactory.php b/src/Codegen/DoubleFactory.php index 4e201a9..6e16bad 100644 --- a/src/Codegen/DoubleFactory.php +++ b/src/Codegen/DoubleFactory.php @@ -58,6 +58,12 @@ public static function instantiate(Blueprint $blueprint): object */ public static function blueprintFor(array $contracts): Blueprint { + // A list assembled programmatically can name the same contract twice, + // and `implements A, A` does not compile — as a fatal out of `eval()`, + // uncatchable and fatal to the whole run. The duplicate says nothing + // the first mention did not, so it is dropped rather than refused. + /** @var non-empty-list $contracts */ + $contracts = array_values(array_unique($contracts)); $key = implode('|', $contracts); return self::$blueprints[$key] ??= self::compile($contracts, $key); @@ -123,13 +129,52 @@ private static function reflect(string $contract, bool $primary): \ReflectionCla $reflection = new \ReflectionClass($contract); - if (!$reflection->isInterface()) { + if ($reflection->isInterface()) { + self::rejectUndoublableInterface($contract); + } else { self::rejectUndoublableClass($reflection, $contract, $primary); } return $reflection; } + /** + * The five interfaces the language forbids a userland class to implement. + * + * Every other refusal in this file is a considered `UnsupportedTarget`; + * these used to walk past all of them and be answered by the compiler + * instead, as a fatal error out of `eval()` — uncatchable by `try`, by an + * adapter, by anything, and fatal to the whole suite run rather than to + * one test. `DateTimeInterface` and `Throwable` are among the first + * contracts anybody reaches for, a clock and an error, so the fatal was + * not a corner. + * + * Not a property of being built in: `Iterator`, `IteratorAggregate`, + * `Stringable` and `Countable` double perfectly well. It is these five + * specifically, and each of them has a way through. + * + * @param class-string $contract + */ + private static function rejectUndoublableInterface(string $contract): void + { + $reason = match (ltrim(strtolower($contract), '\\')) { + 'throwable' => 'PHP forbids a userland class to implement Throwable directly. Double a ' + . 'concrete exception class instead, or an interface of your own that extends none of it.', + 'unitenum', 'backedenum' => 'only an enum may implement it, and an enum cannot be doubled at all — ' + . 'its cases are the values themselves. Pass the case you need, or double an interface the ' + . 'enum implements.', + 'datetimeinterface' => 'PHP forbids a userland class to implement DateTimeInterface. Pass a real ' + . '\DateTimeImmutable, or put a clock interface of your own in front of it and double that.', + 'traversable' => 'PHP requires it to be reached through Iterator or IteratorAggregate. Double ' + . 'one of those — both work here — or an interface of yours that extends one.', + default => null, + }; + + if ($reason !== null) { + throw UnsupportedTarget::notDoublable($contract, $reason); + } + } + /** * Collects the abstract hooked properties the generated class has to * declare — an interface property, or an `abstract` hooked one on a class. diff --git a/src/Codegen/MethodSignature.php b/src/Codegen/MethodSignature.php index 85e348d..a895787 100644 --- a/src/Codegen/MethodSignature.php +++ b/src/Codegen/MethodSignature.php @@ -21,6 +21,10 @@ * takes, so the common path pays nothing * @param 'public'|'protected' $visibility a protected method is overridden and dispatched like any * other, but native visibility keeps it out of setup closures + * @param list $sensitiveParameters positions the contract marked `#[\SensitiveParameter]`. + * Resolved here, with the rest of the reflection, because a + * failure message is rendered on the hot path of a failing + * test and PHP redacts these in its own traces */ public function __construct( public string $name, @@ -33,5 +37,6 @@ public function __construct( public bool $hasReferenceParameters = false, public bool $static = false, public string $visibility = 'public', + public array $sensitiveParameters = [], ) {} } diff --git a/src/Codegen/TargetUnifier.php b/src/Codegen/TargetUnifier.php index 0e45f78..a36f4db 100644 --- a/src/Codegen/TargetUnifier.php +++ b/src/Codegen/TargetUnifier.php @@ -335,9 +335,41 @@ private static function unifyMethod(string $name, array $declarations): MethodSi // An override may widen visibility but never narrow it, so one // public declaration makes the whole override public. visibility: self::visibilityOf($declarations), + sensitiveParameters: self::sensitiveParameters($declarations), ); } + /** + * Positions any declaration marks `#[\SensitiveParameter]`. + * + * Any, not all: the attribute is a statement that the value must not be + * printed, and one contract asking for that is enough. PHP redacts such + * parameters in its own stack traces; understudy formats arguments itself, + * so without this the value went verbatim into the failure message and the + * transcript — which is to say into a CI log. + * + * @param non-empty-list<\ReflectionMethod> $declarations + * + * @return list + */ + private static function sensitiveParameters(array $declarations): array + { + $positions = []; + + foreach ($declarations as $declaration) { + foreach ($declaration->getParameters() as $position => $parameter) { + if ($parameter->getAttributes(\SensitiveParameter::class) !== []) { + $positions[$position] = true; + } + } + } + + $sorted = array_keys($positions); + sort($sorted); + + return $sorted; + } + /** * @param non-empty-list<\ReflectionMethod> $declarations * diff --git a/src/Exception/CannotWire.php b/src/Exception/CannotWire.php index a355514..b0f847f 100644 --- a/src/Exception/CannotWire.php +++ b/src/Exception/CannotWire.php @@ -59,7 +59,7 @@ public static function unknownOverride(string $sut, string $name, string $known) public static function incompatibleOverride(string $sut, string $name, string $expected, string $given): self { return new self(sprintf( - "Cannot wire `%s`: the override for `\$%s` is a `%s`, and the constructor declares `%s`.\n" + "Cannot wire `%s`: the override for `\$%s` has type `%s`, and the constructor declares `%s`.\n" . 'The check happens before the constructor runs, so a wrong type is reported here rather than ' . 'as a TypeError from inside the subject.', $sut, diff --git a/src/Exception/InvalidDefaultValue.php b/src/Exception/InvalidDefaultValue.php index c92fe6e..578dd6d 100644 --- a/src/Exception/InvalidDefaultValue.php +++ b/src/Exception/InvalidDefaultValue.php @@ -17,7 +17,7 @@ final class InvalidDefaultValue extends \RuntimeException implements UnderstudyE public static function ofWrongType(string $requested, string $produced): self { return new self(sprintf( - "The default factory registered for `%s` produced a `%s`.\n" + "The default factory registered for `%s` produced a value of type `%s`.\n" . 'A factory has to return something the requested type can hold, or the double answers with a ' . 'value the code under test cannot use.', $requested, diff --git a/src/Exception/InvalidSpecificationArgument.php b/src/Exception/InvalidSpecificationArgument.php new file mode 100644 index 0000000..37fb50b --- /dev/null +++ b/src/Exception/InvalidSpecificationArgument.php @@ -0,0 +1,73 @@ + 'null', $value === true => 'true', $value === false => 'false', + // NAN is cast with a warning on PHP 8.5 — raised from inside the + // library, while it renders a message about a failure, which under + // `failOnWarning` turns the report into a different failure. INF + // and -INF cast silently and read fine. + is_float($value) && is_nan($value) => 'NAN', is_int($value), is_float($value) => (string) $value, is_string($value) => self::formatString($value), is_array($value) => self::formatArray($value, $depth), @@ -106,6 +114,15 @@ private static function formatString(string $value): string { $truncated = self::truncate($value); + // The cut marker is ours and is valid UTF-8; escaping it along with a + // binary payload would print its three bytes instead of the character. + $marker = ''; + + if ($truncated !== $value && str_ends_with($truncated, self::CUT)) { + $marker = self::CUT; + $truncated = substr($truncated, 0, -strlen(self::CUT)); + } + // A raw newline or quote would break the single line a failure message // renders each argument on, and hide what actually differed. // @@ -118,7 +135,35 @@ private static function formatString(string $value): string "\t" => '\\t', ]); - return "'" . $escaped . "'"; + // Control bytes go out as `\xNN` for the same reason: a NUL travelled + // into the message — and into `transcript()` — as the raw byte, which + // log processors treat differently from one another and a terminal may + // not show at all. + // + // A string that is not valid UTF-8 has its high bytes escaped too. In + // valid text those bytes are a multibyte character, which is readable + // and which `truncate()` already keeps whole; in a binary argument + // they are the half sequence that made the line unreadable. + $pattern = self::isUtf8($value) + ? '/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/' + : '/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff]/'; + + $escaped = (string) preg_replace_callback( + $pattern, + static fn(array $match): string => sprintf('\\x%02X', ord($match[0])), + $escaped, + ); + + return "'" . $escaped . $marker . "'"; + } + + /** + * Whether the bytes are valid UTF-8, asked with PCRE so the package needs + * no ext-mbstring — the same reason {@see truncate()} counts that way. + */ + private static function isUtf8(string $value): bool + { + return preg_match('//u', $value) === 1; } /** diff --git a/src/FailureReport.php b/src/FailureReport.php index 5e6d5c1..be87db5 100644 --- a/src/FailureReport.php +++ b/src/FailureReport.php @@ -76,13 +76,40 @@ public static function render( public static function renderCall(Invocation $invocation): string { return ArgumentFormatter::scope( - static fn(): string => $invocation->method . '(' . implode(', ', array_map( - static fn(mixed $argument): string => ArgumentFormatter::format($argument), - $invocation->args, - )) . ')', + static function () use ($invocation): string { + $arguments = []; + + /** @var mixed $argument */ + foreach ($invocation->args as $position => $argument) { + $arguments[] = self::renderArgument($invocation, $position, $argument); + } + + return $invocation->method . '(' . implode(', ', $arguments) . ')'; + }, ); } + /** + * One recorded argument, redacted where the contract asked for it. + * + * PHP redacts a `#[\SensitiveParameter]` in its own stack traces; + * understudy formats arguments itself and printed the value verbatim — + * into the failure message and into `transcript()`, which is to say into + * a CI log. The placeholder is PHP's own wording, so a reader who has + * seen a redacted trace recognises it. + * + * The type is kept: knowing a password argument was a string and not null + * is most of what the message is read for, and it gives nothing away. + */ + private static function renderArgument(Invocation $invocation, int $position, mixed $argument): string + { + if (!\in_array($position, $invocation->sensitiveArguments, strict: true)) { + return ArgumentFormatter::format($argument); + } + + return sprintf('%s SensitiveParameter', get_debug_type($argument)); + } + /** * The call a strict double refused, and what was configured for that * method and did not accept it — with the arguments that rejected it @@ -138,7 +165,7 @@ public static function renderCallLog(array $callLog, array $expectedArgs = []): /** @var mixed $argument */ foreach ($invocation->args as $position => $argument) { - $rendered = ArgumentFormatter::format($argument); + $rendered = self::renderArgument($invocation, $position, $argument); $arguments[] = self::differs($expectedArgs, $position, $argument) ? '*' . $rendered . '*' : $rendered; diff --git a/src/Invocation.php b/src/Invocation.php index a20e50a..8117616 100644 --- a/src/Invocation.php +++ b/src/Invocation.php @@ -41,6 +41,10 @@ final class Invocation * @param list $liveArgs the arguments as the caller still holds them, * references included — what delegation needs, * where {@see $args} is a reading of them + * @param list $sensitiveArguments positions the contract marked + * `#[\SensitiveParameter]`; carried on the call so a + * failure message and a transcript can redact the value + * the way PHP redacts it in its own traces */ public function __construct( public readonly string $method, @@ -48,6 +52,7 @@ public function __construct( public readonly int $sequence, private readonly ?object $double = null, private readonly array $liveArgs = [], + public readonly array $sensitiveArguments = [], ) {} /** diff --git a/src/Matcher/InstanceOfType.php b/src/Matcher/InstanceOfType.php index 401e964..0214bc5 100644 --- a/src/Matcher/InstanceOfType.php +++ b/src/Matcher/InstanceOfType.php @@ -4,6 +4,8 @@ namespace Rasuvaeff\Understudy\Matcher; +use Rasuvaeff\Understudy\Exception\InvalidSpecificationArgument; + /** * @internal */ @@ -12,7 +14,18 @@ /** * @param class-string $type */ - public function __construct(private string $type) {} + public function __construct(private string $type) + { + // A type nothing can be an instance of matches nothing, forever, and + // says so nowhere: the reader sees "expected … but it was never + // called" and looks for the cause in the subject under test. The + // `class-string` annotation catches a literal under an analyser, not + // a name assembled at runtime and not a project without one — and + // `Understudy::for()` refuses the same input in the same breath. + if (!class_exists($type) && !interface_exists($type)) { + throw InvalidSpecificationArgument::unknownType($type); + } + } #[\Override] public function matches(mixed $argument): bool diff --git a/src/Runtime/Runtime.php b/src/Runtime/Runtime.php index 55e9595..e637f68 100644 --- a/src/Runtime/Runtime.php +++ b/src/Runtime/Runtime.php @@ -462,7 +462,8 @@ public static function dispatch(object $double, string $method, array $args): mi // through it, and the log would then show a value the caller never // passed. Both sides are kept, and only for the methods that can move — // snapshotting every call would cost the whole suite for a rare case. - $tracksReferences = $state->blueprint->method($method)?->hasReferenceParameters ?? false; + $signature = $state->blueprint->method($method); + $tracksReferences = $signature?->hasReferenceParameters ?? false; $invocation = new Invocation( method: $method, @@ -470,6 +471,7 @@ public static function dispatch(object $double, string $method, array $args): mi sequence: $context->nextSequence(), double: $double, liveArgs: $args, + sensitiveArguments: $signature?->sensitiveParameters ?? [], ); $state->record($invocation); diff --git a/src/WhenBuilder.php b/src/WhenBuilder.php index bdb365f..a8d267b 100644 --- a/src/WhenBuilder.php +++ b/src/WhenBuilder.php @@ -4,6 +4,7 @@ namespace Rasuvaeff\Understudy; +use Rasuvaeff\Understudy\Exception\InvalidSpecificationArgument; use Rasuvaeff\Understudy\Expectation\ComputeAnswer; use Rasuvaeff\Understudy\Expectation\Expectation; use Rasuvaeff\Understudy\Expectation\ReturnValue; @@ -54,7 +55,7 @@ public function returns(mixed ...$values): static $list = array_values($values); if ($list === []) { - throw new \InvalidArgumentException('returns() needs at least one value'); + throw InvalidSpecificationArgument::noReturnValues(); } // Several values ARE a chain: `returns($a, $b)` is exactly diff --git a/tests/ClassDoubleTest.php b/tests/ClassDoubleTest.php index d5884a7..e38fbcd 100644 --- a/tests/ClassDoubleTest.php +++ b/tests/ClassDoubleTest.php @@ -13,6 +13,7 @@ use Rasuvaeff\Understudy\Exception\ContextOwnershipViolation; use Rasuvaeff\Understudy\Exception\InvalidCallSpecification; use Rasuvaeff\Understudy\Exception\UnsupportedTarget; +use Rasuvaeff\Understudy\Tests\Fixture\BookRepository; use Rasuvaeff\Understudy\Tests\Fixture\Cls\AbstractLedger; use Rasuvaeff\Understudy\Tests\Fixture\Cls\Bookkeeper; use Rasuvaeff\Understudy\Tests\Fixture\Cls\Countable; @@ -34,6 +35,7 @@ use Rasuvaeff\Understudy\Understudy; use Testo\Assert; use Testo\Codecov\Covers; +use Testo\Data\DataProvider; use Testo\Expect; use Testo\Lifecycle\AfterTest; use Testo\Test; @@ -231,6 +233,102 @@ public function aStaticMultiTargetParameterConflictIsReportedBeforeEval(): void Understudy::for(\StaticParameterTargetForReview::class, \StaticParameterContractForReview::class); } + /** + * Five interfaces the language forbids a userland class to implement. + * They used to walk past every refusal in the factory and be answered by + * the compiler instead, as a fatal out of `eval()` — uncatchable, and + * fatal to the whole run rather than to one test. `DateTimeInterface` and + * `Throwable` are the first two contracts anybody reaches for. + * + * The whole message is asserted, not a fragment: it is the only thing the + * reader gets, and every half of a concatenation in one is a mutant a + * `contains()` cannot see. + * + * @param class-string $contract the interface `for()` must refuse + * @param string $reason the message after the name of the target + */ + #[DataProvider('undoublableInterfaceProvider')] + public function aBuiltInInterfaceNoClassMayImplementIsRefused(string $contract, string $reason): void + { + Expect::exception(UnsupportedTarget::class) + ->withMessage(sprintf('Cannot create an understudy for `%s`: %s', $contract, $reason)); + + Understudy::for($contract); + } + + public static function undoublableInterfaceProvider(): iterable + { + yield 'throwable' => [ + \Throwable::class, + 'PHP forbids a userland class to implement Throwable directly. Double a concrete exception ' + . 'class instead, or an interface of your own that extends none of it.', + ]; + yield 'unit enum' => [ + \UnitEnum::class, + 'only an enum may implement it, and an enum cannot be doubled at all — its cases are the ' + . 'values themselves. Pass the case you need, or double an interface the enum implements.', + ]; + yield 'backed enum' => [ + \BackedEnum::class, + 'only an enum may implement it, and an enum cannot be doubled at all — its cases are the ' + . 'values themselves. Pass the case you need, or double an interface the enum implements.', + ]; + yield 'date time' => [ + \DateTimeInterface::class, + 'PHP forbids a userland class to implement DateTimeInterface. Pass a real \DateTimeImmutable, ' + . 'or put a clock interface of your own in front of it and double that.', + ]; + yield 'traversable' => [ + \Traversable::class, + 'PHP requires it to be reached through Iterator or IteratorAggregate. Double one of those — ' + . 'both work here — or an interface of yours that extends one.', + ]; + } + + /** + * A name written with a leading backslash is the same interface, and + * `Understudy::for('\\Throwable')` is how it reads when the list is + * assembled from strings rather than from `::class`. + */ + public function aLeadingBackslashDoesNotHideTheRefusal(): void + { + Expect::exception(UnsupportedTarget::class)->withMessageContaining('Double a concrete exception class'); + + Understudy::for('\\Throwable'); + } + + /** + * The neighbours: being built in is not the reason those five are + * refused, and a rule written that way would take these with it. + * + * @param class-string $contract + */ + #[DataProvider('doublableBuiltInProvider')] + public function aBuiltInInterfaceThatMayBeImplementedStillDoubles(string $contract): void + { + Assert::instanceOf(Understudy::for($contract), $contract); + } + + public static function doublableBuiltInProvider(): iterable + { + yield 'iterator' => [\Iterator::class]; + yield 'iterator aggregate' => [\IteratorAggregate::class]; + yield 'stringable' => [\Stringable::class]; + yield 'countable' => [\Countable::class]; + } + + /** + * A contract list assembled programmatically can name the same interface + * twice, and `implements A, A` does not compile — another fatal out of + * `eval()`. The duplicate adds nothing the first mention did not. + */ + public function aDuplicatedContractIsAcceptedRatherThanFatal(): void + { + $double = Understudy::for(BookRepository::class, BookRepository::class); + + Assert::instanceOf($double, BookRepository::class); + } + /** * PHP allows a readonly class to be extended only by another readonly one, * and a readonly class has no writable properties — so the initialization diff --git a/tests/DefaultFactoriesTest.php b/tests/DefaultFactoriesTest.php index c7a009d..d1a94d4 100644 --- a/tests/DefaultFactoriesTest.php +++ b/tests/DefaultFactoriesTest.php @@ -119,7 +119,7 @@ public function aFactoryProducingTheWrongTypeIsRefused(): void Expect::exception(InvalidDefaultValue::class) ->withMessage( - "The default factory registered for `" . Logger::class . "` produced a `stdClass`.\n" + "The default factory registered for `" . Logger::class . "` produced a value of type `stdClass`.\n" . 'A factory has to return something the requested type can hold, or the double answers with ' . 'a value the code under test cannot use.', ); diff --git a/tests/Defaults/DefaultFactoriesUnitTest.php b/tests/Defaults/DefaultFactoriesUnitTest.php index 6989e62..0fd32f9 100644 --- a/tests/Defaults/DefaultFactoriesUnitTest.php +++ b/tests/Defaults/DefaultFactoriesUnitTest.php @@ -124,7 +124,7 @@ public function aWrongTypedAnswerIsRefused(): void $registry->register(Logger::class, static fn(): Concrete => new Concrete()); Expect::exception(InvalidDefaultValue::class) - ->withMessageContaining('produced a `' . Concrete::class . '`'); + ->withMessageContaining('produced a value of type `' . Concrete::class . '`'); $registry->valueFor(Logger::class); } diff --git a/tests/ErrorPathTest.php b/tests/ErrorPathTest.php index 85f3dde..57b7c7a 100644 --- a/tests/ErrorPathTest.php +++ b/tests/ErrorPathTest.php @@ -4,14 +4,18 @@ namespace Rasuvaeff\Understudy\Tests; +use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Cardinality; use Rasuvaeff\Understudy\Exception\CannotWire; use Rasuvaeff\Understudy\Exception\InvalidCallSpecification; use Rasuvaeff\Understudy\Exception\NoDefaultValue; use Rasuvaeff\Understudy\Exception\OutcomeUnavailable; +use Rasuvaeff\Understudy\Exception\UnderstudyError; use Rasuvaeff\Understudy\Exception\VerificationFailed; use Rasuvaeff\Understudy\Invocation; use Rasuvaeff\Understudy\Tests\Fixture\Book; use Rasuvaeff\Understudy\Tests\Fixture\BookRepository; +use Rasuvaeff\Understudy\Tests\Fixture\Credentials; use Rasuvaeff\Understudy\Tests\Fixture\Defaults\NullableShapes; use Rasuvaeff\Understudy\Tests\Fixture\Librarian; use Rasuvaeff\Understudy\Understudy; @@ -52,6 +56,82 @@ public function tearDown(): void Understudy::reset(); } + /** + * PHP redacts a `#[\SensitiveParameter]` in its own stack traces; + * understudy formats arguments itself and printed the value verbatim, + * into the failure message and into `transcript()` — which is to say into + * a CI log. The type is kept: knowing the argument was a string and not + * null is most of what the message is read for. + */ + public function aSensitiveArgumentIsRedactedInTheFailureMessage(): void + { + $login = Understudy::for(Credentials::class); + when(fn(): bool => $login->login('user', Arg::any()))->returns(true); + $login->login('user', 'ACTUAL-SUPER-SECRET'); + + try { + Understudy::verify(fn(): bool => $login->login('user', 'EXPECTED'), times: 1); + } catch (VerificationFailed $failure) { + // Marked as differing, like any other argument that did not match + // — the redaction replaces the value, not the reporting. + Assert::string($failure->getMessage())->contains('*string SensitiveParameter*'); + Assert::false(str_contains($failure->getMessage(), 'ACTUAL-SUPER-SECRET')); + + return; + } + + Assert::fail('Expected the verification to fail'); + } + + public function aSensitiveArgumentIsRedactedInTheTranscript(): void + { + $login = Understudy::for(Credentials::class); + when(fn(): bool => $login->login('user', Arg::any()))->returns(true); + $login->login('user', 'ACTUAL-SUPER-SECRET'); + + $transcript = Understudy::transcript($login); + + Assert::string($transcript)->contains("login('user', string SensitiveParameter)"); + Assert::false(str_contains($transcript, 'ACTUAL-SUPER-SECRET')); + } + + /** + * The three paths that threw a bare `\InvalidArgumentException` while + * `UnderstudyError` claimed to be implemented by every exception this + * library throws. A user who followed that claim caught none of them. + * + * @param callable(): mixed $call + */ + #[DataProvider('specificationArgumentProvider')] + public function aRefusedSpecificationArgumentIsAnUnderstudyError(callable $call): void + { + try { + $call(); + } catch (UnderstudyError $error) { + // And still an InvalidArgumentException, so a catch by the SPL + // type written before this change keeps working. + Assert::instanceOf($error, \InvalidArgumentException::class); + + return; + } + + Assert::fail('Expected the argument to be refused'); + } + + public static function specificationArgumentProvider(): iterable + { + yield 'a maximum below the minimum' => [static fn(): mixed => Cardinality::between(5, 2)]; + yield 'a negative count' => [static fn(): mixed => Cardinality::exactly(-1)]; + yield 'returns() with nothing to return' => [static function (): mixed { + $books = Understudy::for(BookRepository::class); + + return when(static fn(): ?Book => $books->find(1))->returns(); + }]; + yield 'a matcher for a class that does not exist' => [ + static fn(): mixed => Arg::instanceOf('Nope\\Missing'), + ]; + } + // --- a facade method handed something that is not a double --------------- /** diff --git a/tests/Expectation/ArgumentFormatterTest.php b/tests/Expectation/ArgumentFormatterTest.php index 8b6efb4..7c5f311 100644 --- a/tests/Expectation/ArgumentFormatterTest.php +++ b/tests/Expectation/ArgumentFormatterTest.php @@ -269,18 +269,30 @@ public function aMultibyteStringUnderTheLimitSurvivesWhole(): void public function aStringThatIsNotUtf8FallsBackToBytes(): void { // PCRE cannot count characters in bytes that are not UTF-8; the byte - // fallback keeps a binary blob from flooding the failure message. + // fallback keeps a binary blob from flooding the failure message. The + // bytes themselves are escaped: a half sequence printed raw is what + // the single-line rule exists to prevent. Assert::same( ArgumentFormatter::format(str_repeat("\xFF", 50)), - "'" . str_repeat("\xFF", 40) . "…'", + "'" . str_repeat('\\xFF', 40) . "…'", ); } - public function aShortStringThatIsNotUtf8SurvivesWhole(): void + public function aShortStringThatIsNotUtf8IsEscapedByteByByte(): void { - $value = "\xFF\xFE"; + Assert::same(ArgumentFormatter::format("\xFF\xFE"), "'\\xFF\\xFE'"); + } - Assert::same(ArgumentFormatter::format($value), "'" . $value . "'"); + /** + * A NUL and its neighbours would break the one line each argument is + * rendered on, and log processors disagree about what a raw NUL means. + * Valid UTF-8 above ASCII is left alone: it is readable, and truncate() + * already keeps a multibyte character whole. + */ + public function controlBytesAreEscapedAndMultibyteTextIsNot(): void + { + Assert::same(ArgumentFormatter::format("a\x00b\x01"), "'a\\x00b\\x01'"); + Assert::same(ArgumentFormatter::format('дом'), "'дом'"); } public function truncationKeepsTheStringsOwnBeginning(): void diff --git a/tests/Fixture/Credentials.php b/tests/Fixture/Credentials.php new file mode 100644 index 0000000..2249147 --- /dev/null +++ b/tests/Fixture/Credentials.php @@ -0,0 +1,14 @@ +values, $accepted); Expect::exception(CannotWire::class) - ->withMessageContaining('is a `' . $rejectedType . '`') + ->withMessageContaining('has type `' . $rejectedType . '`') ->withMessageContaining('declares `list<' . $declared . '>`'); Understudy::wire($subject, ['values' => [$rejected]]); @@ -310,7 +310,7 @@ public function anIncompatibleOverrideIsRefusedBeforeTheConstructorRuns(): void { Expect::exception(CannotWire::class) ->withMessage( - 'Cannot wire `' . CatalogService::class . '`: the override for `$repository` is a `stdClass`, ' + 'Cannot wire `' . CatalogService::class . '`: the override for `$repository` has type `stdClass`, ' . 'and the constructor declares `' . Repository::class . "`.\n" . 'The check happens before the constructor runs, so a wrong type is reported here rather ' . 'than as a TypeError from inside the subject.', @@ -343,7 +343,7 @@ public function nullIsAcceptedAsAnOverrideForANullableParameter(): void public function nullIsRefusedAsAnOverrideForANonNullableParameter(): void { Expect::exception(CannotWire::class) - ->withMessageContaining('the override for `$repository` is a `null`'); + ->withMessageContaining('the override for `$repository` has type `null`'); Understudy::wire(CatalogService::class, ['repository' => null]); }