Releases: rasuvaeff/understudy
Release list
v0.11.0
- Fixed. An interface extending
Throwable,DateTimeInterface,
UnitEnum/BackedEnumorTraversable(withoutIterator/
IteratorAggregate), and an interface declaring__construct, are refused
withUnsupportedTargetbefore generation. The guard compared the
contract's own name, soPsr\Http\Client\ClientExceptionInterfaceand
its kin walked past it intoeval()and died there as a fatal error no
test, adapter ortrycan catch — ending the whole suite run. The
Throwableadvice no longer points at doubling an exception class, which
is refused too; construct a real exception forthrows(). (#133) - Fixed. A specification closure that calls one double method while
evaluating the arguments of another —when(fn () => $r->find($r->count()))
— is refused withInvalidCallSpecificationnaming 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'sreturns()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: nevermethod, 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: voidmethod (returns(null)stays allowed as
the idiom for "answer nothing"), anything on a: nevermethod, and a value
the type cannot hold —nullwhere the type is not nullable, an array or an
object where a scalar is declared, an object of the wrong class — raise
InvalidCallSpecification/InvalidSpecificationArgumentnaming the double,
instead of aTypeErrornaming the generated class from inside the code
under test. No stricter than the engine: a generated method is not under
strict_types, soreturns('5')on: intstill answers5.
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 explicitlabel()still
wins. (#137) - Changed.
ForgottenDoubleafter ascope()closed says so, instead of
blaming areset()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, sostrictStubsstays 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; areadonly
promoted property of a class target stays uninitialized where a plain typed
one starts empty;MatcherLeakedis 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
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 saidnever calledbeside 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 anany()the test did write. Found migrating
yii3-outboxandyii3-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, sotag('alpha')andtag('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 = nullwas a fatal error. The nullability widening applied to
mixedtoo, andmixed|nullis a type PHP refuses at compile time: an
uncatchable fatal out ofeval(), 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()orcontaining()
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, andArg::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, socount()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()andUnderstudy::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 storenulland fail three steps away from the cause (#126). -
WhenBuilder::throwsWith()builds the exception from the call it
answers, one per call — the shapethrows()cannot express, and which
everyone re-derived as a throwinganswers()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 answeringnull(#127). -
The API reference documents the satellites at their current versions.
docs/.api-workspace/composer.jsonpinnedunderstudy-psalm ^0.2,
understudy-phpstan ^0.2,understudy-phpunit ^0.1andunderstudy-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, anddocs.ymlrunscomposer outdated --direct --strictin the workspace: a satellite release the pins do not
reach is a red build now, not a stale page. -
static-analysis.ymlrunscomposer rectorin the Psalm job. It is part of
composer release-checkand 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.phptook every string literal starting with
understudy.for a rule identifier, soClosureShape's php-parser attribute
understudy.receiverOfCallsat on the rules page as a sixth identifier nobody
could write intoignoreErrors(only constants named*IDENTIFIERcount
now); andgenerate-api.mjshad no label for a trait, so the PHPUnit
adapter's page was headed «undefined».
v0.9.0
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()answersForgottenDoublefor a double that is gone. Called a
second time on the same double, or on one areset()already dropped, it
said «Understudy::forget() expects an understudy created by Understudy::for().
This object is not one» — anInvalidCallSpecificationabout an object that
was one. It now goes through the same door as every other facade, so the
answer isForgottenDouble(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.
InvalidCallSpecificationis about the SHAPE of a specification — what is
called, where, how many times — andInvalidSpecificationArgumentabout a
VALUE inside it that no run could act on. An invertedArg::int/float/count()
range and anArg::string()pattern PCRE cannot compile are values, so
invertedBounds()andinvalidPattern()move toInvalidSpecificationArgument
besideArg::instanceOf()'s unloadable type,times(5, 2)andreturns().
Acatch (InvalidCallSpecification)around those two paths has to become
catch (InvalidSpecificationArgument)— orcatch (UnderstudyError), which
both implement. Both classes say the rule in their docblocks. OutcomeandCardinalityare@internal. No public path accepted or
returned either: dispatch records outcomes throughInvocation's scalar
recorders andInvocation::$outcomestayednullfor every call the engine
made, whiletimes()andverify()take integers and aVerificationFailure
carries bounds as integers. Both were@apiby inheritance from the first
design.Invocation::recordOutcome()andInvocation::__construct()are
@internaltoo — the dispatcher's bookkeeping, not a way to build a call by
hand.WhenBuilderis closed by contract. Its docblock now says that
subclassing is not supported and that theprotectedexpectation it carries
is an@internaltype; the keyword stays off only becauseExpectBuilderis
the subclass, and Psalm refuses a@finaltag for the same reason.- How
FailureKindgrows is written down. The readonly fields of
VerificationFailureand every existing case are stable; a NEW case may
arrive in a minor, so amatchover the enum needs adefaultarm. 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, andreturns()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 inunderstudy-phpstan0.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
nullbefore the registry is consulted (the opposite has been true since
0.1.0), counted three free functions, listed noallOf/anyOf/rest/
captorand mentioned neitherexpectSequence()norlean(). - 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, whatcheckpoint()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 mappedshouldNotHaveReceived()tounused(), which asserts
no call at all; the README still told readers to expect a «too few
arguments» report onArg::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.iniisexport-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
Understudy::for()no longer kills the process on five built-in
interfaces.Throwable,UnitEnum,BackedEnum,DateTimeInterfaceand
Traversablewalked past every refusal in the factory and were answered by
the compiler instead — a fatal out ofeval(), uncatchable bytryor by an
adapter, and fatal to the whole suite run rather than to one test. Each is now
anUnsupportedTargetnaming the way through.Iterator,
IteratorAggregate,StringableandCountablekeep 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, becauseimplements A, Adoes not
compile. #[\SensitiveParameter]is honoured. The value of such a parameter is
rendered as its type —login('user', string SensitiveParameter)— in
failure messages and intranscript(), 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 andreturns()with no values threw a bare
\InvalidArgumentException, whileUnderstudyErrordeclares itself
implemented by every exception this library throws — so acatch (UnderstudyError $e), whichllms.txtrecommends, walked past them. The new
InvalidSpecificationArgumentextends\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.NANno longer raises a PHP warning while a failure message is rendered.
On PHP 8.5(string) NANwarns, from inside the library, during the render of
a report about a failure — which underfailOnWarningturns 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. CannotWireandInvalidDefaultValuesay "has typearray" and "produced a
value of typearray" instead of "is aarray".- 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 inexamples/README.mdand a dead plan
reference indocs.ymlare gone, and the committed API pages are regenerated
from the currentsrc/(they were built from a v0.5.0 snapshot).
v0.7.2
- 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: staticloose-default rule.
The Prophecy migration table no longer teaches a non-existent
expect(…)->never()— the supported form isexpect(…)->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's5.38³footnote marker was a damaged
character.examples/README.mdnow documentscase-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
bypassFinals()missed a target whose declaration is written in another
case.final class gateinnamespace AppISApp\Gateto PHP —
class_exists()says so, andbypassFinals(\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 untilfor()refused it. Both halves are
compared the way PHP compares them now. Fixes #97.
v0.7.0
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::$fileand$lineare gone. They were promoted readonly
properties on an@apiclass that the dispatcher never filled in and
nothing ever read — public API whose only value wasnull. Filling them
would mean adebug_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.
neverused 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 throughCardinality, which is what the fluenttimes()
has always used, so a negative count and a maximum below the minimum are
caught where they are written.times: -1used 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" — andstrict()has no
closure, so the reader was sent looking for a mistake they had not made.
strict,lean,forwarding,label,unused,nothingElse,
allVerifiedandtranscripteach 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
ForgottenDoubleanswer a CALL on that object already got. -
A loose double answers
: staticwith itself. The receiver IS the
double and the generated method really does declarestatic, 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: selfis 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: parentand 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\Modeis@internal, like the rest ofRuntime\. It never
appears in a public signature —strict(),lean()andforwarding()are
what a user writes — so the@apion it promised a contract nobody could
reach and no document described. -
Two orphaned docblocks removed, and
WhenBuildersays why it is the one
class here that is notfinal.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 wordfinal", and
finallycontains 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. -
QueryEqualskeeps building itsReflectionMethodper 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
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 becomingnull.Understudy::for(A::class, B::class)where both declaretag(string $name, int $weight = 5)and
= 7rendered= nulland widened the type toint|nullto make that
legal — so an omitted argument was logged as a value neither contract
declares, andverify(fn () => $double->tag('alpha', 5))did not match the
callAwould have made. That is the failurerenderDefault()'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.0was invisible to a call made with0.0. The
dispatch index keyed a literal first argument byserialize(), which is not
isomorphic to===:-0.0 === 0.0is true andd:-0;is notd: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. DispatchIndexPropertyTestgains a second property for identity, over
literals of every scalar type — the existing one drawsintonly, so no
float, let alone a signed zero, was ever reached. The pairs a key can get
wrong in either direction are namedExamples()rather than left to the
random phase: both signed zeros,0against0.0and'0',''against
nullandfalse,1againsttrue, andNAN, 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-psalmdecided 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.mdrecords 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
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-smokenow carries the analyser parity matrix: eleven
idioms — ourArgunqualified, aliased and fully qualified, the static verb
form,rest()short and leaked, a captor capture in a specification and in a
real call, a namesakeArgunder two spellings, and a plain leak — checked
against one table of expectations by bothunderstudy-psalmand
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
overridesassertPostConditions()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 ofscope()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 inArg::float()and
Arg::count()— describes an empty range, andArg::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 raiseInvalidCallSpecification, 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 — unlikeArg::which(), which reaches
into the argument. AndverifySequence()with no calls asserts that nothing
happened, whereexpectSequence()refuses an empty protocol; both are now
stated in the docblocks and pinned by tests. - The mutation gate quoted in
README.md,README.ru.mdandAGENTS.mdsays
92, which is whatinfection.json5has 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 readsFailureKindand the readonly fields of
VerificationFailure, which are frozen.
v0.4.1
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 throughFailureReport, while the verification sweep
had asprintfof its own. SinceverifyAll()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)generatedcount(): mixed
and PHP answered withshould 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 forArrayAccessandJsonSerializable, whose
tentative type ismixedanyway. (#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.mdat 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 istimes(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;FinalStripperpins the glued-comment and bare
final constboundaries; 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 ofunused(),observedCallsfiltering,
expectedCallsas strings, both halves ofallVerified()) 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 wholeArg::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()andlean()inmodes.php, and a new
property-hooks.php(self-skipping on PHP 8.3). The Mockery migration
table gains themakePartial()/withAnyArgs()/Mockery::capture()rows,
and three edge behaviours are now stated: acapture()in an
expectSequence()step matches without recording,lean()cannot release
the stable slot behind a&return, and aclonedoes not carry written
property values over.