v0.4.0
Every open issue of the backlog in one release. All additive — nothing
removed, nothing renamed; a minor because Arg::rest(), Arg::captor(),
Understudy::delegate(), Understudy::lean() and rendered property hooks
are new public API, and on 0.x that is the boundary Composer's caret already
treats as breaking. The perf ritual ran before the tag: three full-harness
runs a side against the commit the published figures were taken at — the
per-call marginal cost is unchanged (0.90µs), double creation moved +1.4-2.4%
while the competitors moved ±3-4% in the same runs, which is inside the noise
floor.
-
Interface-declared property hooks are rendered, so a modern contract can
be doubled at all (#36).public string $name { get; }
on an interface — or anabstracthooked property on a class — no longer
refuses the target: the generated class declares the property with the
dispatcher inside the hook, which no__get-based library can do (__get
fires only for an inaccessible property). Reads answer the forwarding
target's value, else what the code under test wrote (a{ get; set; }
property behaves like a plain one), else the mode's type-safe default —
Understudy::defaults()registrations and the depth-1 nested double
included. Exactly the declared hooks are rendered: a get-only property
refuses a write with PHP's own error. A property read is not a call — not
recorded, not specifiable, not judged by strict mode; stubbing and verifying
reads is future work by design. Still refused, each with its reason: a
readonly class target carrying an abstract hook (a hooked property cannot be
readonly, and a readonly class only extends readonly), and a by-reference
&gethook. Concrete hooks on class targets stay inherited, and on PHP 8.3
nothing changes — the language cannot express a hooked property there. -
Understudy::lean(): a call log that does not retain returned values
(#63). The transcript keeps every invocation with its
outcome untilreset()— which the runner adapters call after the test's
own teardown, so a value a double returned is still referenced while
teardown runs. Real incident: a forwarding double returned file streams and
FileHelper::removeDirectory()failed with "Directory not empty" — on
Windows only, since POSIX unlinks open files. A lean double keeps the
invocation (method, arguments, sequence), so matching,verify(),
transcript()andnothingElse()work unchanged, but not the value:
Invocation::returned()raisesOutcomeUnavailablethe way it already does
for a call that threw. Also caps per-call memory growth in hot loops. The
retention interaction — andUnderstudy::scope()as the other remedy — is
now documented in both READMEs and llms.txt. -
The pre-0.1.0 double-creation regression is now a stated decision, not an
open question (#56).perf/README.mdrecords that the
~0.3µs per double bisected to #23 bought bounded registration/reset/verify
accounting and closed the Fiber hole where an unmetexpect()in a Fiber
passed silently — a false pass, the one failure mode a verification library
must not have. The trade is accepted; reopening it requires the full-harness
A/B the file describes. No code change. -
Arg::captor(): a typed argument captor (#62). The
typed replacement for readingargs[N]out of the call log:
$options = Arg::captor(DeliveryOptions::class),$options->capture()in
the specification where the argument goes, then$options->last()/
$options->all()— typed through the class-string generic, so no
instanceofnarrowing ritual at the read site. The typed form matches like
Arg::instanceOf(), the untyped likeArg::any(); the value is recorded
only once the whole specification matched, so a call the other arguments
rejected captures nothing. Works inwhen(),expect()andverify().
last()on an empty captor raises the newNothingCaptured; captured
values are dropped with the context, like the call log. -
Understudy::delegate(Contract::class, $real): a forwarding double in one
expression (#61). Builds the double, turns forwarding
on and returns it — thefor()+forwarding()pair that suites leaning on
forwarding repeated at every site. The target is validated the way
forwarding()validates it; both existingforwarding()forms stay. -
Arg::rest(): "the arguments before me matter, the rest of the arity does
not" (#60). The one matcher that lets a specification
stop before the method's required parameters run out —
when(fn () => $storage->recordOutcome('svc', Arg::rest()))instead of an
Arg::any()per remaining parameter. To make the shortened call physically
possible, every required parameter of a generated method now defaults to an
internal sentinel; a real call that omits a required argument still fails
withArgumentCountError, raised by the dispatcher instead of the engine,
so a double is no more permissive about arity than the contract. A
specification that stops early without ending inArg::rest()is refused
with the reason — includingArg::remaining()/Arg::none()in that
position, which describe a variadic tail, not omitted parameters. Layering
is untouched: a later, narrower specification still wins over the broad
prefix stub.