Guidance for AI agents working on this package. Read before changing code.
rasuvaeff/understudy is a test double library for PHP 8.3+ whose defining
trait is that a configured call is a real call: when(fn () => $repo->find(123))->returns($book). There are no method-name strings and no
service methods on the double itself.
Public API lives in Rasuvaeff\Understudy: the Understudy facade, the free
functions when()/expect()/expectSequence()/verify() in
src/functions.php, Arg, Captor, Invocation, WhenBuilder/ExpectBuilder,
FailureKind, VerificationFailure, and the exceptions under Exception\.
Outcome and Cardinality are @internal since 0.9 — no public path accepts
or returns either — like everything under Codegen\, Runtime\,
Expectation\, Defaults\ and Matcher\.
The design plan that drove milestones 0-7 shipped in full and was retired
from the monorepo on 2026-08-28; what it decided lives in this file, the
READMEs and the tests. spikes/ holds the executable feasibility fixtures
the design rests on — they are not tests of this package's API, and they must
keep passing on PHP 8.3/8.4/8.5.
- Verification is mandatory. Never claim "done" without a fresh green
composer build. "Should work" does not count. - No suppressions. No
@psalm-suppress, no baseline. Fix the root cause. - A double exposes nothing beyond its contract. Every member added to a
generated class is a name the doubled interface can no longer use. New
operations go on the
Understudyfacade or into a free function — never onto the double. This is the reason the library exists; a change that breaks it is not a trade-off, it is a different library. - Preserve the public contract. Update README + tests with any API change.
No PHP/Composer on the host — run in Docker via the composer:2 image.
docker run --rm -v "$PWD":/app -w /app composer:2 composer build
docker run --rm -v "$PWD":/app -w /app composer:2 composer cs:fix
docker run --rm -v "$PWD":/app -w /app composer:2 composer psalm
docker run --rm -v "$PWD":/app -w /app composer:2 composer test
docker run --rm -v "$PWD":/app -w /app composer:2 composer release-checkOr with Make:
make build
make cs-fix
make psalm
make test
make test-integration
make examples
make test-coverage
make mutation
make release-checkmake test-coverage and make mutation run in composer-pcov:local, built
once from composer:2 by make pcov-image because the base image carries no
coverage driver. make mutation-diff mutates only what the branch changed —
seconds instead of the full run's minute — and the full run stays the gate.
-
Module boundaries.
Codegen,Runtime,Expectation,Matcher,Defaults,BypassandWiringare@internal, and the arrows between them are part of the design:Matcherdepends on nothing but leaf formatting (Expectation\ArgumentFormatter) and the language — a matcher runs while the code under test is executing; onlyRuntimeand theUnderstudyfacade see everything else. A PR pointing a dependency the other way — a matcher reaching intoRuntime, or anything intoMatcherbeyond its interface — is restructured, not merged. -
Adding an
Arg::*matcher is four files and no engine edits. A class insrc/Matcher/implementingArgumentMatcher(matches(),describe()), a static factory onArgreturningmixed(the widening union with every parameter type is what lets it be passed where the contract declares anything), a test intests/covering both verdicts plusdescribe(), and a README/llms.txt row. The dispatcher talks to matchers only through the interface —DoubleStatenever names a concrete matcher. A matcher must not throw on a hostile argument: matching runs inside the code under test, and a matcher that breaks is a test broken by its own tooling (Arg::which()catching a throwing getter is the precedent). -
tests/Integrationis a suite of its own, andcomposer buildruns it. What lives there spawns a process per claim (BypassFinalsIntegrationTest) or runs a body inside a Fiber (FiberIntegrationTest) — neither is a unit of code, and both cost more than the other 740 tests together. The Unit suite excludes the directory by path, so a file added there is integration by placement and nothing has to be registered twice. A suite outsidebuildis a suite that rots: the Windows and bypass-acceptance jobs runcomposer test:integrationexplicitly, because the platform and driver claims they exist for now live in that suite. -
An armed protocol is judged before anything answers the call. The check sits at the top of
Runtime::answer(), ahead of the expectation loop, and it has to: a call that is a step arriving out of turn usually matches an expectation — the test stubbedcommit()so it could return something — and deciding after the loop would mean deciding afterrecordMatch()counted the call andperformAction()ran the test's ownreturns()/answers(). A refused call must not have moved the double. The second refusal, for a call on a protocol double that is neither a step nor configured, is the one place that legitimately waits for$matched, and it sits next to the strict-mode check for that reason. -
expectSequence()is the fail-fast sibling ofverifySequence(), not ofordered(). That decides its semantics rather than leaving them to taste: a cursor inherits "each step due exactly once, in order", whileordered()is orthogonal to cardinality and tolerates repeats. Totality is scoped to the doubles the protocol names, and on those a call is the step due or something the test configured — which is why a query between two steps has to be stubbed. The cheap alternative, watching only the calls the protocol names, letssave($wrongBook)through unmatched and puts the failure back in teardown, which is the thing the feature exists to escape. Arming is also a claim, or a subject with a broadcatchswallows the refusal and passes. -
A failure message is rendered inside one alias table. An object argument renders as
Book#1 {title: 'Dune'}, and the number is what distinguishes a rebuilt copy from the instance the expectation named — which only works while the expectation line and the call log share a table, and they are rendered by different code paths.ArgumentFormatter::scope()opens one; everynew VerificationFailure(...)is built inside it, nesting is a no-op, and the scope is one failure rather than the whole report, because asummaryis read on its own and numbering must not shift when an unrelated failure appears. Objects are never rendered throughspl_object_id()(ids are reused after a collection, so the same failing test would print different numbers), never throughjson_encode()(cycles and closures make it partial), and never by reading a property that is not public —get_object_vars()from outside the class runs no getter and no__get(), which is the point: rendering a message must not execute the code under test. -
Golden files for rendered reports. A failure message that renders calls — a call log, an argument marked with
*— belongs intests/fixtures/messages/<name>.txt, read throughSupport\GoldenMessage::read(). Single-line messages stay inline. There is no update flag: a wording change edits the.txtby hand, and the diff of the file IS the wording review. -
A class not named by any
#[Covers]is invisible to mutation testing, even when every test drives it.TargetUnifiercompiles every double in the suite and was claimed by one test class, so Infection never used the rest to kill its mutants. Attribution is a claim about what a test exercises, and it has to be made; coverage does not infer it. Fixing it took Mutation Code Coverage to 100% — and raised the denominator, which is why honest attribution can make the percentage fall before it rises. -
Rector can quietly undo a guard.
withoutWrapper()lost itsself::$registeredcheck whenLocallyCalledStaticMethodToNonStaticRectorturned the method non-static, and the defect shipped. After arector:fix, read the diff for what moved, not only for what compiles. -
The strip removes the
finaltoken and the one space after it, never a newline. Dropping whitespace that holds a newline would move every line below it, and a stack trace or coverage report one line off is worse than a double space. -
Nothing in
src/may write to STDERR, under any mutation. One line there makes Infection abandon the whole run. It is not enough for the code to be quiet:stream_open()reporting errors only whenSTREAM_REPORT_ERRORSwas set is the documented wrapper contract, and Infection flipped the comparison so that every call reported. The wrapper is therefore silent unconditionally — afalseis the signal, and PHP raises its own warning at the call site, where a caller's@can suppress it. -
A wrapper method must not install the wrapper as a side effect.
withoutWrapper()restores and re-registers only when the wrapper is actually registered; without that guard, a unit test callingstream_open()on a bare instance would leavefile://ours for the rest of the process. -
A warm opcode cache does not reseal a bypassed class, and that is the claim — not "the file is never cached". The narrower statement was measured on Linux (766 files cached, the bypassed target not among them) and then generalised from one platform; Windows CI reported the same file cached. What holds everywhere is the behaviour, so that is what the scenario asserts. Asserting on what an opcode cache chose to keep is asserting on its implementation.
-
Windows refuses
opcache.preloadat startup, so a scenario that needs one has no process to run in there. It is asserted from the other side — PHP's own refusal — rather than skipped: a scenario quietly not run looks exactly like one that passed. -
A scenario answers in its last line, not its whole output. With a coverage driver loaded PHP warns on stdout that JIT is disabled before any of our code runs, and a harness reading the whole stream compares that warning against the expected answer.
-
The foreign-wrapper refusal is narrow on purpose, and a fixture that simulates one must be token-aware. It asks whether the source read back is the source on disk, so it catches another
final-stripper and lets a wrapper that leaves PHP source alone compose. A test fixture stripping withstr_replace('final class ', ...)also rewrites the string literal the check looks for, and the check then reads its own marker back out of the rewrite — it passes, and the passing looks exactly like a working bypass. -
bypassFinals()can only classify a type that is already loaded. Asking the autoloader would load the very class the caller wants opened, and a class is read from disk once. An unloaded enum or interface therefore passes and is refused later byfor(), which is the moment it matters. -
Bypass claims are tested in a process each. A class loads once, so two scenarios in one process prove only whichever ran first — and coverage cannot see into a subprocess, which is why the decisions are unit tested separately from the end-to-end claims. What does not need a process is every decision around the claim:
BypassFinalsTestdrivesUnderstudy::bypassFinals()(registration, refusals, a foreignfile://wrapper) andFinalReasonTestthe four ways a final class is explained, with the wrapper installed and removed inside each test — the global is the same oneFileWrapperTesthas always toggled.understudy#148is about installing it intesto.phpfor the whole run, which is a different thing and still kills Infection's mutants. -
Assert on declarations, not on phrases.
str_contains($source, 'final class')matched the words "final classes" in a fixture's own docblock and turned three passing tests red for the wrong reason. -
The mutation gate is 92, and it does not move without a reason written next to the number. 85 -> 95 -> 94 -> 92 -> 93 -> 92 -> 90 -> 92 is the whole trajectory; the reason for the last move is beside the number in
infection.json5(the escaped-mutant hunt of #76), and the reasons for the older ones are in commit messages, which is exactly why the rule exists — nobody reads a commit message while looking at the number. This file and both READMEs quote the gate, and they went stale the moment the number moved without them: quote it or leave it toinfection.json5, never both silently.TargetUnifierstill carries most of the surviving mutants: it is reflection glue whose branches are combinations of what a signature can be, so honest#[Covers]attribution grows the denominator about as fast as new tests grow the numerator. Read the reasons before moving the number, and add one when you do — a gate whose verdict flips on which PHP built the run is measuring the environment, not the suite. For the published0.xline,minMsi: 92was the explicit release policy. The1.0release requires a reproducible CI-like matrix run on the candidate and a documented threshold decision; a single local run is evidence, not a new gate. Keep the configured threshold and its rationale together until the escaped-mutant list has enough stable headroom for a measured increase. -
The only
&in the package is in generated code, and that is deliberate. Psalm cannot follow a reference into an object property and says so by name; the way out is not a suppression but moving the reference to where Psalm never looks.Runtime::referenceSlot()hands back a plainReferenceSlotobject by value, and the generated method returns$slot->valuefrom its&signature. Zero@psalm-suppress, zero baseline, zeroissueHandlers— keep it that way. -
DoubleState::hasActionFor()must walk expectations exactly as the dispatcher does. Same accessor, same order, stop at the first match and answer with itshasAction(). Asking "does some matching expectation have an action" disagrees with dispatch precisely when a newerexpect(...)->times(1)shadows an older stub, and the by-reference slot is then replaced by a value dispatch never returned. -
A snapshot has to reach nested references.
array_map()detaches only the top level, and a by-reference parameter is often an array with a&$rowinside it. The recursion is depth-capped because$a[] = &$ais legal PHP. -
Invocationcarries the live arguments separately from the logged ones.callOriginal()delegates with the live ones — a real method is expected to be able to write through a by-reference parameter — whileargsstays the reading taken before the call. -
A by-reference argument is snapshotted on both sides, and only where it can move.
MethodSignature::$hasReferenceParametersgates it: taking two readings of every call would cost the whole suite for a rare case. -
Never autoload on the dispatch path.
class_exists($name)does, by default, and an autoloader round trip per unmatched call cost about half the dispatch time — caught bymake perfagainst the recorded baseline, not by any test.class_exists($name, autoload: false)is a hash lookup and is fine; that is what guards the registry's reflection, and it is load-bearing, because a builtin type name would otherwise reachnew ReflectionClass(). The registry itself is asked only when something was registered. -
The defaults registry lives in the context, and the depth-1 double is adopted into the owner context. A nested double handed to a call made from another Fiber must belong to whoever owns the outer double, or the test that configured it can neither configure nor verify what it got back.
-
Depth stops at one, and it is enforced, not described. A double created by a loose default is marked
nested, and asked for another one it refuses. The comment saying "depth stops here" was true of intent and false of code until review caught it: nothing stopped$a->b()->c()from inventing a third collaborator. A registered factory still answers at any depth — that is the test saying it meant this one. -
wire()evaluates a constructor parameter default in exactly one place. A parameter that has one is omitted from the call and PHP applies it — reading it would run= new Foo()during wiring. The exception is an override that fills a variadic tail: Reflection forbids positional arguments after named ones, so every parameter before the tail has to be passed positionally, and an omitted optional one is materialized throughgetDefaultValue(). Anywhere else this is the same trap as the codegen defaults in milestone 2a, different file. -
wire()is Reflection glue, so it gets a fixture matrix, not property tests. Every branch is a shape a constructor can have, and each refusal happens before the constructor runs — a half-built subject would show the test aTypeErrorfrom inside code it did not write. -
Fixtures are one class per file, or PSR-4 cannot find them. Two of the wire fixtures were written into one file and every test that did not touch the other class failed with "there is no such class"; the same mistake reached review once already with
Fixture\Cls\Stamp. -
Inline forwarding enters a real body by reflection, not by
parent::.Runtime::runInline()callsReflectionMethod::invokeArgs()on a reflection of the doubled class, which enters that class's body even though the double overrides the method, while$thisstays the double so the body's self-calls dispatch virtually and come back through the override. The alternative — a generated__understudyReal_*()sibling callingparent::, which is what jasonmccreary/double does — is closed to us by golden rule 3, and a per-callfn () => parent::m(...)closure handed to the dispatcher was built, measured and reverted: it cost +10% per call on every class double, forwarding or not, because the argument is evaluated before dispatch can decide it is not needed (perf/doubles only interfaces, somake perfcannot see this — the measurement was a loop over a loose class double). The language claim is pinned byspikes/08-inline-forwardingon 8.3/8.4/8.5. The reflections are cached in a static keyed byClass::method, andreset()does not clear it: it is metadata about a type, not state of a double. -
Inline forwarding needs the instance to be exactly the doubled class, and the state copy decides when it is on.
get_parent_class($double) === $real::classis the whole test: an interface double has no parent, and a donor that is a subclass carries overrides the doubled class's bodies do not contain — running those instead would silently change which code executes, so it keeps the proxy form.DoubleState::$inlineParentis set only afterRuntime::copyState()succeeded, because a body that runs inline reads$this->properties the skipped constructor never filled. Re-pointing an inline double at another instance is refused rather than copied twice: areadonlyproperty cannot be written again, and a second copy would discard what the real code has written since. -
A state copy reflects each property against its DECLARING class. Before PHP 8.4 a
readonlyproperty can only be initialized from the scope of the class that declares it, and aReflectionPropertyreached through the subclass writes from the subclass's scope. The loop therefore walks the donor's hierarchy and skips what the current class did not declare. A hooked property is copied throughsetRawValue()/getRawValue()(reading through the hooks would execute the donor's code during setup, writing through them would re-run asetthe real object already ran), a virtual one has no state to copy, and a property the donor left uninitialized isunset()on the double — which is enough from outside, because the only thing that writes a property on a constructor-less double isPropertyDefaults, and it writes public writable ones and nothing else. -
A clone made while a real body runs inherits that double's forwarding; an object no context knows is refused. Both shapes satisfy the override's
: static— they are of the generated class — so the language stops refusing what the proxy path refused as a foreign instance, and the two need opposite answers.clone $thisis adopted inRuntime::adoptClone()fromRuntime::$inlineFrames, the stack of doubles whose bodies are running: PHP already copied the state those bodies read, and without the frame a: selfwither — or a body merely computing over an internal copy, which proxied worked — would hold a double answering defaults in the middle of real code. Expectations are still not copied, and aclone $doublethe test makes is untouched — whether no body is running at all, or the test's ownanswers()closure was reached from one, whichRuntime::$inlineFloorhides the frames underneath for exactly the span of. The frame alone is a guess, because one generated class serves every double of a type: a body handed another double of its own class —merge(Basket $other)— and cloning THAT would adopt it on the running double's behalf, with bodies to run and no state copy to run them over, which is a baremust not be accessed before initializationwhere the property is typed and a quiet wrong answer where it has a declared default.Runtime::isCopyOf()therefore requires the copy to hold the frame's own state, and the comparison isget_mangled_object_vars()on both — the object's own property table, which is the only reading that reaches a parent's private properties, answers a hooked property with its backing store rather than running the doubled class'sgetin the middle of__clone(), has no entry for a virtual one, and omits a property nobody initialized, so "never set" differs from every value. The copy is property-identical to its source at this moment, because a generated__clone()does nothing but calladoptClone(). Two doubles in the same state stay indistinguishable — the copy is adopted and runs real bodies where a plain double answered defaults — and that is the residual of PHP's blind spot, not a thing the comparison can close: it runs them over the state the copy already holds, so no reading is of a value the object never had, it is the other double's.new staticcannot be adopted — its constructor built a new object with its own state — so it is refused byOriginalReturnTypeViolation::foreignSelf(), and the check is registration (ownerOf() === null), not return type: an unknown instance is broken for every caller, not only for one whose signature saysstatic. -
Forwarding is not gated on
$matched, and strictness is. Strict mode is a complaint that a matched expectation answers; forwarding is the mode's own answer, and an expectation that only counts the call —expect(...)->times(1)with no action — still has to get one. Gating it would make counting a call change what it returns. -
Forwarding comes before the
neverfallback. A: nevermethod on a forwarding double has a real implementation to reach, and that implementation is where the throw lives. -
A by-reference parameter is collected as
[&$slot], not[$slot]. The copy would make&a promise the double cannot keep once the call is forwarded. Verified on 8.3 and 8.5: a reference survives both the array literal and the...spread; a copy does not. -
for($real)remembers,forwarding()decides. A double that started running real code the moment it was built would be a surprise rather than a shorthand, andcallOriginal()needs the target either way. -
A fixture's shape is the test input, so
tests/Fixtureis skipped by rector. Rector once rewroteFixture\Cls\Ledgerinto a promoted constructor property, which silently turned "this property has a declared default" into "this property has none" and made the property-initialization test assert the opposite of what it was written for. Improving a fixture is changing the contract under test. -
A generated
__destruct()must not declare a return type. PHP rejects one with a fatal, not an exception, so it is rendered by hand rather than through the signature path. Every other magic method — including__get,__set,__call,__invokeand__toString— accepts the widenedOriginalType|ArgumentMatcherparameter union on 8.3, 8.4 and 8.5. -
Never call
getDefaultValue()to find out what a default is at generation time. On= new Foo()it runs the constructor. Dispatch is the exception, and the only one:MethodSignature::defaultAt()calls it for an argument the caller omitted, which is the moment PHP itself would have evaluated the initializer — and per call, so= new Foo()still builds one object per call rather than sharing one. The default's source expression comes fromReflectionParameter::__toString(), which renders it fully qualified without reading the declaring file — and is the only way to see anewdefault without evaluating it. Two things it does not qualify:self/static/parent, which would resolve against the generated class, and a global constant, which Reflection reports prefixed with the declaring namespace and therefore under a name that does not exist. -
A default that cannot be reproduced exactly refuses the target. Answering with a different default than the contract's is a test that passes for the wrong reason.
-
An object default is not always at the front of the expression.
[new Foo()]and['k' => new Foo()]are legal initializers, and evaluating either runs a constructor. Detection scans the whole source with quoted runs blanked out, never just the first token. -
finalon a property is notreadonly. It stops a subclass from redeclaring the property; the outside can still write it, so the initializer fills it like any other. -
A clone belongs to the context that cloned it.
__clone()runs on the copy and PHP hands it no reference to the original, so the original's owner cannot be recovered — this is a language limit, not a shortcut. Cloning inside a Fiber gives that Fiber the copy. -
8.4-only syntax belongs in
eval, never in a fixture file. Property hooks,finalproperties and asymmetric visibility are a parse error on 8.3 — a file carrying them takes the whole suite down there rather than skipping a test. -
The per-double loop in
Runtime::retire()is load-bearing, and it looks like the opposite. Teardown runs after every test and walks every double the context held, stamping it forgotten and dropping its owner — an obvious thing to replace with a flag on the context thatownerOf()reads. That was built, and it made teardown cheaper in an isolated loop and double creation 13-23% slower in the benchmark: the owner entries then live until collection, and the map they sit in grows across the whole suite. The work was not removed, it was moved somewhere it costs more. Reverted, measured back to parity. Before touching it again, read the next bullet. -
An isolated micro-benchmark cannot see a cost you moved. It times an operation; the regression above was in the system around it, and only showed up in a run that had already built ten thousand doubles. The full harness is the one that sees it — and it reads 0.415 normalised where three filtered runs read 0.307 if the machine is busy. What is trustworthy is neither on its own: an A/B of the full harness on a quiet machine, three runs per side, with the competitors' movement in the same runs used as the noise floor. If Mockery and PHPUnit moved as much as we did, nothing was measured.
-
A perf figure that cannot be reproduced from the repository is not a figure. The recorded environment said "pinned to six cores"; the
make perftarget did no pinning, so the numbers depended on flags in someone's shell history and could not be re-taken. The pinning lives in thePERFvariable now. Before replacing any figure, re-run the harness at the commit the old one was taken at: doing that showed the environment was comparable and the movement was ours, which is the difference between attributing a regression and shrugging at it. -
perf/is a separate Composer project and must stay one. Mockery, Prophecy and PHPUnit belong to the comparative benchmark harness, never to this package'srequire-dev— they would slow everycomposer buildand mutation run for no gain, and the rootAGENTS.mdallows extra dev dependencies only for integration tests. It isexport-ignored, installed through a path repository, and run withmake perf/perf-cold/perf-memory. Methodology and its traps live inperf/README.md; the rules there (teardown and verification inside the measured unit, no unbounded call history, scenarios defined by behaviour) are what make the numbers mean anything. Never edit a recorded figure without rerunning the harness that produced it. -
State is keyed by the double object, never by
spl_object_id(). PHP reuses an object id after collection, so an id-keyed store would hand a fresh double the previous one's state.RuntimeusesWeakMaps andRuntimeContextusesSplObjectStorage. -
Recording is a depth counter, not a boolean. A nested recording phase must not switch the enclosing one off when it unwinds.
-
An abstract static has nothing to inherit, so the double declares it. Both shapes reach this: a class target's own
abstract public static, and an interface static an abstract class implementing that interface never fills in — Reflection reports the latter with the interface as its declaring class. Treating either as inherited leaves the generated concrete subclass without an implementation, and PHP answers that with a fatal error naming the method, not an exception.isOverridable()is what routes them; only an implemented static on a class target belongs in$classStatics. -
Multi-target methods are unified, not compared. Parameters are contravariant, so
write(int)andwrite(string)share the implementationwrite(int|string); refusing them on signature difference would reject a doublable pair. Only genuinely unsatisfiable combinations are conflicts: divergent return types (covariant) and by-reference mismatches. -
Generated methods collect arguments by name, never
func_get_args(), which omits parameters left at their default —tag('alpha')andtag('alpha', 1)must record as the same call.func_num_args()cannot stand in for the sentinel either: with named arguments PHP reports the filled count, so a specification that skipped a middle parameter would be invisible. -
Every generated parameter defaults to the arity sentinel, optional ones included. That is what lets a specification leave a parameter unspelled and mean it: a materialized default in its place is indistinguishable from spelling the default value, which made arity an implicit part of every specification (#123). Dispatch puts the contract's default back for a real call, so
tag('alpha')andtag('alpha', 1)still log as one call. Two consequences to hold on to: a double's generated signature no longer advertises the contract's defaults, andnulljoins the type only where no target declares a default — a position optional because another target does not declare it at all. Widening on the rendered default is what mademixed $v = nullrender asmixed|null, an uncatchable fatal out ofeval(). -
Mutation numbers lie unless every class has its own
#[Covers]test. Infection's mutant-to-test mapping is#[Covers]-driven: with one test class covering one class, the run reported 56 mutants at 93% MSI while the honest figures were 406 and 68%. Check the mutant count, not just the MSI.composer mutationruns with--with-uncoveredsince 2026-11-03, and three more things decide the figure there:- A test that builds its doubles in a data provider executes nothing under
coverage: the provider runs at collection time, so the line is Not Covered
however many tests list it. Build inside the test body (the message table
in
tests/Exception/ExceptionMessageTest.phpyields closures for that reason). - A message built as
sprintf('a' . 'b', ...)or athrowover several lines puts its operand mutants on a line with no opcode, so they are Not Covered. Assign the literal to a local first ($format = 'a' . 'b';) and pass the variable; the text is byte-identical. Rector'sSimplifyUselessVariableundoes this for a plainreturn, which is fine: there the first operand shares the statement's line already. - Multi-line parameter lists put
int $x = 0,on a line the template's ignore pattern matches; a one-line signature does not match and keeps its mutants. Known equivalents, so nobody hunts them again:DoubleStateliteral-key index (serialize(0.0)constants,array_reverse, the index itself is an optimisation of a walk that still runs),FinalStripper's two fast paths and the loop bounds of its token scans (an out-of-range$tokens[$i]isnulland falls through),Wire's$resolved[0]/[1](both are the same double),Runtime's?->on a state that is never null at that point and itsinlineMethodscache keys,ArgumentFormatter'snullandNANmatch arms (the default arm renders both identically on PHP 8.4).
- A test that builds its doubles in a data provider executes nothing under
coverage: the provider runs at collection time, so the line is Not Covered
however many tests list it. Build inside the test body (the message table
in
-
Rector and Psalm pull in opposite directions here.
RemoveUselessVarTagandFlipTypeControlToUseExclusiveTypeare skipped inrector.phpwith the reasons written down; do not add skips beyond those without the same. -
Every gate here reads the working tree; a consumer downloads
git archive. Between the two there isexport-ignoreand nothing else, which is how 116K of feasibility spikes shipped in 0.1.0.bin/consumer-smokeis the check: it installs a dist archive of HEAD into a throwaway project and uses the package from there, proves the install came from that archive rather than from Packagist, and refuses a dev directory or a tooling config inside it. CI runs the core leg on every PR that touches the distributed tree. The whole family from local checkouts at once — the run to make before a release — isbin/understudy-consumer-smokein the workspace repository. -
The analyser parity matrix lives in
bin/consumer-smoke, and nowhere else.understudy-psalmandunderstudy-phpstananswer the same public contract by opposite mechanisms — one suppresses an issue after it is raised, the other types the matcher asneverso none is — and only a shared set of inputs can say whether they agree. Put the fixtures in each analyser package and there are two copies that drift, neither wrong on its own; put them here and both packages already run them, because their own CI invokescore/bin/consumer-smoke --only=psalm|phpstanagainst their working tree. A new idiom the plugins have an opinion about is one fixture file plus one word inPARITY_SILENTorPARITY_REPORTED, and a disagreement is a failing smoke rather than a difference nobody compared. This CI does not run those legs — the analyser packages' do. -
A composition the adapters cannot defend against is pinned by the smoke, not by a warning. A PHPUnit consumer that writes its own
assertPostConditions()silently wins over the trait's, and neither PHPUnit nor the adapter can notice; the#[Before]guard does not catch it either, because#[After]still resets. The phpunit leg runs both a bare override (verification lost, test green) and the aliased recipe from the README (verification kept). A documented remedy that nothing executes is a remedy that rots. -
Code:
declare(strict_types=1),final readonly classwhere nothing mutates,#[\Override], explicit types, named arguments. -
examples/is a gate, not a showcase. Every script checks itself throughexamples/_check.php(which throws —assert()is compiled out underzend.assertions=-1), andcomposer buildruns all of them through@examples. A script that only prints proves the file parses. Two more rules the mechanism imposes: a new script must be added toexamples/README.mdwith a bare filename in the Script column —bin/package-auditstrips spaces and backticks but not brackets, so a markdown link makes the row match no file and the script silently drops to lint-only — and it must be added to theexamplesscript incomposer.json, orbuildwill not run it. A helper is prefixed with_, which is what marks it an include. -
CI workflows are SHA-pinned. Every
uses:in.github/workflows/*.ymlreferences a 40-char commit SHA with a# vNtrailing comment. Never revert to floating@vNtags. Updates go through Dependabot. Workflows carrypermissions: { contents: read }andpersist-credentials: falseon every checkout. Verify withzizmor --persona=auditor .github/.
docs/ holds the family site — one VitePress site for all five packages,
deployed to https://rasuvaeff.github.io/understudy/ from master. The plan
it was built from was retired from the monorepo once the site shipped; what it
decided lives in this section and in docs/scripts/check-integrity.mjs.
| Rule | Why |
|---|---|
The sidebar in docs/src/.vitepress/config.ts is the page set. A page not reachable from it is an error, not a page nobody linked yet |
integrity check 6 |
docs/src/api/** is generated. Fix a docblock and run make docs-api; never edit a page there |
it is overwritten on the next run |
| The completeness budget only goes down. Document the new member instead of raising it | integrity check 8 |
A new @api free function needs make docs-api too, and a row in llms.txt |
checks 2b and 7 — the class-only pipeline used to drop functions silently |
A new PHPStan rule needs its row on /api/rules in the same PR |
check 9; the analyser packages have no other public contract |
MIGRATION.md is generated from docs/src/guide/migrating-*.md. Edit the pages, run make docs-migration |
check via docs:check:migration |
docs/.api-workspace/composer.lock is committed and moves with every satellite release. A caret on 0.x pins a minor; on 1.x it follows the stable major, but the lock still has to move after every satellite release. Bump the constraints, composer update in the workspace, make docs-api, commit all three |
docs.yml runs composer outdated --direct --strict there and goes red otherwise |
make perf means re-reading three files: perf/README.md, README.md's Performance table and README.ru.md's. Keep the <!-- #region site --> markers and the Taken YYYY-MM-DD line |
checks 10 and 11 |
An @include that cannot resolve fails silently in VitePress — the page renders as its heading and nothing else |
check 12 |
{{ in prose needs <code v-pre> |
Vue interpolates it otherwise |
A stub page cannot be built. Remove its <!-- DRAFT comment when you write it |
check-drafts.mjs, first in docs:build |
Prose is linted. make docs-vale must report zero findings; reword rather than add an exclusion |
.vale.ini |
A load-bearing claim in the guide gets an assertion. docs/scripts/check-claims.php names the page beside each one |
make docs-claims; the guide is the only part of the site nothing else holds — the reference is reflected and the cookbook is diffed |
Commands: make docs-install, make docs-api, make docs-dev,
make docs-build, make docs-claims, make docs-cookbook, make docs-migration,
make docs-links, make docs-vale. Only docs-api, docs-claims and docs-cookbook
need PHP; the rest are Node, so the site stays buildable without a PHP
toolchain.
Since the 2026-09-18 review wave: the null-safe reads in the recording
branch of Runtime::dispatch() and in Understudy::record() (a generated
class always has a blueprint, and the signal's double is registered unless
the specification names a forgotten one — a path the message tolerates with
understudy), the nested: true flag of the recording's default (the
throwaway context observes nothing either way), the value written into the
droppedByScope map (only membership is read), the Fiber branch of
popScope() passing byScope (no Fiber scope test exists — add one if the
branch grows), and the trailing return true of the intersection loop in
ReturnContract::acceptsType() (falling through lands on the match default,
which accepts a name class_exists() does not know).
- If the change touches the public API, run
make docs-apiand commit the regenerateddocs/src/api/**; if it touches the site's prose, runmake docs-buildandmake docs-vale. - Update
README.mdandREADME.ru.md(both languages, same commit; andexamples/if usage changed); updateCHANGELOG.mdwhen releasing. - Re-run
composer build; if the change affects public API or release safety, also runmake release-check. Paste the output. - Before a release tag, re-measure
perf/on the commit the current numbers were taken from, then on the candidate.perf/README.mdstates figures — cost per call, cost per double, memory per double — and nothing in CI defends them: a regression is found only by somebody deciding to measure. Comparing against a number in the file is not enough either, because the environment moves; that is whyPERFin theMakefilepins it, and why the old commit is measured again rather than trusted. This is how the 350 -> 435 bytes per double was attributed to PR #9-#14 and #16 rather than to the machine.