Skip to content

[Bug]: [mutate] @pest-mutate-ignore silently suppresses nothing, or the wrong set #1942

Description

@bpmore

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions