Skip to content

Consolidation pass: parser internals, LSP, CI, tests - #153

Merged
yoeunes merged 59 commits into
mainfrom
consolidation
Sep 2, 2026
Merged

yoeunes merged 59 commits into
mainfrom
consolidation

Conversation

@yoeunes

@yoeunes yoeunes commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator
Q A
Bug fix? yes
New feature? no
BC Break? yes
Deprecations? no
Fixed tickets -
License MIT

A consolidation pass over the whole library: correctness fixes first, then
the structural work the fixes made possible. 58 commits, each one green on
its own.

Fixed

  • The AST file cache never worked: the payload embedded its version unquoted,
    so every file it wrote was a syntax error, swallowed on load and counted as
    a hit.
  • (?(R)yes|no) recompiled to (?((?R))yes|no), which PCRE refuses.
  • A duplicate group name was reported at the wrong offset when an alternation
    or an inline flag group came before it.
  • The language server counted columns in bytes where the protocol counts
    UTF-16 units, spun at full speed once the editor disconnected, and died on
    anything a handler threw.
  • The solver answered that /a^b/ matches the same strings as /ab/.
  • Transpiling to Python dropped every flag and corrupted patterns holding a
    quote.
  • \N{U+0041} was refused inside a character class, which PCRE accepts.
  • Rector had been failing at configuration load since the PHPUnit set it named
    was dropped upstream.

Performance

  • Reading \w, \s or \d under /u no longer walks 1.1M code points one
    at a time (1.7s to 0.8s); case folding a wide class went from seconds to
    milliseconds.
  • The compatibility aliases are resolved on demand: 27 classes were loaded
    into every process that autoloaded the package, now none are.

Structure

  • Parser.php from 2520 to 1883 lines. Position arithmetic replaced by the
    span each token now carries; code point reading, group names, inline flags,
    PCRE verbs and version conditions moved to readers of their own; the token
    cursor moved onto TokenStream.
  • The two bridge console formatters, the five copies of the sample escaper and
    the transpiler targets share their common parts.
  • Dead surface removed: UnicodeNode and the 25 visitUnicode methods it
    required, ReDoSAnalyzerInterface.

Tests and CI

  • CI runs the whole suite; functional, regression and documentation tests had
    never run there. Mutation testing runs on the lines a pull request touches.
  • 228 tests that asserted nothing now assert something: every PCRE construct is
    parsed and written back out, generated samples are checked against their
    pattern, token streams are spelled out.
  • Each of the 32 lint rules has an example and a counter-example, and a test
    refuses a new rule without one.
  • The corpus expectations are a committed fixture instead of being derived from
    the linter's own output.
  • 6616 tests, task lint green.

Breaking changes are listed in UPGRADING.md. Linting the 12 929 corpus patterns
produces a byte-identical report.

yoeunes added 30 commits August 24, 2026 16:11
corpus/corpus.json records the URL and branch of every corpus repository so
a fresh clone can restore them. corpus/update now clones the missing entries
before pulling, and gains --add, --clone-only, --no-clone and
--write-manifest.
Line and pattern churn from upstream projects only; no lint rule output
changed for patterns present in both runs.
addcslashes escaped every byte above 0x7E, so a pattern such as
/《붉은별》/iu was reported as /\343\200\212…/iu. Under /u that is a
different pattern — \343 is ã, which has a case — and it could not be
pasted back into PHP. DisplayEscaper now escapes control bytes only,
falling back to full byte escaping when the text is not valid UTF-8.

Inline flag diagnostics also claimed a flag was "already set globally"
when an earlier inline flag group had set it.
Adds TextFormatter, smarty, WhichBrowser/Parser, minify, highlight.php,
browscap-php, php-markdown, html5-php, html-sanitizer and
mail-mime-parser: 67 new findings, no diagnostic change on the patterns
already covered. Non-ASCII patterns are now logged verbatim.
json_encode() rejects invalid UTF-8, so linting a codebase holding a
byte-mode pattern aborted the whole JSON report with "Failed to encode
JSON". The checkstyle and JUnit formatters were quieter but no better:
htmlspecialchars() returns an empty string for such values, so the
pattern simply vanished from the report.

