Skip to content

Commit 94eba2d

Browse files
authored
Count uncovered code in mutation testing (--with-uncovered) (#1)
Behaviour-neutral changes only, no release.
1 parent 90302f9 commit 94eba2d

5 files changed

Lines changed: 103 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## Unreleased
9+
10+
- Mutation testing now counts code that tests execute without naming it in
11+
`#[Covers]` (`infection --with-uncovered`, template `PublicVisibility`/
12+
`ProtectedVisibility` switches and default-parameter signature ignore). New
13+
tests pin the re-raise of a second signal under the default disposition (an
14+
in-process run plus a forked child that must die of the signal) and the
15+
fallback for a previous handler that is not callable from the scope.
16+
Behaviour-neutral `src/` change: `SignalScope::listen()`/`run()` signatures are
17+
split one parameter per line and the default signal set moved to a private
18+
constant `DEFAULT_SIGNALS` (still `[SIGTERM, SIGINT]`).
19+
820
## 0.1.0 — 2026-10-15
921

1022
- Initial release: `SignalScope` (`listen()`, `run()`, `context()`,

‎composer.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@
8282
],
8383
"cs": "php-cs-fixer fix --dry-run --diff --config=.php-cs-fixer.php",
8484
"cs:fix": "php-cs-fixer fix --diff --config=.php-cs-fixer.php",
85-
"mutation": "infection --threads=max",
85+
"mutation": [
86+
"Composer\\Config::disableProcessTimeout",
87+
"infection --threads=max --with-uncovered"
88+
],
8689
"psalm": "psalm --no-cache",
8790
"rector": "rector process --dry-run",
8891
"rector:fix": "rector process",

‎infection.json5‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,28 @@
1010
"testFramework": "testo",
1111
"minMsi": 95,
1212
"mutators": {
13-
"@default": true
13+
"@default": true,
14+
// Constructor/method parameter declarations with a default value
15+
// (`private int $x = 100,`, `bool $strict = false,`): the mutant sits on a
16+
// signature line with no executable opcode under pcov, so it is reported Not
17+
// Covered whatever the tests do. Only a type-prefixed `$name = default,` line
18+
// matches; a body line cannot (no type before the variable). Do NOT add ^ or $:
19+
// Infection wraps the pattern as ^-\s*REGEX$ over the removed diff line.
20+
// Verified 2026-11-01 by diffing the survivor lists with and without it:
21+
// bulkhead 6, circuit-breaker 13, json-ld 19 mutants removed, all of them
22+
// such signature lines; Escaped and Timed Out unchanged.
23+
"global-ignoreSourceCodeByRegex": [
24+
"(?:(?:private|protected|public|readonly)\\s+)*\\??[A-Za-z_\\\\][\\w\\\\|]*\\s+\\$\\w+\\s*=\\s*[^,;]+,"
25+
],
26+
// Off: with --with-uncovered a PublicVisibility mutant sits on a parameterless
27+
// method's signature line, which has no executable opcode under pcov/xdebug,
28+
// so it is reported Not Covered and can never be killed, whatever #[Covers]
29+
// says. Measured on pinned-http-client 2026-11-01: 4 of 4 such mutants were
30+
// Not Covered, none killed.
31+
"PublicVisibility": false,
32+
// Off for the same reason: a ProtectedVisibility mutant on a protected method's
33+
// signature line is Not Covered and cannot be killed. Measured on
34+
// understudy-phpunit 2026-11-01: 4 of 4 such mutants were Not Covered.
35+
"ProtectedVisibility": false
1436
}
1537
}

