Skip to content

Releases: rasuvaeff/understudy

v0.11.0

Choose a tag to compare

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

v0.10.0

Choose a tag to compare

@github-actions github-actions released this 06 Sep 09:14
53afa55

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

A specification is read the way the contract reads a call

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

Refusals for what used to be silent

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

Additions

  • Understudy::strict() and Understudy::label() answer with the double
    they configured
    , so the mode can be chosen where the double is handed over:
    ClientInterface::class => Understudy::strict(Understudy::for(ClientInterface::class))
    used to store null and fail three steps away from the cause (#126).

  • WhenBuilder::throwsWith() builds the exception from the call it
    answers, one per call — the shape throws() cannot express, and which
    everyone re-derived as a throwing answers() closure (#127).

  • Invocation::arg() reads one argument by position or by the contract's
    own parameter name, and refuses a name the method does not declare rather
    than answering null (#127).

  • The API reference documents the satellites at their current versions.
    docs/.api-workspace/composer.json pinned understudy-psalm ^0.2,
    understudy-phpstan ^0.2, understudy-phpunit ^0.1 and understudy-testo ^0.1 — a caret on 0.x pins a minor — so the site reflected the plugins at
    0.2.0 and the adapters at 0.1.x while 0.8.0, 0.5.0, 0.2.0 and 0.3.0 were out,
    and the weekly rebuild rebuilt the same thing. The pins move to the current
    lines, the workspace lock is committed so the pages in the repository and on
    the site come from the same install, and docs.yml runs composer outdated --direct --strict in the workspace: a satellite release the pins do not
    reach is a red build now, not a stale page.

  • static-analysis.yml runs composer rector in the Psalm job. It is part of
    composer release-check and was run by nobody but a human before a tag —
    this package went out four times with it red under green builds.

  • Two defects of the reference generator that the refresh exposed:
    docs/scripts/reflect-rules.php took every string literal starting with
    understudy. for a rule identifier, so ClosureShape's php-parser attribute
    understudy.receiverOfCall sat on the rules page as a sixth identifier nobody
    could write into ignoreErrors (only constants named *IDENTIFIER count
    now); and generate-api.mjs had no label for a trait, so the PHPUnit
    adapter's page was headed «undefined».

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 05 Sep 13:22
9ac32c6

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

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

v0.8.0

Choose a tag to compare

@github-actions github-actions released this 05 Sep 10:47
9f39e5a
  • 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).

v0.7.2

Choose a tag to compare

@github-actions github-actions released this 04 Sep 10:04
5aff3ea
  • Documentation review fixes. llms.txt no longer claims bypassFinals()
    and the runner adapters are «being built next» (both shipped long ago), and
    now lists all four free functions and the : static loose-default rule.
    The Prophecy migration table no longer teaches a non-existent
    expect(…)->never() — the supported form is expect(…)->times(0).
    idle() comments across the READMEs, llms.txt and the guide now say what
    the operation does: it covers every context of the test, not the current
    one. The Performance table's 5.38³ footnote marker was a damaged
    character. examples/README.md now documents case-studies/ and the gate
    that runs it (make docs-cookbook). AGENTS.md's dead link to the retired
    site plan replaced with where the decisions live.

v0.7.1

Choose a tag to compare

@github-actions github-actions released this 04 Sep 08:41
7174769
  • bypassFinals() missed a target whose declaration is written in another
    case.
    final class gate in namespace App IS App\Gate to PHP —
    class_exists() says so, and bypassFinals(\App\Gate::class) accepted the
    name and installed the target — but the strip compared both halves with
    === and walked past the very declaration it was installed for. The class
    stayed final, with nothing said until for() refused it. Both halves are
    compared the way PHP compares them now. Fixes #97.

v0.7.0

Choose a tag to compare

@github-actions github-actions released this 04 Sep 08:04
14176a1

A minor rather than a patch: Invocation loses two public properties,
verify() refuses argument combinations it used to resolve by precedence, and
notADouble() gains a required argument. Composer's caret treats that as
breaking on 0.x, and it is — though every one of them was the engine knowing
something and not saying it.

  • Invocation::$file and $line are gone. They were promoted readonly
    properties on an @api class that the dispatcher never filled in and
    nothing ever read — public API whose only value was null. Filling them
    would mean a debug_backtrace() on every dispatched call, which this
    package will not pay for a field nobody asked for.

  • verify($call, never: true, times: 3) is refused rather than resolved.
    never used to win and the count beside it was discarded without a word, so
    a test could say two opposite things and pass. Both analyser packages report
    this pairing and both promise a runtime counterpart for every complaint they
    make; this is it, in their wording, so a user does not meet a second
    phrasing of a mistake they have already seen.

  • verify($call, times: -1) is refused as the nonsense argument it is.
    The bounds now go through Cardinality, which is what the fluent times()
    has always used, so a negative count and a maximum below the minimum are
    caught where they are written. times: -1 used to become the range
    [-1, -1] and fail as an unmet count.

  • A facade method handed something that is not a double names itself.
    Understudy::strict(new stdClass()) used to answer "the specification
    closure did not call a method on an understudy" — and strict() has no
    closure, so the reader was sent looking for a mistake they had not made.
    strict, lean, forwarding, label, unused, nothingElse,
    allVerified and transcript each say their own name now, the way
    forget() already did. Knowing which facade was called also separates two
    mistakes that used to share one message: an object that never was a double,
    and a double whose context has been reset — the latter now gets the same
    ForgottenDouble answer a CALL on that object already got.

  • A loose double answers : static with itself. The receiver IS the
    double and the generated method really does declare static, so a fluent
    contract chains without a stub for each link. It used to refuse with
    NoDefaultValue — the one return type whose answer needs no invention at
    all. Both READMEs say so, and say why : self is a different claim.

  • A return-type conflict no longer prints an unresolved parent. The message
    paths rendered the type without the declaring class, so a reader on PHP 8.3
    or 8.4 was told a target declares : parent and left to work out which
    class that is; 8.5 resolves it in Reflection, which is why it went
    unnoticed. Pinned by a test that fails on 8.3 without the fix.

  • Runtime\Mode is @internal, like the rest of Runtime\. It never
    appears in a public signature — strict(), lean() and forwarding() are
    what a user writes — so the @api on it promised a contract nobody could
    reach and no document described.

  • Two orphaned docblocks removed, and WhenBuilder says why it is the one
    class here that is not final. FileWrapper::install() says that it only
    ever widens, and that a target list added to the global mode changes nothing
    because global already reaches every class it names.

  • A targeted bypassFinals() stops tokenising files it has no interest
    in.
    The guard was "does the source contain the word final", and
    finally contains it — 58% of a real vendor tree passes, and in targeted
    mode almost none of it declares anything asked for. A file that never names
    a target's class cannot declare that class, so it never reaches the
    tokenizer: 16 µs against 256 µs on a 41 KB file, or roughly a second of
    tokenising per process. The filter is a NECESSARY condition, which is why it
    is safe — its absence is proof, its presence decides nothing and the
    tokenizer still says.

  • QueryEquals keeps building its ReflectionMethod per call, and that
    is the answer rather than an omission: caching the decision per class and
    calling the getter directly measures 94 ns against 104 ns, which is 0.6% of
    the 1.65 µs a dispatch through this matcher costs. The number is in the
    code, so the next audit reads it instead of re-asking.

Fixes #95.

v0.6.0

Choose a tag to compare

@github-actions github-actions released this 04 Sep 06:35
fc563f9

A minor rather than a patch: a pair of targets the unifier used to accept is
now refused. Composer's caret treats that as breaking on 0.x, and it is —
though what those targets produced was a double that logged an argument value
neither contract declares.

  • Conflicting parameter defaults across unified contracts rejected the
    target instead of quietly becoming null.
    Understudy::for(A::class, B::class) where both declare tag(string $name, int $weight = 5) and
    = 7 rendered = null and widened the type to int|null to make that
    legal — so an omitted argument was logged as a value neither contract
    declares, and verify(fn () => $double->tag('alpha', 5)) did not match the
    call A would have made. That is the failure renderDefault()'s own
    docblock says the unifier refuses; now it does, the way the by-reference
    conflict beside it already did. A default declared in one target and absent
    or required in another is not a conflict and keeps working. Fixes #91.
  • A stub armed with -0.0 was invisible to a call made with 0.0. The
    dispatch index keyed a literal first argument by serialize(), which is not
    isomorphic to ===: -0.0 === 0.0 is true and d:-0; is not d:0;. The
    index is only consulted from the second expectation on a method, so an
    unrelated second stub changed the first one's behaviour. Fixes #92.
  • DispatchIndexPropertyTest gains a second property for identity, over
    literals of every scalar type — the existing one draws int only, so no
    float, let alone a signed zero, was ever reached. The pairs a key can get
    wrong in either direction are named Examples() rather than left to the
    random phase: both signed zeros, 0 against 0.0 and '0', '' against
    null and false, 1 against true, and NAN, which matches no call
    including itself.
  • The parity matrix gained ForeignCapture: somebody else's zero-argument
    capture() written inside a specification. Both analysers must report it,
    and one of them did not — understudy-psalm decided a capture by the method
    name and an empty argument list, because its issue hook is handed no resolved
    receiver, and swallowed the diagnostic. Fixed in that package (#18) and
    pinned here, which is what the matrix exists for.
  • perf/README.md records that the figures were re-verified for the v0.5.0
    tag and left unchanged: three runs a side at the commit they were taken from
    and on the release candidate, with our movement inside the competitors'.

v0.5.0

Choose a tag to compare

@github-actions github-actions released this 03 Sep 17:52
5b09cbe

A minor rather than a patch: a closing scope() verifies less than it did, and
two Arg:: factories now refuse a configuration they used to accept. Both are
behaviour toward the consumer's own test code, which Composer's caret already
treats as breaking on 0.x.

  • bin/consumer-smoke now carries the analyser parity matrix: eleven
    idioms — our Arg unqualified, aliased and fully qualified, the static verb
    form, rest() short and leaked, a captor capture in a specification and in a
    real call, a namesake Arg under two spellings, and a plain leak — checked
    against one table of expectations by both understudy-psalm and
    understudy-phpstan. The two answer the same contract by opposite
    mechanisms, and nothing compared them before; the analyser packages' own CI
    runs these legs against their working tree. The phpunit leg gained the
    composition hazard the adapter cannot defend against: a consumer class that
    overrides assertPostConditions() without the documented alias loses
    verification silently, and the README's recipe keeps it — both now executed
    rather than described.
  • A closing scope() no longer answers for the enclosing context. It
    verified every live context of the test, so a self-contained nested scope
    failed on a claim the caller had simply not got round to satisfying yet —
    including the recommended use of scope() to drop doubles holding OS
    resources before the runner's teardown, which is exactly the position where
    the enclosing expectations are still open. A close now verifies the context
    it opened, and only that one; nothing is settled or forgiven, so
    verifyAll(), checkpoint() and the runner adapter's teardown still report
    the enclosing claims. A test that relied on a scope surfacing an outer
    failure earlier will now see it at the end of the test instead. (#84)
  • A matcher that could never match is refused where it is written.
    Arg::int(min: 5, max: 1) — and the same shape in Arg::float() and
    Arg::count() — describes an empty range, and Arg::string('/[unclosed') is
    not a pattern PCRE compiles. Both built a matcher that answered "no" to every
    argument, so a typo surfaced as an expectation never met with nothing
    pointing at its cause; the broken pattern also raised PHP's own warning on
    every call from inside the code under test, which is the one thing a matcher
    must not do. Both now raise InvalidCallSpecification, and the pattern's
    message carries PCRE's own reason. (#86)
  • Two decisions written down rather than changed. A predicate handed to
    Arg::satisfies() is the test's own code, so an exception in it travels
    instead of being read as a mismatch — unlike Arg::which(), which reaches
    into the argument. And verifySequence() with no calls asserts that nothing
    happened, where expectSequence() refuses an empty protocol; both are now
    stated in the docblocks and pinned by tests.
  • The mutation gate quoted in README.md, README.ru.md and AGENTS.md says
    92, which is what infection.json5 has required since #76.
  • Both READMEs and the failure-messages guide say what was only in the 0.1.0
    changelog: the wording of a failure message is not public contract, and
    anything acting on a failure reads FailureKind and the readonly fields of
    VerificationFailure, which are frozen.

v0.4.1

Choose a tag to compare

@github-actions github-actions released this 30 Aug 09:59
d8d57b2
  • verifyAll() renders the call log when an expectation goes unmet. Two
    code paths reported the same failure and only one showed the calls:
    verify() rendered through FailureReport, while the verification sweep
    had a sprintf of its own. Since verifyAll() is the path every runner
    adapter takes, the alias table and the * argument marks — the part that
    says WHICH call differed — were missing from almost every failure anyone
    saw. Both paths now render the same report, the failure carries
    observedCalls, and the sentence the two used to word differently ("but it
    was called never") is settled. A test asserting the exact text of an unmet
    expectation may need updating. (#77, #79)
  • Doubling a built-in interface no longer emits a deprecation notice.
    PHP's own interfaces carry tentative return types, which
    ReflectionMethod::getReturnType() reports as null, so
    Understudy::for(Repo::class, Countable::class) generated count(): mixed
    and PHP answered with should either be compatible with Countable::count(): int. The unifier reads the tentative type, which
    satisfies the contract exactly and needs no #[\ReturnTypeWillChange]. The
    defect was invisible for ArrayAccess and JsonSerializable, whose
    tentative type is mixed anyway. (#78, #80)
  • A documentation site for the whole family, at
    https://rasuvaeff.github.io/understudy/: the guide, migration guides from
    Mockery and PHPUnit, a cookbook of real incidents whose output the build
    diffs against the scripts that produce it, and an API reference reflected
    out of all five packages' src/ — free functions and analyser rule
    identifiers included, neither of which a class-only reference would show.
    MIGRATION.md at the root is generated from the same pages. (#82)
  • The README's Mockery table mapped ->atLeast()->once() to
    times(minimum: 1), which is not what that does.
    times() branches on
    the number of arguments it was given, so one argument is an exact count
    however it is spelled; the open range is times(1, null). Both READMEs now
    show all four forms and say why. (#81)
  • The escaped-mutant hunt: 240 → 158, MSI 90.4% → 94.0%, gate raised to 92
    (the full trajectory and the reason live next to the number in
    infection.json5). About sixty targeted tests and fixtures, each written
    from an escaped mutant's diff rather than from the line it sits on:
    constant defaults now assert their rendered SOURCE (\Cfg::LIMIT, uppercase
    SELF::/PARENT:: resolved through the declaring hierarchy) where the old
    tests compared values a mutant renders identically; static satisfaction
    gets its satisfied complements (a type-distinct variadic tail, untyped and
    union return contracts); by-reference detection is asserted at every
    parameter position; FinalStripper pins the glued-comment and bare
    final const boundaries; settle() is pinned to drop satisfied claims and
    keep every stub; Fiber routing is pinned for by-reference returns,
    callOriginal() and ordering claims; the structured failure fields
    (actualCount, both bounds of unused(), observedCalls filtering,
    expectedCalls as strings, both halves of allVerified()) are asserted
    exactly, as are six refusal messages formerly checked by fragments. Three
    more test classes attribute #[Covers] they exercised all along. Three
    manual arbitrations confirmed the remainder is dominated by genuine
    equivalents — redundant second guards, set-map values read through
    array_keys(), perf fastpaths, warning-only offsets.
  • Honest-coverage sweep after 0.4.0. Twelve targeted tests kill the
    escaped mutants the new code left behind (hooked-property collection and
    rendering, cross-Fiber property routing, forwarding write-through, captor
    registration, the specification-hole boundary, retired-scope invisibility),
    and three test classes now attribute #[Covers] they exercised all along —
    which is what let several of those mutants escape unmapped. A new matcher
    property pins "no matcher throws on a hostile argument, every one describes
    itself" across the whole Arg:: catalog, rest() and a captor's
    capture() included.
  • examples/ caught up with 0.4.0: Arg::rest() and a typed captor in
    basic-usage.php, delegate() and lean() in modes.php, and a new
    property-hooks.php (self-skipping on PHP 8.3). The Mockery migration
    table gains the makePartial()/withAnyArgs()/Mockery::capture() rows,
    and three edge behaviours are now stated: a capture() in an
    expectSequence() step matches without recording, lean() cannot release
    the stable slot behind a & return, and a clone does not carry written
    property values over.