What Happened
// @pest-mutate-ignore has three defects in pestphp/pest-plugin-mutate (v5.0.2, identical to the current 5.x). None of them produces an error. Two make an annotation suppress nothing while it looks like it applies. The third makes a bare annotation suppress the wrong set.
1. Whitespace inverts the bare form. NodeVisitor::enterNode() tests $matches[1] === '' on the raw (.*) capture, before any trim. One trailing space, or a docblock's own */, makes the tail non-empty. The tail then matches no mutator name, so the annotation suppresses nothing:
// @pest-mutate-ignore BLANKET
// @pest-mutate-ignore<space> INERT
/** @pest-mutate-ignore */ INERT
A formatter that strips trailing whitespace can flip the second line back to the first, so a formatting-only commit can change mutation results.
2. A bare marker inherits the previous annotation's list. MutationGenerator::generate() sets $mutatorsToIgnore only in the @pest-mutate-ignore: branch and never resets it between lines. Once any earlier line used the : form, ?? ['all'] can no longer fire, and a later bare marker is recorded with that earlier line's mutator list instead of ['all'].
3. Mutated source is never written when .temp/mutations is missing, and the run still reports a score. Mutation.php writes the mutated file with an unchecked file_put_contents($modifiedSourcePath, ...) (line 52 on 5.x) into TMP_FOLDER (src/../.temp/mutations), and nothing recreates that directory when it is absent. Every write then fails, no mutant is actually applied, and the run still classifies mutants and prints a score. Removing the directory deliberately gave a different surface on each platform:
Windows one file_put_contents warning per mutant, then "Score: 0.00%", exit 0
macOS "0 Mutations for 0 Files created", "Score: 0.00%", exit 0
Checking the write and creating the directory when it is missing would close it. It is a separate mechanism from 1 and 2 and is reported alongside them because all three produce a result with no real mutation behind it.
Expected: a bare annotation suppresses every mutator on its node regardless of trailing whitespace or comment style. A bare marker means all whatever came before it. A failed mutation write fails the run instead of producing a score.
How to Reproduce
Each script is a line-for-line copy of the 5.x logic, with Str::after() inlined, so it runs with plain php and no install.
Defect 1, from NodeVisitor::enterNode():
<?php
function verdict(string $comment, string $mutator = 'TrueToFalse'): string {
preg_match('/@pest-mutate-ignore(.*)/', $comment, $matches);
if ($matches === []) {
return 'none';
}
if ($matches[1] === '') {
return 'BLANKET';
}
$names = array_map(fn (string $m): string => trim($m, ' */:'), explode(',', $matches[1]));
return in_array($mutator, $names, true) ? 'SCOPED' : 'INERT';
}
foreach ([
'bare' => '// @pest-mutate-ignore',
'bare + trailing space' => '// @pest-mutate-ignore ',
'docblock bare' => '/** @pest-mutate-ignore */',
': Name' => '// @pest-mutate-ignore: TrueToFalse',
': Name, prose' => '// @pest-mutate-ignore: TrueToFalse, because ints',
': Name prose' => '// @pest-mutate-ignore: TrueToFalse because ints',
'unknown name only' => '// @pest-mutate-ignore: NotAMutator',
] as $name => $comment) {
printf("%-22s %s\n", $name, verdict($comment));
}
Defect 2, from MutationGenerator::generate():
<?php
function after(string $subject, string $search): string {
$pos = strpos($subject, $search);
return $pos === false ? $subject : substr($subject, $pos + strlen($search));
}
function scan(string $contents): array {
$mutatorsToIgnoreByLine = [];
foreach (explode(PHP_EOL, $contents) as $lineNumber => $line) {
if (str_contains($line, '@pest-mutate-ignore')) {
if (after($line, '@pest-mutate-ignore:') !== $line) {
$mutatorsToIgnore = explode(',', after($line, '@pest-mutate-ignore:'));
$mutatorsToIgnore = array_map(trim(...), $mutatorsToIgnore);
}
$mutatorsToIgnoreByLine[$lineNumber + 1] = $mutatorsToIgnore ?? ['all'];
}
}
return $mutatorsToIgnoreByLine;
}
foreach ([
'bare alone' => "\$a = 1; // @pest-mutate-ignore",
'colon then bare' => "\$a = 1; // @pest-mutate-ignore: IncrementInteger\n\$b = 2; // @pest-mutate-ignore",
'bare then colon' => "\$a = 1; // @pest-mutate-ignore\n\$b = 2; // @pest-mutate-ignore: IncrementInteger",
'colon, gap, bare' => "\$a = true; // @pest-mutate-ignore: TrueToFalse\n\$b = 2;\n\$c = true; // @pest-mutate-ignore",
] as $name => $src) {
printf("%-17s %s\n", $name, json_encode(scan($src)));
}
Output of each on PHP 8.4, before and after the fix below:
| form |
current |
fixed |
| bare |
BLANKET |
BLANKET |
| bare + trailing space |
INERT |
BLANKET |
| docblock bare |
INERT |
BLANKET |
: Name |
SCOPED |
SCOPED |
: Name, prose |
SCOPED |
SCOPED |
: Name prose |
INERT |
INERT |
| unknown name only |
INERT |
INERT |
| fixture |
current |
fixed |
| bare alone |
{"1":["all"]} |
{"1":["all"]} |
| colon then bare |
{"1":["IncrementInteger"],"2":["IncrementInteger"]} |
{"1":["IncrementInteger"],"2":["all"]} |
| bare then colon |
{"1":["all"],"2":["IncrementInteger"]} |
{"1":["all"],"2":["IncrementInteger"]} |
| colon, gap, bare |
{"1":["TrueToFalse"],"3":["TrueToFalse"]} |
{"1":["TrueToFalse"],"3":["all"]} |
Defect 3: delete vendor/pestphp/pest-plugin-mutate/.temp/mutations, then run vendor/bin/pest --mutate on any project with a covered class.
The fix for 1 and 2, tested as a local patch against v5.0.2. A pull request against pestphp/pest-plugin-mutate 5.x can follow if this shape is acceptable:
--- a/src/Support/MutationGenerator.php
+++ b/src/Support/MutationGenerator.php
@@ -47,6 +47,8 @@ class MutationGenerator
$mutatorsToIgnoreByLine = [];
foreach (explode(PHP_EOL, $contents) as $lineNumber => $line) {
if (str_contains($line, '@pest-mutate-ignore')) {
+ $mutatorsToIgnore = null;
+
if (Str::after($line, '@pest-mutate-ignore:') !== $line) {
$mutatorsToIgnore = explode(',', Str::after($line, '@pest-mutate-ignore:'));
$mutatorsToIgnore = array_map(trim(...), $mutatorsToIgnore);
--- a/src/Support/NodeVisitor.php
+++ b/src/Support/NodeVisitor.php
@@ -33,11 +33,13 @@ class NodeVisitor extends NodeVisitorAbstract
foreach ($node->getAttribute('comments') as $comment) { // @phpstan-ignore-line
preg_match('/@pest-mutate-ignore(.*)/', (string) $comment->getText(), $matches); // @phpstan-ignore-line
if ($matches !== []) {
- if ($matches[1] === '') {
+ $mutatorsToIgnore = trim($matches[1], " \t\n\r*/:");
+
+ if ($mutatorsToIgnore === '') {
return NodeTraverser::DONT_TRAVERSE_CURRENT_AND_CHILDREN;
}
- if (in_array($this->mutator::name(), array_map(fn (string $mutatorToIgnore): string => trim($mutatorToIgnore, ' */:'), explode(',', $matches[1])), true)) {
+ if (in_array($this->mutator::name(), array_map(fn (string $mutatorToIgnore): string => trim($mutatorToIgnore, ' */:'), explode(',', $mutatorsToIgnore)), true)) {
return NodeTraverser::DONT_TRAVERSE_CURRENT_AND_CHILDREN;
}
}
Sample Repository
No response
Pest Version
5.2.1 (pest-plugin-mutate 5.0.2)
PHP Version
8.4.25
Operation System
macOS, Windows
Notes
A question, not part of the fix: // @pest-mutate-ignore: TrueToFalse because ints stays INERT under the fix above, and that is the form people naturally write. The tail is a comma-separated list of mutator names, so the reason text becomes part of the name. Taking only the leading identifier of each comma-separated part would make it SCOPED, and an unknown name would still correctly stay INERT. Because that changes what the annotation accepts, it is left to you rather than folded into the fix.
Related, not duplicated here: #1928 and pestphp/pest-plugin-mutate#44 cover the same line scan splitting on PHP_EOL in LF files on Windows. That is a different defect, and the fix above does not touch it. Defect 3 may be what some reports of 0 Mutations for 0 Files created (#1313) are seeing, but that has not been confirmed.
Defects 1 and 2 were measured on macOS (Darwin arm64); defect 3 on both macOS and Windows.
What Happened
// @pest-mutate-ignorehas three defects inpestphp/pest-plugin-mutate(v5.0.2, identical to the current5.x). None of them produces an error. Two make an annotation suppress nothing while it looks like it applies. The third makes a bare annotation suppress the wrong set.1. Whitespace inverts the bare form.
NodeVisitor::enterNode()tests$matches[1] === ''on the raw(.*)capture, before any trim. One trailing space, or a docblock's own*/, makes the tail non-empty. The tail then matches no mutator name, so the annotation suppresses nothing:A formatter that strips trailing whitespace can flip the second line back to the first, so a formatting-only commit can change mutation results.
2. A bare marker inherits the previous annotation's list.
MutationGenerator::generate()sets$mutatorsToIgnoreonly in the@pest-mutate-ignore:branch and never resets it between lines. Once any earlier line used the:form,?? ['all']can no longer fire, and a later bare marker is recorded with that earlier line's mutator list instead of['all'].3. Mutated source is never written when
.temp/mutationsis missing, and the run still reports a score.Mutation.phpwrites the mutated file with an uncheckedfile_put_contents($modifiedSourcePath, ...)(line 52 on5.x) intoTMP_FOLDER(src/../.temp/mutations), and nothing recreates that directory when it is absent. Every write then fails, no mutant is actually applied, and the run still classifies mutants and prints a score. Removing the directory deliberately gave a different surface on each platform:Checking the write and creating the directory when it is missing would close it. It is a separate mechanism from 1 and 2 and is reported alongside them because all three produce a result with no real mutation behind it.
Expected: a bare annotation suppresses every mutator on its node regardless of trailing whitespace or comment style. A bare marker means
allwhatever came before it. A failed mutation write fails the run instead of producing a score.How to Reproduce
Each script is a line-for-line copy of the
5.xlogic, withStr::after()inlined, so it runs with plainphpand no install.Defect 1, from
NodeVisitor::enterNode():Defect 2, from
MutationGenerator::generate():Output of each on PHP 8.4, before and after the fix below:
: Name: Name, prose: Name prose{"1":["all"]}{"1":["all"]}{"1":["IncrementInteger"],"2":["IncrementInteger"]}{"1":["IncrementInteger"],"2":["all"]}{"1":["all"],"2":["IncrementInteger"]}{"1":["all"],"2":["IncrementInteger"]}{"1":["TrueToFalse"],"3":["TrueToFalse"]}{"1":["TrueToFalse"],"3":["all"]}Defect 3: delete
vendor/pestphp/pest-plugin-mutate/.temp/mutations, then runvendor/bin/pest --mutateon any project with a covered class.The fix for 1 and 2, tested as a local patch against v5.0.2. A pull request against
pestphp/pest-plugin-mutate5.xcan follow if this shape is acceptable:Sample Repository
No response
Pest Version
5.2.1 (pest-plugin-mutate 5.0.2)
PHP Version
8.4.25
Operation System
macOS, Windows
Notes
A question, not part of the fix:
// @pest-mutate-ignore: TrueToFalse because intsstays INERT under the fix above, and that is the form people naturally write. The tail is a comma-separated list of mutator names, so the reason text becomes part of the name. Taking only the leading identifier of each comma-separated part would make it SCOPED, and an unknown name would still correctly stay INERT. Because that changes what the annotation accepts, it is left to you rather than folded into the fix.Related, not duplicated here: #1928 and pestphp/pest-plugin-mutate#44 cover the same line scan splitting on
PHP_EOLin LF files on Windows. That is a different defect, and the fix above does not touch it. Defect 3 may be what some reports of0 Mutations for 0 Files created(#1313) are seeing, but that has not been confirmed.Defects 1 and 2 were measured on macOS (Darwin arm64); defect 3 on both macOS and Windows.