Search by

php-regex / regex-infection

yoeunes

Infection mutators for the regex literals of preg_*() calls: no mutant the PHPRegex automata prove equivalent to the original, and each with the subject that kills it when one is found.

Package info

github.com/php-regex/regex-infection

Homepage

Issues

Documentation

pkg:composer/php-regex/regex-infection

Fund package maintenance!

yoeunes

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

2.x-dev 2026-11-09 22:46 UTC

This package is auto-updated.

Last update: 2026-11-10 08:24:41 UTC


README

Documentation Badge Author Badge GitHub Release Badge License Badge Packagist Downloads Badge GitHub Stars Badge Supported PHP Version Badge

PHPRegex Infection

Mutators for Infection that mutate the regex literals your code passes to preg_*(): a + turns into *, an anchor goes, a class loses a member. The PHPRegex automata drop every mutant they prove behaves as the original for the call it sits in, so no run of your suite is spent on a mutant no test could kill — and each mutant that survives carries the subject that kills it, written into the pattern as (?#kill: "…"), ready to paste into a test.

Requires Infection 0.29 or later and nikic/php-parser 5. Works with Infection installed through Composer or as its PHAR.

Documentation: php-regex.com — the Infection guide walks the mutators end to end.

Features

  • Six mutators, one per family of regex fault: RegexQuantifier, RegexAnchor, RegexCharClass, RegexAlternation, RegexFlag, RegexEscape
  • A mutant the automata prove equivalent for the call is never produced: /a+/ → /a/ is equivalent for preg_match($re, $s), which only says whether a match exists, and kept for preg_match($re, $s, $m), which writes the match
  • Each mutant that survives says what kills it: (?#kill: "0\x0a") before the closing delimiter, a PHP double-quoted literal, checked against the running engine for the call as written — its offset, flags, limit and replacement included
  • Mutants the engine refuses, mutants whose backtracking the ReDoS analysis proves exponential where the original's is not, and mutants alike another one already produced are left out
  • Deterministic: the work per pattern is counted in steps, never in time, so the same code gets the same mutants on every machine
  • No configuration: the mutators take Infection's own ignore and ignoreSourceCodeByRegex settings

Installation

composer require --dev php-regex/regex-infection

The package brings php-regex/regex-parser, regex-automata, regex-redos and regex-generator with it: the parser, the automata that prove two patterns alike, the ReDoS analysis, and the sample generator that looks for a kill string where the automata cannot.

Usage

Enable the mutators in infection.json5, and turn off Infection's own regex mutators, which this package replaces:

{
    "mutators": {
        "@default": true,
        "PregMatchRemoveCaret": false,
        "PregMatchRemoveDollar": false,
        "PregMatchRemoveFlags": false,
        "PHPRegex\\Infection\\Mutator\\RegexQuantifier": true,
        "PHPRegex\\Infection\\Mutator\\RegexAnchor": true,
        "PHPRegex\\Infection\\Mutator\\RegexCharClass": true,
        "PHPRegex\\Infection\\Mutator\\RegexAlternation": true,
        "PHPRegex\\Infection\\Mutator\\RegexFlag": true,
        "PHPRegex\\Infection\\Mutator\\RegexEscape": true
    }
}

PregMatchRemoveFlags removes a modifier the pattern may not use — /^\d+$/i without i matches the same strings — which leaves a mutant no test can kill.

An escaped mutant then reads:

- if (1 === preg_match('/^\d+$/', $id)) {
+ if (1 === preg_match('/^\d+\z(?#kill: "0\x0a")/', $id)) {

"0\x0a" is "0\n": $ matches before a final newline, \z does not. A test that passes it, and asserts the answer, kills the mutant.

The kill string

  • It is a subject for the preg_*() call as written — what reaches the call, not what reaches your function: a function that trims its input first needs a test that passes something the trim leaves.
  • It is a PHP double-quoted literal: $, \, ", the parentheses, the braces, # and the delimiters are written \xNN, as is every byte outside printable ASCII. It pastes into a test as is.
  • preg_match_all() without $matches gets the subject on which the count differs; preg_replace_callback() the one on which the callback receives other matches, as the call's flags shape them, or is called at other places; preg_grep() the element one pattern keeps and the other does not.
  • It is left out when the literal would be longer than 120 bytes, when the call's offset, flags, limit or replacement is only known at run time, or a number is written as a string, when the pattern's delimiter is ?, : or " or the pattern ends inside an open \Q, when neither the automata nor the sampled subjects tell the two patterns apart, and once the checks a pattern gets are spent.
  • With the # delimiter, which (?# would end, it is written (?(DEFINE)kill: "…"), every byte but letters, digits and spaces as \xNN.

What is read

A string literal written as the pattern of a native preg_match(), preg_match_all(), preg_replace(), preg_replace_callback(), preg_split(), preg_grep() or preg_filter() call, by position or as pattern:, inside a function — Infection mutates nothing else. A pattern in a constant, a variable, a concatenation or an array, Safe\preg_match(), Composer\Pcre\Preg, Laravel's regex: rule and the #[Assert\Regex] constraint are not read; a pattern PHPRegex cannot read is skipped without a word — regex lint shows which.

Two mutators of another package with the same short name would replace one another: Infection keys mutators by getName().

Documentation

Resources

Sponsors

Sponsor

If PHPRegex saves you time, consider sponsoring its maintenance.

License

MIT. See LICENSE.