Both now go through DisplayEscaper, like the console output already did.
Well-known vendors and regex-heavy code: Google (api-php-client, gax,
site-kit), Automattic Jetpack, Yoast SEO, Azure, Shopify, SendGrid,
Mailgun, Algolia, PayPal, GitLab, Slack, MediaWiki, moodle, Mautic,
roundcube, phpMyAdmin, silverstripe, craftcms, statamic, grav,
concrete, pimcore, ProcessWire, sabre dav/vobject, TCPDF, html2pdf,
pdfparser, laminas filter/validator, simplepie, spyc, jBBCode, zxcvbn.

530 repositories, 726 new findings, no diagnostic change on the 1296
patterns already covered.
Two patterns PCRE compiles were rejected, both found by linting the
corpus:

- the validator refused the negation of the POSIX class "word" only,
  while PCRE negates every class it supports;
- the lexer is not aware of /x, so the '[' of a comment such as
  "# match $type not containing whitespace : [ or ]" opened a character
  class that never closed. Comments are now consumed as literals up to
  the end of the line, the way PCRE reads them.
Only the pattern-level "x" modifier turned on extended mode, so "(?x)"
and "(?x:...)" left whitespace significant and "#" literal — and a "["
in what should have been a comment opened a character class.

The lexer now follows the modifier through the group structure, the
parser skips ignorable whitespace and builds comment nodes accordingly,
and the compiler renders a comment as "# ..." and keeps "\ " escaped
whenever extended mode is in force at that point rather than only when
the whole pattern carries the flag.

Scoping matches PCRE: a bare "(?x)" holds until the end of the
enclosing group and crosses "|", while "(?x:...)" ends with its group.
The AST holds no node for the whitespace that /x ignores, so compiling
a parsed pattern back collapsed "/  a  b  /x" to "/ab/x" and flattened
documented multi-line patterns onto a single line.

RegexNode now carries the body it was parsed from, and the compiler
reads the gaps between nodes back from it — only when they hold nothing
but whitespace, so the nodes stay the single source of truth for what a
pattern matches. An AST built by hand has no source and compiles as
before, and pretty-printing still reflows on purpose.
Escaping punctuation is optional in most places, a backreference has
several syntaxes and a code point can be written as an escape or as
itself. The compiler picked one form for each, so recompiling a parsed
pattern rewrote code nobody asked to have rewritten — "(?P=name)" came
back as "\k<name>", "[@\[\]]" as "[@[\]]", "«" as "\xC2\xAB".

Literals, character literals, backreferences and the opening bracket of
a character class now keep the source text when it is available, guarded
by a check that both spellings mean the same thing, so a stale offset
can never change what a pattern matches. Text inside \Q...\E is left
alone: the quoting is dropped on the way out, so those literals still
need escaping. Conditionals no longer add parentheses around an
assertion condition.

Pattern comparison keeps using the normalized form through the new
preserveSpelling flag, so the optimizer still reports the same
suggestions.
The docblocks of the promoted constructor parameters kept the indentation
they had before the constructor was expanded over several lines.
The cached payload embedded the cache version unquoted, so every file it
wrote held "if (Regex::CACHE_VERSION !== 1.3.0)" — a syntax error. Loading
one raised a ParseError that the filesystem cache swallowed, so the cache
never gave anything back while still counting the read as a hit.

The version is now written as a literal, a load that comes back empty
counts as a miss, and the cache version is bumped: the AST nodes gained
properties since 1.3.0, and entries written by that release would be
restored with those properties uninitialized.
CI ran tests/Unit and tests/Integration only, so the functional, regression
and documentation tests were never executed by anything but a developer
running the suite by hand. The tests job now runs the phpunit configuration
as it is, which covers the whole tests/ tree.

Mutation testing gets a job of its own. Running it over the whole source
tree would take hours, so it mutates the lines a pull request touches and
holds them to the thresholds that infection.json.dist already declared.
Columns were counted in bytes while the protocol counts UTF-16 code units,
so every diagnostic on a line holding an accent or an emoji landed a few
characters off. They are now converted both ways, with a byte fallback for
a line that is not valid UTF-8, and the line lookup bisects instead of
walking the file for each position.

Reading the body of a message only gave up when fread() returned false. A
closed connection returns an empty string instead, so a disconnected editor
left the server spinning at full speed.

Nothing caught what a handler threw, so a single pattern hitting a
recursion or resource limit took the whole session down. A failed request
is now answered with an internal error and the loop carries on.

