Skip to content

Quality infrastructure: Infection, PhpBench, PHPStan level 8#53

Merged
mmucklo merged 1 commit intomasterfrom
quality/infection-phpbench-phpstan8
Apr 13, 2026
Merged

Quality infrastructure: Infection, PhpBench, PHPStan level 8#53
mmucklo merged 1 commit intomasterfrom
quality/infection-phpbench-phpstan8

Conversation

@mmucklo
Copy link
Copy Markdown
Owner

@mmucklo mmucklo commented Apr 13, 2026

Summary

Delivers the three quality-infrastructure items from the ROADMAP (A, B, C), plus a short fork survey that found nothing worth back-porting.

Everything in this PR is non-behavioral — no user-visible changes to the parser or public API. Four internal nullable-return guards were added in Parse.php to satisfy PHPStan level 8; they preserve existing behavior for well-formed input and emit a clean error for the previously-silent edge cases.

Fork survey (preamble)

13 forks total; 7 had commits ahead of upstream. Every ahead-commit inspected. Nothing to back-port:

  • Tooling-only forks (compwright, ranqiangjun, Nilead, formatz, rmitvn) — superseded by current master.
  • ArthurHoaro (2019): added accented-character handling in display names. Upstream v2.1 / v3.0 supersede this via allowUtf8LocalPart — same inputs now parse identically in legacy mode and are correctly rejected under rfc5321().
  • KMK-ONLINE (2014): defensive isset($commentNestLevel) guard. Unreachable in current code; variable is always initialized at the top of parse().

Interesting finding: no fork attempted RFC 5322 / 5321 / 6531 compliance work before v3.0. The v3.x architecture is genuinely new territory for this library.

Infection (mutation testing)

Baseline: 74% MSI / 79% covered MSI / 94% mutation code coverage across 1122 mutants.

  • composer require --dev infection/infection
  • infection.json5 with the @default mutator set, text + summary logs
  • composer infect runs the full suite; CI script included
  • Thresholds pinned to the current baseline (minMsi=74, minCoveredMsi=79) so future regressions fail

Targeted test strengthening (wins encoded in the diff):

Fix Mutants killed
ParseErrorCode::severity() explicit mapping per code 13 MatchArmRemoval
LengthLimits exact defaults (64/254/63 and 128/512/128) 7 integer mutants
toJson JSON_UNESCAPED_UNICODE + flag pass-through assertions 4+ BitwiseOr mutants

85% MSI target remains aspirational; tracked as a partial ([~]) item in ROADMAP. Raise thresholds as the suite improves.

PhpBench (performance baseline)

  • composer require --dev phpbench/phpbench
  • phpbench.json config; benchmarks/ParseBench.php with 10 subjects
  • composer bench runs the suite with the default PhpBench report
  • autoload-dev gains Email\Benchmarks\ namespace

Subjects covered (smoke-run numbers for reference; real runs will vary):

Subject Purpose
benchSimpleAsciiAddress parseSingle hot path
benchSimpleAsciiAddressArrayApi legacy parse(…, false) comparison
benchNameAddr display-name + angle-addr
benchUtf8LocalPart UTF-8 without IDN
benchIdnDomain IDN punycode round-trip
benchObsRoute RFC 5322 §4.4 source-route
benchBatch10Comma multi-address array output
benchBatch100StreamCount parseStream generator throughput
benchInvalidAddress error path
benchCommentExtraction RFC 5322 comment capture

PHPStan level 6 → 8

Four issues surfaced, all fixed in-place with zero behavior change:

  1. Parse::parseMultiple() — narrow the parse() return via a local @phpstan-var so ParseResult::fromArray()'s shape requirement is satisfied.
  2. idn_to_ascii() string|false — guard the return before passing to preg_match.
  3. mb_split() list<string>|false — return a domain_invalid validation failure when it errors rather than iterating a bogus value (edge case that never fired in tests, but was undefined behavior in principle).
  4. file_get_contents() in tests/ParseTest.php — assert not-false before passing to Yaml::parse().

No new phpstan-baseline.neon entries needed.

Test plan

  • composer ci passes: cs:check, PHPStan level 8, 65 tests / 489 assertions
  • composer infect passes at new thresholds (74% MSI / 79% covered MSI)
  • composer bench runs without errors, produces numbers for all 10 subjects
  • No behavior regressions — the three nullable-return guards and the level-8 fixes preserve existing behavior for normal inputs; only previously-undefined edge cases now return clean errors

