Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,13 @@ seconds instead of the full run's minute — and the full run stays the gate.
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:
`BypassFinalsTest` drives `Understudy::bypassFinals()` (registration,
refusals, a foreign `file://` wrapper) and `FinalReasonTest` the four ways a
final class is explained, with the wrapper installed and removed inside each
test — the global is the same one `FileWrapperTest` has always toggled.
`understudy#148` is about installing it in `testo.php` for 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.
Expand Down Expand Up @@ -483,6 +490,30 @@ seconds instead of the full run's minute — and the full run stays the gate.
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 mutation` runs with `--with-uncovered` since 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.php` yields closures for that
reason).
- A message built as `sprintf('a' . 'b', ...)` or a `throw` over 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's
`SimplifyUselessVariable` undoes this for a plain `return`, 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: `DoubleState` literal-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]` is `null`
and 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 its
`inlineMethods` cache keys, `ArgumentFormatter`'s `null` and `NAN` match arms
(the default arm renders both identically on PHP 8.4).
- **Rector and Psalm pull in opposite directions here.** `RemoveUselessVarTag`
and `FlipTypeControlToUseExclusiveType` are skipped in `rector.php` with the
reasons written down; do not add skips beyond those without the same.
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

- Mutation testing now counts code the tests do not name in `#[Covers]` (`infection --with-uncovered`), with the signature-default and visibility mutators configured as in the package template. Overall MSI with the flag is about 94% against the unchanged gate of 92.
- Tests: the wording of every failure message that has a factory is pinned word for word (`ExceptionMessageTest`); `Understudy::bypassFinals()`, `BypassUnavailable` and the explanations for a final class that is still standing are covered in-process (`BypassFinalsTest`, `FinalReasonTest`); defaults, `parent`, intersection overlap, `wire()` edge types, `TypeDefaultResolver` type strings and `DefaultFactories` ancestry have direct cases.
- No behaviour change. In `src/`, failure messages assembled from several literals are built into a local `$format`/`$reason` before use (same text), and six one-line signatures with a defaulted parameter are split one parameter per line.

## 1.1.1 — 2026-10-14