The messages are read from an injected stream and the responses are written
to one, which is what lets the tests drive a session and read its answers
back. The handshake also stops advertising a version of its own.
Anchors compiled to an epsilon transition whatever their place in the
pattern. At the edges of an alternative that is right — a whole-string
match starts at the start and ends at the end — but in the middle it is
not: "/a^b/" matches nothing, and the solver answered that it was
equivalent to "/ab/".

The placement check that partial match mode already ran now also guards
full match mode, so a pattern whose anchors carry meaning is refused
instead of being answered wrongly.
Two paths outside the state budgets went through all 1 114 112 code points.
Reading "\w", "\s" or "\d" under /u ran a preg_match per code point, and
folding the case of a class ran two mb_* calls and a union per code point it
covered, so a wide class cost seconds and no budget ever noticed.

The classes are now matched a block at a time, with the block encoded in one
conversion instead of code point by code point. Case folding walks the few
thousand code points that have a mapping at all, so folding "[\x{0}-\x{10FFFF}]"
costs what folding "[a]" costs.

Reading "\w" under /u drops from 1.7s to 0.8s, and a wide case-insensitive
class from seconds to milliseconds.
Python has no regex literal, so formatLiteral() returned the pattern as a
raw string — and dropped every flag on the way: "/foo/i" came back as
r'foo', matching case-sensitively. The flags are now spelled inline, at the
start of the pattern, which is where Python accepts them.

A quote inside the pattern was escaped with a backslash, but a raw string
keeps that backslash, so the pattern changed. The delimiter is chosen to
suit the pattern instead, and one holding both quotes falls back to an
ordinary string with its backslashes doubled.
The curl and wget fallbacks ran without any timeout flag, so an unreachable
mirror left the updater hanging until someone killed it. Both now give up on
connecting after ten seconds and on the transfer after thirty, which is what
the stream context already did.
The Laravel and Symfony console formatters were the same 347 lines twice
over: after renaming, the two files differed by their namespace, their class
name and their docblock. Laravel builds its console on Symfony's, so they
render a report identically and always will.

The rendering moves to a shared abstract formatter and each bridge keeps the
name it exposes.
Five commands and formatters carried the same byte-escaping loop to show a
counterexample: quote it, write the control bytes as escapes, say so in
words when it is empty. It now lives next to the other display escaping,
with the console markup passed in by the caller.
The two compiler visitors write very different dialects, but they refuse an
unsupported construct the same way and strip the whitespace /x allows inside
a quantifier the same way, each with its own copy.

Both now extend a base that holds the context they compile against and those
two behaviours, which is also where a third target would start.
No path ever produced a UnicodeNode: a "\x{...}" or "\u{...}" escape becomes
a CharLiteralNode, and has for as long as the parser has existed. The node
still sat in NodeVisitorInterface, so twenty-five visitors carried a
visitUnicode() method that could not be called, several lint rules branched
on a type they could not see, and two test classes existed only to reach
those branches.

ReDoSAnalyzerInterface goes with it: nothing implemented it, not even
ReDoSAnalyzer, which has the same signature.

The removals are listed in UPGRADING.md. The cache version stays at 1.4.0,
which is not released yet and already covers this shape change.
The lexer cached its two compiled token regexes under a key mixing the byte
mode with the PHP version, but the patterns it compiles are constants and
only the byte mode changes what comes out. A process reading patterns for
several PHP versions compiled the same two regexes again for each of them.

Nothing else in the lexer read the version, so the constructor argument goes
with the key. Choosing a PHP version explicitly still reaches the parser,
which is what decides the modifiers a pattern may carry.
Sixteen places read a key their own array shape declares as always present
and fell back to null "just in case", which static analysis has been
reporting as dead for a while.

Two of them were not dead: a test built a lint result without its pattern
key, and another passed one array level too many to a private method through
reflection. The fallbacks were hiding malformed test data rather than
guarding real input, so the fixtures are fixed and the fallbacks are gone.
analyze() ran four subsystems and caught \Throwable around each of them,
writing whatever came out into the report as an error on the pattern. A
TypeError or a broken collaborator therefore came back as "your regex is
invalid", and no report could ever point at a bug in the library.

Each block now catches the exceptions RegexParser raises for a pattern it
cannot handle, and nothing else.

That leaves a cache that throws while being read. Writing to a cache was
already allowed to fail silently; reading now is too, since parsing the
pattern again is always an option and a broken cache is never the pattern's
fault.
The help command carried its own copy of the command table, and it had
already drifted: three commands were missing from it altogether, and four
descriptions no longer matched what the command itself says. "regex help
graph" answered "Unknown command".