Files changed

  • New: infection.json5, phpbench.json, benchmarks/ParseBench.php
  • Modified: composer.json (2 new scripts + benchmarks namespace), phpstan.neon (level 6→8), .gitignore (Infection artifacts), src/Parse.php (3 nullable guards + 1 docblock shape), tests/ParseTest.php (strengthened assertions + 1 level-8 fix), ROADMAP.md (items flipped)

What's next (from ROADMAP)

All of A, B, C now done. Remaining quality items that aren't in this PR: Psalm cross-check, property-based tests, Parse.php line coverage 86.69% → ≥95%, PHP 8.5 CI. Then the v4.0 breaking work (trimmed to DNS/MX callback + RFC 6854 group syntax after canonical() and normalizer moved to v3.3).

Three quality/infrastructure items from the ROADMAP, plus a short fork
survey that found nothing worth back-porting.

Fork survey:
- 13 forks, 7 with commits ahead of upstream. All were inspected.
- Nothing to back-port. Upstream's v3.x configurable ParseOptions has
  superseded every fork's patches (accented-char handling via
  allowUtf8LocalPart, PHP 8 support, PSR-log versions, etc.). None of
  the forks attempted RFC compliance work — that's genuinely new
  territory that this library led with.

Infection mutation testing:
- composer require --dev infection/infection (^0.29.8).
- infection.json5 config: @default mutator set, PHPUnit test framework,
  text + summary logs, .infection-tmp working dir.
- composer infect script: XDEBUG_MODE=coverage infection --threads=max.
- Current baseline after targeted fixes: 74% MSI / 79% covered MSI /
  94% mutation code coverage across 1122 mutants. Thresholds pinned to
  the baseline (minMsi=74, minCoveredMsi=79) so future regressions fail.
- Targeted test strengthening added:
    * ParseErrorCode::severity() — was only checking "returns a
      ValidationSeverity"; now asserts each code maps to its specific
      Warning/Critical (kills 13 MatchArmRemoval mutants in one pass).
    * LengthLimits defaults — asserts the exact 64/254/63 and 128/512/128
      values for createDefault() and createRelaxed() (kills 7 integer
      mutants).
    * toJson UNESCAPED_UNICODE + flag pass-through on both
      ParsedEmailAddress and ParseResult (kills BitwiseOr mutants on
      the json_encode flag argument).
- 85% MSI target remains aspirational and is tracked in ROADMAP as a
  partial (~) item; raise thresholds as the suite improves.

PhpBench baseline:
- composer require --dev phpbench/phpbench (^1.4).
- phpbench.json config; benchmarks/ParseBench.php with 10 subjects:
  single ASCII, ASCII via legacy array API, name-addr, UTF-8 local-part,
  IDN domain, obs-route, 10-address comma batch, 100-address
  parseStream batch, invalid input, comment extraction.
- composer bench script: phpbench run --report=default.
- autoload-dev gains Email\Benchmarks\ namespace mapped to benchmarks/.
- Smoke run validates all 10 subjects run and produce numbers.

PHPStan level 6 → 8:
- Four real issues surfaced; all fixed in-place:
    * Parse::parseMultiple() — narrow the parse() return via a local
      @phpstan-var to satisfy ParseResult::fromArray()'s shape.
    * idn_to_ascii() return is string|false — guard before preg_match.
    * mb_split() return is list<string>|false — return a domain-invalid
      result when it fails rather than foreach on bogus value.
    * tests/ParseTest.php — file_get_contents() is string|false; assert
      not-false before passing to Yaml::parse().
- No new baseline entries needed; phpstan-baseline.neon unchanged.

Tooling:
- composer.json: new infect and bench scripts; autoload-dev extended
  for the benchmarks namespace.
- .gitignore: infection.log, infection-summary.log, .infection-tmp/.
- ROADMAP: Infection marked partial [~], PhpBench and PHPStan 8 marked
  [x] with implementation notes.

Tests: 65 tests / 489 assertions (up from 60 / 472 in v3.3).
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 13, 2026

Codecov Report

❌ Patch coverage is 83.33333% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 89.95%. Comparing base (084b5a3) to head (bb73a6a).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
src/Parse.php 83.33% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##             master      #53      +/-   ##
============================================
- Coverage     90.02%   89.95%   -0.07%     
- Complexity      380      382       +2     
============================================
  Files             6        6              
  Lines           982      986       +4     
============================================
+ Hits            884      887       +3     
- Misses           98       99       +1     
Files with missing lines Coverage Δ
src/Parse.php 85.92% <83.33%> (-0.07%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@mmucklo mmucklo merged commit d8e967c into master Apr 13, 2026
10 checks passed
@mmucklo mmucklo deleted the quality/infection-phpbench-phpstan8 branch April 13, 2026 08:32
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