diff --git a/CHANGELOG.md b/CHANGELOG.md index bd5444c..ca4b0e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +- Mutation testing now counts code that tests execute without naming it in + `#[Covers]` (`infection --with-uncovered`, template `PublicVisibility`/ + `ProtectedVisibility` switches and default-parameter signature ignore). New + tests pin the re-raise of a second signal under the default disposition (an + in-process run plus a forked child that must die of the signal) and the + fallback for a previous handler that is not callable from the scope. + Behaviour-neutral `src/` change: `SignalScope::listen()`/`run()` signatures are + split one parameter per line and the default signal set moved to a private + constant `DEFAULT_SIGNALS` (still `[SIGTERM, SIGINT]`). + ## 0.1.0 — 2026-10-15 - Initial release: `SignalScope` (`listen()`, `run()`, `context()`, diff --git a/composer.json b/composer.json index 6d432b0..c5e5577 100644 --- a/composer.json +++ b/composer.json @@ -82,7 +82,10 @@ ], "cs": "php-cs-fixer fix --dry-run --diff --config=.php-cs-fixer.php", "cs:fix": "php-cs-fixer fix --diff --config=.php-cs-fixer.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", diff --git a/infection.json5 b/infection.json5 index 9c0d6f4..14de1e3 100644 --- a/infection.json5 +++ b/infection.json5 @@ -10,6 +10,28 @@ "testFramework": "testo", "minMsi": 95, "mutators": { - "@default": true + "@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 } } diff --git a/src/SignalScope.php b/src/SignalScope.php index d47abfc..1e417f8 100644 --- a/src/SignalScope.php +++ b/src/SignalScope.php @@ -21,6 +21,8 @@ */ final class SignalScope { + private const array DEFAULT_SIGNALS = [\SIGTERM, \SIGINT]; + /** * Signals currently owned by an active scope; one process, one handler table. * @@ -53,8 +55,10 @@ private function __construct( * @throws \InvalidArgumentException on an empty, duplicate or uncatchable signal * @throws \LogicException when another active scope owns one of the signals */ - public static function listen(Context $parent, array $signals = [\SIGTERM, \SIGINT]): self - { + public static function listen( + Context $parent, + array $signals = self::DEFAULT_SIGNALS, + ): self { $signals = self::validated($signals); foreach ($signals as $signal) { @@ -97,8 +101,11 @@ public static function listen(Context $parent, array $signals = [\SIGTERM, \SIGI * * @return T */ - public static function run(Context $parent, callable $work, array $signals = [\SIGTERM, \SIGINT]): mixed - { + public static function run( + Context $parent, + callable $work, + array $signals = self::DEFAULT_SIGNALS, + ): mixed { $scope = self::listen($parent, $signals); try { diff --git a/tests/SignalScopeTest.php b/tests/SignalScopeTest.php index f1a2833..8b90882 100644 --- a/tests/SignalScopeTest.php +++ b/tests/SignalScopeTest.php @@ -165,6 +165,53 @@ public function anIgnoredSignalStaysIgnored(): void Assert::true($scope->context()->isCancelled()); } + public function aHandlerNotCallableFromTheScopeCountsAsTheDefaultDisposition(): void + { + $name = 'privateHandler'; + pcntl_signal(\SIGUSR1, [$this, $name]); + $this->send(\SIGUSR1); + Assert::same($this->previous, [\SIGUSR1]); + + $scope = SignalScope::listen(Context::background(), [\SIGUSR1]); + $scope->restore(); + + Assert::same(pcntl_signal_get_handler(\SIGUSR1), \SIG_DFL); + $this->privateHandler(\SIGUSR2); + Assert::same($this->previous, [\SIGUSR1, \SIGUSR2]); + } + + public function aSecondSignalWithTheDefaultDispositionIsReRaised(): void + { + $scope = SignalScope::listen(Context::background(), [\SIGWINCH]); + + $this->send(\SIGWINCH); + $this->send(\SIGWINCH); + + Assert::same(pcntl_signal_get_handler(\SIGWINCH), \SIG_DFL); + Assert::same($scope->signal(), \SIGWINCH); + + // The kernel ignores SIGWINCH by default, so the re-raise above is + // harmless but unobservable. Infection maps a mutant to the tests that + // execute its line in this process, so the observable check (a forked + // child that must die of the re-raised signal) lives in the same test. + $pid = pcntl_fork(); + if ($pid === 0) { + try { + pcntl_signal(\SIGUSR1, \SIG_DFL); + SignalScope::listen(Context::background(), [\SIGUSR1]); + $this->send(\SIGUSR1); + $this->send(\SIGUSR1); + } finally { + posix_kill(posix_getpid(), \SIGKILL); + } + } + + pcntl_waitpid($pid, $status); + + Assert::true(pcntl_wifsignaled($status)); + Assert::same(pcntl_wtermsig($status), \SIGUSR1); + } + public function sighupCanBeHandled(): void { $scope = SignalScope::listen(Context::background(), [\SIGHUP]); @@ -377,6 +424,12 @@ public function anInvalidSetLeavesNothingInstalled(): void } } + /** Registered by name: private, so not callable from SignalScope's scope. */ + private function privateHandler(int $signal): void + { + $this->previous[] = $signal; + } + private function childCount(Context $context): int { $state = (new \ReflectionProperty(Context::class, 'state'))->getValue($context);