The application now hands the help what it has registered, so the summary,
the list of available commands and each page take their name and their
description from the command. The pages for graph, clear-cache and version
are written, and a test walks every registered command to keep the two in
step.

The wiring the binary carried moves to a factory, which is what lets that
test build the real application.
The compatibility file ran class_alias() over twenty-five classes as soon as
the package was autoloaded, and class_alias() loads its target: every process
using the library paid for the whole automata layer, whether it compared two
languages or only parsed a pattern. Twenty-seven RegexParser classes were
loaded before a line of user code ran; now none are.

The old names are aliased by an autoloader that answers for them, so they
resolve to the same class as before, on the first mention rather than
up front.
Fifty-five tests parsed a pattern and asserted nothing: they caught a crash
and nothing else, so a parser that quietly dropped a construct passed them
all. They are now one case per construct, each asserting what the parser
read by writing the pattern back out, and — for the patterns PCRE accepts —
that what comes out still compiles.

Nine of them do not come back exactly as they went in: "(?P'name'...)",
"\g{name}" and the conditionals around an assertion are written back in
another spelling. The expected output records that rather than hiding it.

What is left of the old file is the half that always asserted: the syntax
the parser must refuse, and the places where it reads ahead and rewinds. It
is named for that now.
"(?(R)yes|no)" asks whether the pattern is currently recursing. The parser
reads the condition as a subroutine reference, and the compiler wrote it
back as a subroutine call — "(?((?R))yes|no)" — which PCRE refuses. Any
round trip through the compiler therefore broke such a pattern.

Found while giving the assertion-free tests something to assert.
Two hundred and twenty-eight tests ran a pattern through the parser, the
validator or the sample generator and asserted nothing at all. They were
green whatever the library returned, as long as it did not throw.

The patterns they covered are kept, with an assertion each:

  - every construct is parsed and written back out, and what comes back is
    compared to the pattern that went in — three hundred and fifteen cases,
    each recording the spelling the compiler produces;
  - the patterns the validator must accept say so, instead of running the
    validator and ignoring it;
  - a generated sample is now checked against the pattern it came from,
    eight times over, since the generator picks its characters at random.

The files whose only content was assertion-free are gone; the error cases
that lived among them move to the parser's rejection tests.
Twenty-three lexer tests asserted that tokenizing produced at least one
token, which is true of every input, empty ones included. They are now one
case per input, each spelling out the tokens that come out.

Writing them down records something worth knowing: the lexer folds the
negation of a unicode property into the token value, so "\P{L}" and "\p{^L}"
are the same token by the time the parser sees them.

The three remaining "at least one of something" assertions elsewhere are
replaced by what the test was actually after, or dropped where the line
above already compared the whole result.
The twenty-eight rules were only exercised through the linter as a whole and
through the corpus, which counts issues per project rather than naming them.
A rule that stopped firing, or started firing on sound patterns, would have
gone unnoticed.

Each of the thirty-two rule ids now has a pattern it must report and a close
one it must leave alone — "[a-a]" against "[a-f]", "(a+)+" against
"(?>a+)+". A last test compares the table with the registry, so a new rule
cannot be registered without an example.
The corpus test read its expectations out of the rendered report, then ran
two hundred lines of heuristics to decide which of the rendered warnings a
bare pattern still deserved — re-implementing the rules it was testing. A
rule could not disagree with itself, and a pattern the reconstruction had
mangled was skipped in silence.

The reconstruction now happens once, in a generator, and what it produces is
committed: a fixture saying which rules each of the 1493 patterns raises.
The test compares the whole list rather than checking that a few expected
ids are among the reported ones, so a rule that starts firing where it did
not is caught too.

A pattern PCRE accepts but the parser rejects stops the generator instead of
being skipped by the test.
rector.php asked for PHPUnitSetList::PHPUNIT_110, which the installed
rector-phpunit no longer defines: the tool stopped at configuration load,
so every "task lint" run and every CI job failed before analysing a line —
and had been failing since the package dropped its per-version sets.

COMPOSER_BASED replaces it: it reads the installed PHPUnit and applies what
suits it. The changes Rector had not been able to make in the meantime come
with this — mostly assignments rewritten as "??=".
The projects moved: two patterns are gone, five are new, and a good number
of the others changed line. Nothing the linter says about a pattern present
in both runs changed — same warnings, same optimization suggestions — so
this is upstream churn only.

