v0.1.0
-
Structured failures.
VerificationFailed::failures()answers a
list<VerificationFailure>— one record per failed claim, each carrying
itsFailureKind(UnmetExpectation,StrictStubUnused,OutOfOrder,
OutOfSequence,UnaccountedCalls,UnusedDouble), the double's label,
the call specification, the claimed bounds, the actual count, the observed
calls asInvocationrecords, and its own rendered summary. The exception
message is exactly the summaries joined with a blank line — there is no
path where the two disagree. For tooling that acts on a failure rather than
printing it: runner adapters, IDE plugins, report aggregators. The readonly
fields ofVerificationFailureandFailureKindare frozen public API
from v0.1.0; renaming, removing or retyping one is a major-version change. -
Golden files for rendered reports. Multi-line failure messages moved to
tests/fixtures/messages/*.txt, read through
Support\GoldenMessage::read(): a wording review now reads a.txtdiff
instead of PHP string concatenation. The convention is written into
AGENTS.md; single-line messages stay inline. -
A migration table for Mockery users in both READMEs — no aliases and no
converter, by policy: migration help belongs in documentation. The two rows
that bite carry the dogfooding lessons: a spy counter counted every call,
anexpect()counts only calls matching its arguments (so every migrated
counter needsnothingElse()), and incidental setup written as
shouldReceive(...)->once()becomeswhen(), notexpect(). -
AGENTS.md now states the module boundaries (a matcher depends on nothing
but leaf formatting; onlyRuntimeand the facade see everything) and the
four-file walkthrough for adding anArg::*matcher. -
Understudy::lastCall(callable $call): ?Invocation— the newest
recorded call matching the specification, or null when there was none. The
null-safe replacement for readingcount($calls) - 1out ofcalls():
an empty log has no last element, and Psalm reports the index arithmetic
asint<-1, max>before the test even runs. Found by dogfooding the
migratedyii3-correlation-idsuite, where the hand-written fake's
null-safehandledRequestproperty had no direct counterpart. -
Understudy::forget(object $double): void— retires a double on
purpose. For the double a test built and then replaced, still holding its
stubs: underverifyAll(strictStubs: true)that stub is a failure about a
double the test no longer uses. Verification, accounting and reset stop
seeing a forgotten double; any call on it — or any question about its
calls — fails withForgottenDouble, whose message namesforget()
rather than sending the reader looking for areset()they never wrote.
One-way, like every other form of forgetting here. Also found by the same
dogfooding. -
calls()already answerslist<Invocation>; a dogfooding review
suspected otherwise, and the suspicion is recorded here so the next
reviewer does not re-check it: the Psalm findings that prompted it came
fromcount($calls) - 1, whichlastCall()removes. -
ext-mbstringis no longer required. The only runtime use was counting
characters while truncating an argument for a failure message; PCRE (/./us)
does the counting now — it is a core extension and cannot be disabled — with
a byte fallback for strings that are not valid UTF-8. The package installs
on a stockphp:8.3-cliimage, where mbstring is absent. -
A registered loose default now outranks
nullon a nullable return.
Understudy::defaults(Book::class, …)had no effect on a method declared
?Book: the resolver answerednullbefore the registry was reached, and
the same happened inside a union carrying anullbranch. A registration is
the test saying what a type should be, and?Bookis still a Book when
there is one. Without a registration a nullable return isnullas before. -
Verification and teardown now span every context the test used, not only
the caller's. A body run in a Fiber owns a context of its own, and an
adapter asks about the test from wherever it stands — never the same place
under Testo, where#[RunInFiber]puts the pipeline in one Fiber and the
assert collector opens a second around the body. An unmetexpect()inside
such a test kept the suite green. Isolation is unchanged: a Fiber still has
its own recording phase, call log and sequence counter. -
Understudy::idle()answers for the whole test, andreset()drops every
context the test used — including when it is itself called from inside a
Fiber, which is the shape the runners actually have. -
Fixed: a DNF return type of two intersections,
(A&B)|(C&D), was cut in
half before it was split, and the corrupted type reached the user in the
error message. Parentheses now come off each branch, and a union of nothing
but intersections answers with the first one instead of refusing a type the
engine can build. -
bypassFinals()no longer breaksflock(),ftruncate(),stream_select()
orfile_put_contents(..., LOCK_EX)for files opened after it: the wrapper
implementsstream_lock,stream_truncateandstream_cast. -
Dispatch is flat in the number of expectations registered for a method
(800 stubs went from 64us per call to 1.3us), and a method with a single
expectation answers from a lookup and nothing else, so the common case is
unchanged. -
Understudy::idle()— whether the current context holds no doubles. Runner
adapters use it as an integration guard: a context that is not idle when the
next test begins means some earlier cleanup never ran. -
Understudy::nothingElse()accepts any number of doubles. A test that used
several can now close them out in one line —nothingElse($repo, $clock, $mailer)— and a failure reports every double with unaccounted calls
instead of stopping at the first. -
A model-based property over the ledger lifecycle — stub/expect → dispatch →
verify → checkpoint — driven byGen::commands()+StateMachine. Any
random interleaving of configuration, dispatch and verification commands has
to keep the double's answers, its call log and its accounting in lock-step
with a pure model, including most-recent-first matching, claim accounting,
verify marking,nothingElse()accounting and checkpoint settling. Four
pinned examples carry the layerings that were wrong before: a newer claim
shadowing an older stub, checkpoint survival of stubs, accounting through an
explicit verify, and a violated claim failing both checks. -
The comparative benchmarks are re-measured after the dispatch work, and both
READMEs carry the new figures. Per-call cost went from 1.75µs to 0.82µs and
building a double from 2.08µs to 1.28µs; PHPUnit improved by more, and is now
ahead of understudy end to end on both the stub and the mock scenario rather
than only per call. Per-double memory grew from ~350 B to 435–450 B — 32 of
those bytes are the second, reverse-ordered expectation list that made the
dispatch cheap, and the trade is written down next to the number. -
make perfpins the container to six cores and raises its CPU share. The
environment the old figures quoted was produced by flags that lived outside
the repository, which is why they are replaced rather than compared against. -
The refusal to double a
finalclass says which of three situations it is:
bypass was never enabled, it was enabled for other classes but not this one,
or it was asked for and could not reach the source. The last one names the
reason — read out of a PHAR, already loaded before the wrapper went in, no
source file at all. Saying "bypass is not enabled" while it was enabled sent
the reader to fix the thing that was already right. -
The bypass acceptance matrix now runs the environments it was always meant
to: a warm cross-process opcode cache, a coverage driver, a PHAR, an OPcache
preload, a competing final-stripper onfile://, and a wrapper that leaves
source alone. Two of them are new facts rather than new tests — a warm
opcode cache does not reseal a bypassed class, whether or not the file lands
in the cache at all — which differs by platform; and a wrapper that does not rewrite PHP source composes and is accepted,
which is the boundary the narrow refusal was always drawing. The suite also
runs once more under an authoritative Composer classmap, which resolves a
class to a path out of a static map and includes it without stat'ing first —
a different route to the same read, and one the wrapper has to be on too. -
for()on an abstract class no longer dies on an abstract static. A static
the target leaves unimplemented — its own, or one an interface declares and
the abstract class never fills in — has nothing for the generated subclass to
inherit, so it is declared there like any other contract member. It was being
treated as inherited instead, and PHP answered the generated class with a
fatal error naming the missing method: not an exception, so nothing could
report it. Calling it still refuses with the usualInvalidCallSpecification;
a static has no instance state to dispatch. -
wire()accepts an override for a variadic tail. It takes a list, every
element is checked against the declared type, and anything else is refused by
name before the constructor runs. Filling a tail passes the parameters before
it positionally, which is the one placewire()materializes a declared
default rather than leaving it to PHP. -
wire()checks an override against a union or intersection parameter instead
of leaving it to the constructor, and accepts anintwhere afloatis
declared — PHP widens one to the other at the call boundary even under
declare(strict_types=1), so refusing it would refuse a call the subject
would have taken. -
A class static method that an interface target also declares is checked for
the whole contract — visibility, parameter types and variance, arity, optional
and variadic parameters, by-reference on both sides — and not only for its
return type. The generated class inherits that static, and PHP rejects the
class outright when the inherited declaration does not satisfy the interface;
a fatal error is not something a test can catch. -
A default factory that answers with a double whose context was torn down now
says so, naming the contract, instead of handing the double on to fail later
on its first method call. -
The mutation gate goes back to 92. The static-contract check is branch-dense
reflection glue, and covering it honestly raised the denominator faster than
the tests raised the numerator: the run measures 93.1% locally, three mutants
above a 93 gate, on a mutant count that differs from CI's by dozens. The
reason is written intoinfection.json5next to the number. -
Reading a file through a
Bypass\FileWrappernobody installed no longer
installs it.withoutWrapper()restored and re-registered unconditionally, so
one such read made the wrapperfile://'s owner for the rest of the process
— every read after it transformed by something the process never asked for.
The guard existed and was lost when rector turned the method non-static. -
url_stat()is quiet unconditionally, likestream_open(): afalseis the
answer, and a warning raised inside a wrapper is one the caller cannot
suppress. -
The mutation gate rises from 92 to 93, the first move upward, with what the
pass found written intoinfection.json5— including why 95 is no longer the
right target for an engine that now contains a tokenizer and a stream
wrapper. -
Understudy::bypassFinals()liftsfinaloff a class so it can be doubled —
one class by name, or every class the process loads from then on. It is opt-in
because the technique has limits worth meeting knowingly: it works only for a
class not yet read from disk, it changes what reflection reports for the rest
of the process, and it cannot reach a PHAR or a preloaded class.finalon
methods is never lifted, so a class carrying one is still refused. -
A
finaltarget now says what to do about it, in order of preference:
double the interface, build a real value object, enable bypass before the
class loads, or introduce an interface. -
bypassFinals()refuses rather than pretending: a class already loaded, a
name that turns out to be an enum or an interface, and afile://that
something else is already transforming. -
A Windows job joins CI. The bypass path stands in front of
file://, and
paths, separators and symlink privileges are where the platforms disagree. -
The mutation gate moves from 94 to 92, for the reasons written into
infection.json5. Both drops so far are debt with an address, and PR #13
raises it back. -
The mutation gate moves from 95 to 94. That is debt with an address, not a
judgement about how well the engine is tested: 61 of the 102 surviving
mutants are inTargetUnifier's return-type machinery, milestone-1 code whose
survivors have grown with the denominator as later milestones added mutants.
The plan carries the row for the pass that pays it back. -
A method declared
&method()now returns a reference into a stable slot the
double's state owns, one per method, so a mutation through it survives to the
next read. It used to return a reference to a local, which the next call
replaced and through which nothing could persist. The slot is seeded by the
mode's own answer and then kept — a loose default recomputes an empty value
every time, and writing that back would undo what the caller wrote — while a
configured answer still replaces it. -
Invocation::callOriginal()delegates with the arguments the caller still
holds, so a real method can write through a by-reference parameter. It used to
pass the log's reading of them, which is a copy. -
The call log keeps both readings of a by-reference argument:
Invocation::argsis what the call received,Invocation::argsAfter()what it
became once answered. Taken only for methods that declare such a parameter, so
every other call pays nothing, and taken whether the call returned or threw. -
Understudy::defaults(Contract::class, $factory)says what a loose double
should hand back for a contract, instead of a nested double that answers
everything with a default and tells the test nothing. The nearest registration
wins, measured as distance in the type graph, so the answer does not depend on
registration order; an equal-distance tie raisesAmbiguousDefaultFactoryand
a wrong-typed result raisesInvalidDefaultValue. Registrations belong to the
context and are dropped byreset(). -
A return type that can itself be doubled now becomes one — a double of its
own, one level deep, adopted into the context that owns the outer double
rather than whichever Fiber happened to be running. It used to raise
NoDefaultValue. The depth limit is enforced: a double created this way
refuses to produce another, so a chain of implicit collaborators says so
instead of growing silently. A registered factory still answers at any depth. -
The loose-default lookup asks the registry only when something was registered.
Reaching forclass_exists()first autoloads, and an autoloader round trip on
every unmatched call cost about half the dispatch time — the comparative
benchmark caught it against the recorded baseline; no test would have. -
Understudy::wire(Sut::class)builds a real subject with an understudy for
every constructor dependency and hands back both. It reads the constructor and
nothing else. Object parameters become doubles, an intersection becomes one
double of both contracts, a scalar with a default keeps it; a union of several
object types, a scalar without a default, a by-reference parameter, an
inaccessible constructor and a non-concrete subject are each refused by name,
before the constructor runs. A parameter that has a default is omitted from
the call so PHP applies it — reading it would run= new Foo()during wiring
— and an object that cannot be doubled falls back to its own default rather
than refusing the whole subject. -
Forwarding.
Understudy::forwarding($double, $real)delegates every call the
test did not configure to a real instance and records it like any other. The
target must satisfy every contract the double stands in for.for($real)
builds a double of that object's class and remembers the object without
turning delegation on — wrapping is not delegating — and refuses a final
class, whose concrete type a double cannot keep. -
Invocation::callOriginal()delegates a single call from insideanswers(),
whatever the mode. With no target it raisesOriginalCallUnavailablerather
than reaching for the parent implementation over a constructor that never ran. -
A by-reference parameter is collected as a reference, so a forwarded method
can write back to the caller's variable — the one thing declaring&
promises. It used to be copied on the way into the dispatcher. -
Forwarding records the boundary call only: a real method calling itself is not
visible, because understudy proxies an object rather than instrumenting one.
A fluent method that returned the real instance comes back as the double, so a
chain stays doubled; astaticmethod returning a different instance of the
real class raisesOriginalReturnTypeViolation. An understudy is refused as a
forwarding target: every call would come straight back to a dispatcher, and an
unmatched one would keep coming back until the stack ran out. -
Class targets.
Understudy::for()accepts a class as its first target, with
interfaces after it: the constructor and destructor never run, public and
protected methods are dispatched, private and static ones are left to the
target, writable public properties start at an empty value of their type, and
areadonlytarget produces areadonlydouble.clonenow yields a double
of its own — same contracts, no expectations, no call log — instead of an
object the runtime had never heard of. -
A target that cannot be doubled faithfully is refused before anything is
generated, with the reason and the alternative: afinalclass, a non-private
finalinstance method, an enum, a trait, an internal or anonymous class, or
a class after the first target. -
Parameter defaults are reproduced instead of approximated. A class constant is
rendered through its declaring class, an enum case as itself, and an object
default from the parameter's own source rendering —new Foo(1)and
[new Foo(1)]alike — which also avoids running the constructors that
getDefaultValue()would have. Refusal is narrow: a source namingself,
staticorparent, which would resolve against the generated class. A
default is never silently replaced withnull, as it used to be — that made
the double answer something the contract never promised. -
Comparative benchmark harness in
perf/— understudy against Mockery,
Prophecy and PHPUnit'sMockObject, plus cold-start and retained-memory
measurements. A separate Composer project,export-ignored from the
distribution, so none of those libraries reach this package's dependencies.
Both READMEs carry the resulting table;perf/README.mdcarries the method,
including why the number of doubles built per timed iteration is part of it. -
Multi-target unification: a variadic tail declared by reference in one target
and by value in another now names both targets in the rejection instead of
reporting the same one twice. -
Initial engine: interface doubles generated from Reflection, the sentinel
recording mechanism behind the call-closure API,when()with
returns()/throws()/answers(),verify()with count bounds,
Understudy::calls(),unused(),label(), loose and strict modes,
type-safe loose defaults, recorded call outcomes, and failure messages that
mark the argument that differed. -
Expectation ledger with
expect(), cardinality, chained actions, ordered
expectations,nothingElse(),allVerified(), exact cross-double
verifySequence(), transcripts, checkpoints, and nested scopes. -
Fiber-local runtime contexts with owner-routed normal calls, context-bound
configuration and verification, scoped-double invalidation, and reset of
only the current execution context. -
Compatible multi-interface signature unification, including contravariant
parameters, covariant and synthesised intersection return types,
deterministic primary-interface parameter names, and safe handling of
mixedand static contract methods.