- **Fixed.** Doubling a type whose method declares a parameter as `self` no
Expand Down
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@
"@php examples/structured-failures.php",
"@php examples/where-guard.php"
],
"mutation": "infection --threads=max",
"mutation": [
"Composer\\Config::disableProcessTimeout",
"infection --threads=max --with-uncovered"
],
"psalm": "psalm --no-cache",
"rector": "rector process --dry-run",
"rector:fix": "rector process",
Expand Down
42 changes: 42 additions & 0 deletions infection.json5
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,51 @@
// environment, not the suite (see AGENTS.md), so the number stays at 92
// until the escaped list — TargetUnifier 45, FinalStripper 29, Runtime 28
// — has been hunted down far enough to give 94 real headroom.
//
// Re-measured 2026-11-03 with `--with-uncovered` (PHP 8.4, pcov, zend.assertions off like CI):
// 4016 mutants, 14 ignored by the signature-default rule below, Covered Code MSI 95%, overall MSI
// 93.8% (94.1% with the ignored ones out of the denominator) against a gate of 92. Before the
// flag the same command showed 3093 mutants and 94.1% (Covered Code MSI). What is left, 173
// Escaped and 62 Not Covered:
// - About 20 Not Covered sit inside `\assert()`, which CI compiles out (FileWrapper 9, TargetUnifier 5,
// Runtime 2, the rest one each). Unkillable at zend.assertions=-1.
// - Defensive branches no input reaches: `Arg::reason(null)`, `renderConstantDefault()` with no
// declaring class or no `parent`, `Understudy::foreignFileWrapper()` with no source file, the
// `file_get_contents() === false` return in `FileWrapper::stream_open()`.
// - Equivalents named in AGENTS.md: `DoubleState` literal-key index (an optimisation of a walk that
// still exists), `FinalStripper` fast paths, `?->` on values that are never null, cache keys.
// `bypassFinals()`, `BypassUnavailable` and `FileWrapper` are covered in-process by
// `BypassFinalsTest`, `FinalReasonTest` and `FileWrapperTest` (wrapper installed and removed inside
// each test, as `FileWrapperTest` always did); only load-order claims need the subprocess suite.
// Unit only: every package declares a Benchmarks suite (testo.php), and Infection's initial
// test run would execute it. A timing-sensitive benchmark fails under host load and aborts
// the whole mutation run (result, 2026-11-03). Benchmarks mutate nothing and kill nothing.
"testFrameworkOptions": "--suite=Unit",
"minMsi": 92,
"mutators": {
"@default": true,
// Constructor/method parameter declarations with a default value
// (`private int $x = 100,`, `bool $strict = false,`): the mutant sits on a
// signature line with no executable opcode under pcov, so it is reported Not
// Covered whatever the tests do. Only a type-prefixed `$name = default,` line
// matches; a body line cannot (no type before the variable). Do NOT add ^ or $:
// Infection wraps the pattern as ^-\s*REGEX$ over the removed diff line.
// Verified 2026-11-01 by diffing the survivor lists with and without it:
// bulkhead 6, circuit-breaker 13, json-ld 19 mutants removed, all of them
// such signature lines; Escaped and Timed Out unchanged.
"global-ignoreSourceCodeByRegex": [
"(?:(?:private|protected|public|readonly)\\s+)*\\??[A-Za-z_\\\\][\\w\\\\|]*\\s+\\$\\w+\\s*=\\s*[^,;]+,"
],
// Off: with --with-uncovered a PublicVisibility mutant sits on a parameterless
// method's signature line, which has no executable opcode under pcov/xdebug,
// so it is reported Not Covered and can never be killed, whatever #[Covers]
// says. Measured on pinned-http-client 2026-11-01: 4 of 4 such mutants were
// Not Covered, none killed.
"PublicVisibility": false,
// Off for the same reason: a ProtectedVisibility mutant on a protected method's
// signature line is Not Covered and cannot be killed. Measured on
// understudy-phpunit 2026-11-01: 4 of 4 such mutants were Not Covered.
"ProtectedVisibility": false,
// Raw-I/O passthroughs. Each of these forwards one call to the native
// filesystem and returns what it answered; a mutant either changes
// nothing observable or needs a real side effect on disk to see. The
Expand Down
26 changes: 11 additions & 15 deletions src/Codegen/DoubleFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -277,15 +277,13 @@ private static function unifyAbstractHooks(array $targets): array
$name = $property->getName();

if (!$primary->isInterface() && $primary->isReadOnly()) {
$format = 'the class is readonly and `%s::$%s` is an abstract property hook. A readonly class '
. 'may only be extended by a readonly class, and a hooked property cannot be readonly '
. '— so no double can implement it. Double an interface of the contract instead.';

throw UnsupportedTarget::notDoublable(
$primary->getName(),
sprintf(
'the class is readonly and `%s::$%s` is an abstract property hook. A readonly class '
. 'may only be extended by a readonly class, and a hooked property cannot be readonly '
. '— so no double can implement it. Double an interface of the contract instead.',
$property->getDeclaringClass()->getName(),
$name,
),
sprintf($format, $property->getDeclaringClass()->getName(), $name),
);
}

Expand All @@ -294,15 +292,13 @@ private static function unifyAbstractHooks(array $targets): array
$get = $hooks['get'] ?? null;

if ($get !== null && $get->returnsReference()) {
$format = '`%s::$%s` declares a by-reference `&get` hook, and a double dispatches property '
. 'reads by value — the reference it handed back would not be the one the contract '
. 'promises. Expose the value through a method, or pass a real object.';

throw UnsupportedTarget::notDoublable(
$target->getName(),
sprintf(
'`%s::$%s` declares a by-reference `&get` hook, and a double dispatches property '
. 'reads by value — the reference it handed back would not be the one the contract '
. 'promises. Expose the value through a method, or pass a real object.',
$property->getDeclaringClass()->getName(),
$name,
),
sprintf($format, $property->getDeclaringClass()->getName(), $name),
);
}

Expand Down Expand Up @@ -436,7 +432,7 @@ private static function whyFinalStillStands(string $contract, \ReflectionClass $
$origin = match (true) {
$file === false => 'it has no source file of its own',
str_starts_with($file, 'phar://') => 'it was read out of a PHAR, and `phar://` is not `file://`',
default => 'it was already loaded, from `' . $file . '`, before the wrapper was installed',
default => sprintf('it was already loaded, from `%s`, before the wrapper was installed', $file),
};

return "the class is final, and bypass was asked for it but could not reach it: {$origin}.\n"
Expand Down
27 changes: 11 additions & 16 deletions src/Codegen/TargetUnifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -1238,17 +1238,14 @@ private static function withoutStringLiterals(string $source): string
private static function renderSourceDefault(\ReflectionParameter $parameter, string $method, string $source): string
{
if (preg_match('/\b(self|static|parent)\s*::/i', self::withoutStringLiterals($source)) === 1) {
$format = 'the default of `$%s` in `%s()` is `%s`, which resolves `self`, `static` or `parent` '
. 'against the declaring class. The generated double is a different class, so the value '
. 'cannot be reproduced. Give the parameter a default the double can build, or double '
. 'an interface.';

throw UnsupportedTarget::notDoublable(
$parameter->getDeclaringClass()?->getName() ?? $method,
sprintf(
'the default of `$%s` in `%s()` is `%s`, which resolves `self`, `static` or `parent` '
. 'against the declaring class. The generated double is a different class, so the value '
. 'cannot be reproduced. Give the parameter a default the double can build, or double '
. 'an interface.',
$parameter->getName(),
$method,
$source,
),
sprintf($format, $parameter->getName(), $method, $source),
);
}

Expand Down Expand Up @@ -1285,7 +1282,7 @@ private static function renderConstantDefault(\ReflectionParameter $parameter):
$declaring = $parameter->getDeclaringClass();

if (\in_array(strtolower($class), ['self', 'static', 'parent'], strict: true)) {
if ($declaring === null) {
if (!$declaring instanceof \ReflectionClass) {
return null;
}

Expand Down Expand Up @@ -1340,14 +1337,12 @@ private static function renderValueDefault(\ReflectionParameter $parameter, stri
$value = $parameter->getDefaultValue();

if (self::holdsPlainObject($value)) {
$format = 'the default of `$%s` in `%s()` holds an object that cannot be written back as source. '
. 'Give the parameter a scalar, array or enum default, or double an interface.';

throw UnsupportedTarget::notDoublable(
$parameter->getDeclaringClass()?->getName() ?? $method,
sprintf(
'the default of `$%s` in `%s()` holds an object that cannot be written back as source. '
. 'Give the parameter a scalar, array or enum default, or double an interface.',
$parameter->getName(),
$method,
),
sprintf($format, $parameter->getName(), $method),
);
}

Expand Down
5 changes: 3 additions & 2 deletions src/Codegen/TypeRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,17 @@ private static function resolveParent(string $rendered, ?\ReflectionClass $decla

$parent = $declaringClass?->getParentClass();

if ($parent === false || $parent === null) {
if ($parent === false || !$parent instanceof \ReflectionClass) {
return $rendered;
}

// Word boundaries, and not already qualified: a class genuinely named
// `Parent`, or `\App\Parent`, is somebody's own name and not the
// keyword.
$replacement = '\\\\' . $parent->getName();
$resolved = preg_replace(
'/(?<![\\\\\\w])parent(?![\\\\\\w])/i',
'\\\\' . $parent->getName(),
$replacement,
$rendered,
);

Expand Down
8 changes: 5 additions & 3 deletions src/Exception/AmbiguousDefaultFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ public static function between(string $requested, array $candidates): self
{
sort($candidates);

return new self(sprintf(
"More than one default factory is equally close to `%s`: %s.\n"
$format = "More than one default factory is equally close to `%s`: %s.\n"
. 'Register one for `%s` itself — resolution by distance is what keeps the answer independent '
. 'of the order the factories were registered in, and a tie has no order to fall back on.',
. 'of the order the factories were registered in, and a tie has no order to fall back on.';

return new self(sprintf(
$format,
$requested,
implode(', ', array_map(static fn(string $c): string => '`' . $c . '`', $candidates)),
$requested,
Expand Down
28 changes: 12 additions & 16 deletions src/Exception/BypassUnavailable.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,12 @@ final class BypassUnavailable extends \LogicException implements UnderstudyError
*/
public static function alreadyLoaded(string $class): self
{
return new self(sprintf(
"`%s` is already loaded, so its `final` cannot be lifted any more.\n"
$format = "`%s` is already loaded, so its `final` cannot be lifted any more.\n"
. "A class is read from disk once per process, and the transform happens as it is read.\n"
. "- Call Understudy::bypassFinals() from your test bootstrap, before Composer autoloads it.\n"
. '- Or double an interface it implements, which needs no bypass at all.',
$class,
));
. '- Or double an interface it implements, which needs no bypass at all.';

return new self(sprintf($format, $class));
}

/**
Expand All @@ -34,12 +33,10 @@ public static function alreadyLoaded(string $class): self
*/
public static function notAClass(string $class, string $kind): self
{
return new self(sprintf(
"`%s` is %s, and lifting `final` would not make it extendable.\n"
. 'Bypass exists for a final *class* standing between a test and the code under test.',
$class,
$kind,
));
$format = "`%s` is %s, and lifting `final` would not make it extendable.\n"
. 'Bypass exists for a final *class* standing between a test and the code under test.';

return new self(sprintf($format, $class, $kind));
}

/**
Expand All @@ -50,12 +47,11 @@ public static function notAClass(string $class, string $kind): self
*/
public static function foreignWrapper(string $owner): self
{
return new self(sprintf(
"Something else is already serving `file://` through a user-space wrapper (%s).\n"
$format = "Something else is already serving `file://` through a user-space wrapper (%s).\n"
. "Replacing that wrapper would silently disable whatever it does, so understudy will not.\n"
. "- Call Understudy::bypassFinals() before the other wrapper is installed.\n"
. '- Or double an interface, which needs no wrapper at all.',
$owner,
));
. '- Or double an interface, which needs no wrapper at all.';

return new self(sprintf($format, $owner));
}
}
Loading
Loading