The lint expectations are regenerated with it, since they are read from the
log.
The lexer rewrites what it reads: it strips the backslash off "\d", reads
"\P{Greek}" back as "{^Greek}", keeps only the name inside "\N{...}". The
value of a token is therefore not the text it was cut from, and everyone
who needed that text — the parser, for its error positions and for the
comments it rebuilds — was left to guess it from the value.

A token now carries the length of its span, and end() gives the offset just
past it. Nothing reads it yet; the next commits replace the guessing with it.

The invariant that makes it worth trusting is asserted: the tokens of a
pattern tile it exactly, checked over the 1496 patterns of the corpus.
Rebuilding the text of a comment from the tokens inside it meant a table of
twenty-eight cases guessing how each token had been spelled: put the
backslash back on this one, wrap that one in braces, leave this other alone.
It could only ever approximate — a "\d" inside a comment is a backslash and
a letter, not a character type, and the table had no way to tell.

Tokens carry their span now, so the text is read from the pattern. The
table goes, and with it the two tests that existed to walk every one of its
arms; what they were standing in for — a comment keeping the text it was
written with — is asserted directly.
Every atom computed where it ended by adding lengths together: the value,
plus one for a backslash the lexer had eaten, plus one more for the letter
after it. Unicode properties needed a correction on top of that — minus one
here, plus one there, depending on whether the pattern had spelled the
negation as "\P" or as "^" — written twice, once for the pattern and once
for the inside of a character class.

The token knows where it ends, so it is asked.

Two state flags went with it. They existed to shift the position of a
duplicate group name backwards by the length of whatever came before, and
they were shifting it onto the wrong character: "/(?<name>a)|(?<name>b)/"
blamed the "|", and "/(?J)(?<name>a)(?-J)(?<name>b)/" blamed a byte in the
middle of "(?-J)". The caret now points at the group that repeats the name.
Six methods of the parser did nothing with the pattern around them: given
"\x41", "\u{1F600}", "\o{101}", "\101", "\N{LATIN SMALL LETTER A}" or the
letter after a "\c", they answered which character it names. One of them
reaches for intl, which is a surprising thing to find in a recursive-descent
parser.

They move to a reader of their own, with a name for the -1 they return when
a spelling cannot be read — the escape keeps its text in the tree and the
validator decides whether that is an error.

The three tests that reached the private methods through reflection are
replaced by tests of the reader itself, covering the forms they never did.
Two chains of a dozen "if the next token is this type, build that node" sat
side by side, one for the pattern and one for the inside of a character
class, agreeing on nine of the token types and building the same nodes from
them twice.

They share a table now, and each context adds what only it accepts: a dot, a
subroutine call or a "\K" outside, a nested class, a stray "-" or a POSIX
class inside. An anchor, an assertion and a backreference stay outside-only,
since a class turns those into something else and a class holding one has
been built by hand.

Sharing the table settles a disagreement the two chains had: "\N{U+0041}"
was read outside a class and refused inside one, though PCRE takes it in
both places.
The parser carried ten methods for moving through the tokens — check, match,
advance, previous, consume and their literal-matching cousins — plus a
three-field cache of the current token, guarding an array index behind a
position comparison and an invalidation flag that four other places had to
remember to clear.

They belong to the stream. TokenStream is a cursor now, the cache is gone —
the array index it was standing in for is the cache — and the parser reads
the tokens through it.

The reflection tests that reached those private methods become tests of the
stream, which needs no reflection to be asked what it does at the end of the
input.
PCRE spells the name of a group four ways — "(?<name>", "(?'name'",
"(?P<name>", "(?P=name" — and Python patterns bring double quotes along. One
method read all of them, another kept the names already used, and a flag on
the parser said whether "J" currently allowed two groups to share one.

The three move together: a reader holds the names it has seen and answers
whether duplicates are allowed, which is the only reason it needed to know
about "J" at all.

The two tests that reached the private method through reflection become
tests of the reader, and cover what they did not: a quote left open, a name
starting with a digit, a reference that must not claim the name it reads.
Fifty-three test files were named for the metric they were written to move:
CoverageTest, CompleteCoverageTest, Complete100PercentCoverageTest,
FinalCoverageBoostTest. Nothing in a name said what the file held, and eight
of them held a little of everything — two hundred and thirty-seven cases
spread across the lexer, the parser and seven visitors, grouped by nothing.