‎src/SignalScope.php‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
*/
2222
final class SignalScope
2323
{
24+
private const array DEFAULT_SIGNALS = [\SIGTERM, \SIGINT];
25+
2426
/**
2527
* Signals currently owned by an active scope; one process, one handler table.
2628
*
@@ -53,8 +55,10 @@ private function __construct(
5355
* @throws \InvalidArgumentException on an empty, duplicate or uncatchable signal
5456
* @throws \LogicException when another active scope owns one of the signals
5557
*/
56-
public static function listen(Context $parent, array $signals = [\SIGTERM, \SIGINT]): self
57-
{
58+
public static function listen(
59+
Context $parent,
60+
array $signals = self::DEFAULT_SIGNALS,
61+
): self {
5862
$signals = self::validated($signals);
5963

6064
foreach ($signals as $signal) {
@@ -97,8 +101,11 @@ public static function listen(Context $parent, array $signals = [\SIGTERM, \SIGI
97101
*
98102
* @return T
99103
*/
100-
public static function run(Context $parent, callable $work, array $signals = [\SIGTERM, \SIGINT]): mixed
101-
{
104+
public static function run(
105+
Context $parent,
106+
callable $work,
107+
array $signals = self::DEFAULT_SIGNALS,
108+
): mixed {
102109
$scope = self::listen($parent, $signals);
103110

104111
try {

‎tests/SignalScopeTest.php‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,53 @@ public function anIgnoredSignalStaysIgnored(): void
165165
Assert::true($scope->context()->isCancelled());
166166
}
167167

168+
public function aHandlerNotCallableFromTheScopeCountsAsTheDefaultDisposition(): void
169+
{
170+
$name = 'privateHandler';
171+
pcntl_signal(\SIGUSR1, [$this, $name]);
172+
$this->send(\SIGUSR1);
173+
Assert::same($this->previous, [\SIGUSR1]);
174+
175+
$scope = SignalScope::listen(Context::background(), [\SIGUSR1]);
176+
$scope->restore();
177+
178+
Assert::same(pcntl_signal_get_handler(\SIGUSR1), \SIG_DFL);
179+
$this->privateHandler(\SIGUSR2);
180+
Assert::same($this->previous, [\SIGUSR1, \SIGUSR2]);
181+
}
182+
183+
public function aSecondSignalWithTheDefaultDispositionIsReRaised(): void
184+
{
185+
$scope = SignalScope::listen(Context::background(), [\SIGWINCH]);
186+
187+
$this->send(\SIGWINCH);
188+
$this->send(\SIGWINCH);
189+
190+
Assert::same(pcntl_signal_get_handler(\SIGWINCH), \SIG_DFL);
191+
Assert::same($scope->signal(), \SIGWINCH);
192+
193+
// The kernel ignores SIGWINCH by default, so the re-raise above is
194+
// harmless but unobservable. Infection maps a mutant to the tests that
195+
// execute its line in this process, so the observable check (a forked
196+
// child that must die of the re-raised signal) lives in the same test.
197+
$pid = pcntl_fork();
198+
if ($pid === 0) {
199+
try {
200+
pcntl_signal(\SIGUSR1, \SIG_DFL);
201+
SignalScope::listen(Context::background(), [\SIGUSR1]);
202+
$this->send(\SIGUSR1);
203+
$this->send(\SIGUSR1);
204+
} finally {
205+
posix_kill(posix_getpid(), \SIGKILL);
206+
}
207+
}
208+
209+
pcntl_waitpid($pid, $status);
210+
211+
Assert::true(pcntl_wifsignaled($status));
212+
Assert::same(pcntl_wtermsig($status), \SIGUSR1);
213+
}
214+
168215
public function sighupCanBeHandled(): void
169216
{
170217
$scope = SignalScope::listen(Context::background(), [\SIGHUP]);
@@ -377,6 +424,12 @@ public function anInvalidSetLeavesNothingInstalled(): void
377424
}
378425
}
379426

427+
/** Registered by name: private, so not callable from SignalScope's scope. */
428+
private function privateHandler(int $signal): void
429+
{
430+
$this->previous[] = $signal;
431+
}
432+
380433
private function childCount(Context $context): int
381434
{
382435
$state = (new \ReflectionProperty(Context::class, 'state'))->getValue($context);

0 commit comments

Comments
 (0)