The cases are kept and regrouped by what they exercise: a sweep per subject
under tests/Integration/Sweep. The rest are renamed for their subject, and
the five small lexer files become one.

One case is dropped rather than moved: it ran eighteen patterns through the
validator inside a try/catch that ignored the result.
The Symfony bridge had a Severity that meant pass, warn, fail or critical —
the outcome of a check — sitting beside the library's Severity, which grades
a lint issue. And an AnalysisReport of sections and issues, beside the
AnalysisReport that Regex::analyze() returns, with nothing in common.

Reading either file meant checking which import was in scope. They are
CheckOutcome and SecurityReport now, which is what they hold.
Two classes find regex patterns in PHP source — one reads the tokens, the
other builds a syntax tree — and they sat apart in the lint namespace with
the interface they share. They are together now, under Lint\Extraction.

The second was called PhpStanExtractionStrategy though it has nothing to do
with PHPStan: it uses nikic/php-parser, which PHPStan happens to bring
along. It is PhpParserExtractionStrategy.

The old names still resolve. The test overrides for the functions these
classes call are mirrored into the new namespace, since a function override
only reaches the namespace it is declared in.
Rector cannot resolve a PHPUnit assertion called statically through self::
and stops on the file. The three tests written that way call them on $this
like every other test in the suite.
"(?im-sx)" says what it turns on and what it turns off, and "(?^im)" turns
off everything it does not list. Three files knew that separately: the lexer,
to decide where /x starts; the parser, to build the group and to refuse a
modifier set and unset at once; and the compiler, to write the group back
out.

They share a small value object now. It answers the three questions each of
them asked in its own way — what does this turn on, is this modifier in
force inside the group, and what do the modifiers look like once applied.
"(?(VERSION>=10.4)yes|no)" branches on the version of PCRE reading the
pattern. Working out what it asks is string work — the word, the comparison,
the digits between the dots — and it was spread through fifty lines of
stream walking, rewinding three separate times when the text turned out to
say something else.

The reading moves to a value object that takes the collected word. The
parser keeps the walking and the one rewind it needs.
Most of these are backtracking verbs, but PCRE spells three other things the
same way: the alphabetic form of a lookaround, a script run, and the match
limit. Deciding which is which is string work on the text between the
parentheses — a table of nine names, two prefixes, one number to read, and
the shorthand that turns "(*:name)" into a mark.

It moves to a reader. The parser keeps what needs it: parsing the payload of
an assertion or a script run, which only it can do.
"(?P'name'...)" had its own reader: its own loop over the tokens, its own
three error messages, and no check that the name reads like a name or that
it is not already taken. "(?'name'...)", which PCRE treats identically, went
through the shared reader all along.

Both go through it now. A repeated name and a name starting with a digit are
refused where they were accepted, which is what PCRE does with them.
Nine blocks read the body of a group, expected the closing parenthesis and
built a node from it — the same three lines each time, differing only in
which kind of group they named. A method for exactly that already existed
and five of the nine used it.

They all do now, and the two tables that say which character introduces
which lookaround replace four more copies. Naming a group goes the same way:
the four spellings PCRE accepts share one reader instead of three.
Six readers answered "not this kind of condition" with false, so the caller
tested each of them with its own if and its own variable — thirty lines of
scaffolding for a chain of six attempts.

They answer with null, and the chain is a chain.

The two guards that checked for a "D" or a "V" before calling the reader are
gone with it: each reader already collects its word and rewinds when the
word is not the one it wanted.
Reading a "(?...)" modifier group did three things at once: collect the
letters, work out what they mean, and build the group. The collecting is its
own step now — it has to start by picking up a "^" that reached the parser
as an anchor token, which is worth a name and a sentence.

The same for the quantifier that may follow a quoted run: it repeats the
last quoted character rather than the run, which nine lines in the middle of
the sequence loop expressed without ever saying so.

And the range endpoints of a character class are checked by one guard
instead of two copies of the same message.
The three tests written for the new readers used self:: for their
assertions, which Rector cannot resolve; it stops on the file rather than
analysing it.
Infection belongs on a developer's machine, where it can be pointed at the
code being worked on. Running it on the lines a pull request touches makes
every review wait on it, and a wide pull request gives it far too much to
mutate.

"composer infection" and "task infection" still run it locally, with the
thresholds infection.json.dist declares.
@yoeunes
yoeunes merged commit a2f3b3a into main Sep 2, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant