Skip to content

4.0 — breaking modernization - #72

Open
mmucklo wants to merge 15 commits into
masterfrom
4.0
Open

4.0 — breaking modernization#72
mmucklo wants to merge 15 commits into
masterfrom
4.0

Conversation

@mmucklo

@mmucklo mmucklo commented Aug 28, 2026

Copy link
Copy Markdown
Owner

🎨 Visual review companion → — the same content as colorful before/after diagrams and charts, at high / mid / low zoom.

4.0 is a breaking-modernization release. Same parser, tighter surface: the API is modernized and the internals are typed, but a valid address parses identically — output is byte-identical, verified by the full spec corpus.

✅ 0 behavior changes ✅ 14/14 CI checks green ✅ net −39 source LOC ✅ 109 tests / 7.2k assertions

High level — what's merging

Everything sorts into four buckets. Only the first two touch callers.

flowchart LR
    B["🔴 BREAKING · 2<br/>7 setters removed<br/>validators → private"]:::brk
    D["🟠 DEPRECATED · 2<br/>parse()<br/>getInstance()"]:::dep
    I["🔵 INTERNAL · 3<br/>ParserState enum<br/>snake → camelCase<br/>readonly config"]:::int
    X["🟢 DOCS · 3<br/>UPGRADE guide<br/>ROADMAP<br/>CHANGELOG"]:::saf
    B --> R
    D --> R
    I --> R
    X --> R
    R["v4.0<br/>0 behavior changes<br/>net −39 LOC"]:::rel
    classDef brk fill:#f8e3e2,stroke:#cf3b3b,color:#7a1f1f
    classDef dep fill:#f6ecd6,stroke:#a9721a,color:#5a3d0e
    classDef int fill:#e2ecfb,stroke:#2f6bd6,color:#1a3c73
    classDef saf fill:#e0f0e7,stroke:#2c8253,color:#164a2e
    classDef rel fill:#e7e4fb,stroke:#5b4fe3,color:#2c2470,stroke-width:2px
Loading

Mid level — the public surface, before → after

API v3.9 v4.0 Impact
parseSingle / parseMultiple / parseStream primary none
ParseOptions::withX() builders ✅ unchanged none
parse(string, bool $multiple) ⚠️ @deprecated (→5.0) soft
getInstance() ⚠️ @deprecated (→5.0) soft
ParseOptions::setX() 🔴 removed breaking
validateLocalPart() / validateDomainName() protected 🔴 private breaking
STATE_* (12 consts) ints 🔵 enum ParserState: int internal
ParseOptions state fields private + setters 🟢 public readonly additive

The two breaking rows are the whole upgrade. Migration is mechanical — full table in the new v3.x → v4.0 section of UPGRADE.md:

// setters → withX() (reassign; withX returns a new instance)
$o->setBannedChars($a);      →   $o = $o->withBannedChars($a);
// deprecated methods → typed API
$parser->parse($in, true);   →   $parser->parseMultiple($in);
Parse::getInstance();        →   new Parse(null, ParseOptions::rfc5322());

Low level — the numbers GitHub's diff buries

GitHub shows +787 / −722 (1,509 lines touched) — it reads like a rewrite. It isn't:

Metric Value Note
GitHub churn +787 / −722 moves & renames counted on both sides
Net source footprint −39 LOC src/ 3,035 → 2,996 — the release removes more than it adds
snake_case → camelCase ~305 sites pure rename (output keys unchanged)
STATE_* → ParserState:: 75 sites pure rename
— setters removed 7 methods deletion
Parsing-logic changes 0 the diff is find-and-replace + removal, not new behavior

Per-file: Parse.php 792 · ParseContext.php 164 · ParseTest.php 162 · ParseOptions.php 106 · ParserState.php 37 (new) · PropertyTest.php 10.

Proof it's safe

  • Behavior: the 235-case testspec.yml corpus asserts exact output; the runner was re-pointed at parseMultiple()->toArray() and every case still matches → byte-identical.
  • CI (all green): tests on PHP 8.1–8.6, PHPStan L8, Psalm, CS Fixer, Coverage, and the Benchmarks (vs base) job — the ParserState enum lands within ±2% of master, well under the 1.5× gate (enum === is identity comparison).
  • Upgrade cost: if you use the typed methods + withX(), nothing changes; otherwise ~2 minutes of find-and-replace.

Notable / worth a look

  • Test suite now validates the typed path, not the deprecated parse() — a strict improvement that shipped for free with the deprecation.
  • parse() is a shim over a new private parseInternal() coreparseSingle/parseMultiple/parseStream call the core directly, so the deprecated method has no internal callers.
  • Immutability is now complete: every ParseOptions property is readonly; you can't keep the mutating setters and readonly, which is why removal (not just deprecation) is the right call.
  • Scope is deliberately lean — DNS/MX, confusable-target, and RFC 6854 groups are deferred to 4.1–4.3 (additive), and the deprecation removals to 5.0. See ROADMAP.md (incl. the new North Star on error identity vs. presentation).

Draft until the UPGRADE guide gets a final read and a 4.0.0-beta1 tag is cut to soak.

4.0 groundwork. The polymorphic array-returning parse() is deprecated
(removed in 5.0); the typed methods are now the entry points.

- Extract the state-machine core into a private parseInternal(); parse()
  becomes a thin @deprecated shim over it, and parseSingle/parseMultiple/
  parseStream call parseInternal directly (no longer routed through the
  deprecated method). parse() output is byte-identical.
- Rewrite the testspec runner onto parseMultiple()->toArray() /
  parseSingle()->toArray() — validates the typed path and drops the suite's
  dependency on parse(). 236 assertions unchanged.
- Docs: README (Basic Usage, ParseOptions examples, Other Examples),
  cookbook, UPGRADE, and ARCHITECTURE now use the single-purpose methods;
  parse() is presented only as the deprecated legacy shim.
- Roadmap: parse() deprecation recorded in the ledger; removal moved to a
  new v5.0 section. CHANGELOG [Unreleased] Deprecated entry added.

110 tests / 7214 assertions, PHPStan L8, Psalm, CS all green.
Completes the 3.9 deprecation: local-part validation is folded back into a
private ParseContext-based validateLocalPart() (dropping the array-shaped BC
shim and its psalm-suppress), and validateDomainName() is now private too.

Both took the parser's internal accumulator and were never a supported
extension point; validation is customized through ParseOptions. BREAKING for
any subclass that overrode them. Removed the now-moot BC-override test;
CHANGELOG Removed entry and roadmap/ledger updated.

109 tests / 7187 assertions, PHPStan L8, Psalm, CS all green.
…tInstance

- Promote the 5 ParseOptions state fields (bannedChars, separators,
  useWhitespaceAsSeparator, lengthLimits, allowedWhitespace) to public
  readonly, assigned once in the constructor. The getX() accessors remain.
- Remove the 7 @deprecated mutating setters (deprecated since v3.0). No
  call sites; configure via the constructor or withX() builders.
- Deprecate Parse::getInstance() (removed in 5.0). The static singleton
  carries process-global state and is pinned to the LEGACY preset, so it
  silently applies permissive defaults; modern PHP uses explicit
  instantiation / DI. Docs and tests switched to new Parse().
- Replace the deprecated-setters test with one covering the readonly
  properties + withX() builders. Prune 7 now-stale psalm baseline entries.
- CHANGELOG/ROADMAP/ledger updated.

109 tests / 7165 assertions, PHPStan L8, Psalm, CS all green.
Aligns the internal accumulator with the codebase's camelCase convention.
Mechanical rename of ~20 fields across Parse.php and ParseContext.php
(property declarations + all $ctx->/$this-> accesses). The public
snake_case output-array keys are string literals in addAddress() and are
untouched, so parse() / toArray() output is byte-identical.

109 tests / 7183 assertions (testspec output unchanged), PHPStan L8,
Psalm, CS all green.
Encodes the context's three concerns structurally: the input snapshot
(chars/len/multiple/emails) and hoisted config (separators/bannedChars/
useWhitespaceAsSeparator/allowedWhitespace) are now public readonly
constructor-promoted properties, so a state handler can no longer mutate
config; only the per-address accumulator stays mutable.

parseInternal() now builds chars/len/config before constructing the
context and passes them in (they were previously assigned after `new`).
$chars/$len are still kept as loop locals for the hot counter.

109 tests / 7186 assertions, PHPStan L8, Psalm, CS all green.
Introduces src/ParserState.php (backed int enum, values matching the former
Parse::STATE_* constants) and types ParseContext::$state/$subState as
ParserState. The parser can no longer hold an out-of-range state, and the
old "subState 0 default is not a valid start" caveat is gone — an
un-initialized enum property is a type error, not a silent wrong value.

Mechanical: 75 self::STATE_X -> ParserState::X, the dispatch switch and the
in_array() state check now use enum cases, and the one state-interpolating
log line uses ->name. Behavior-preserving.

Perf: enum === is identity comparison (≈ int), so no hot-loop regression is
expected; verified by the CI "Benchmarks (vs base)" job (≤1.5x base). Local
phpbench is unreliable here (Xdebug + opcache.enable_cli=0 time it out).

109 tests / 7208 assertions, PHPStan L8, Psalm, CS all green.
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.17426% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.95%. Comparing base (6d4a628) to head (e6f81f5).

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

Impacted file tree graph

@@             Coverage Diff              @@
##             master      #72      +/-   ##
============================================
+ Coverage     95.84%   96.95%   +1.10%     
+ Complexity      446      438       -8     
============================================
  Files             7        7              
  Lines          1108     1084      -24     
============================================
- Hits           1062     1051      -11     
+ Misses           46       33      -13     
Files with missing lines Coverage Δ
src/ParseContext.php 100.00% <100.00%> (ø)
src/ParseOptions.php 100.00% <100.00%> (+0.96%) ⬆️
src/Parse.php 95.62% <94.73%> (+1.43%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

parse() has no internal callers now (the typed methods use parseInternal()
directly), so Psalm's findUnusedCode flags the public deprecated shim. It's
still called by external code until its 5.0 removal. (Local runs passed on a
stale cache; CI runs fresh.)
UPGRADE.md: new v3.x -> v4.0 section — breaking changes (removed ParseOptions
setters with a setter -> withX() migration table; validateLocalPart/
validateDomainName now private), the parse()/getInstance() deprecations, the
readonly state fields, and a no-action note for the internal changes
(ParserState enum, ParseContext modernization).

ROADMAP.md: 4.0 is now explicitly lean (breaking cleanup + internal
modernization only). Moved the deferred features out: DNS/MX -> v4.1,
confusable-against-target-list -> v4.2 (both additive), and RFC 6854 group
syntax -> v5.0 (breaking: new group-node result shape).
…keystone

Encodes the strategy connecting three longer-horizon goals — PHP framework
integration, localized error messages, and ports to other languages — which
all converge on decoupling error identity (ParseErrorCode + named parameters)
from presentation (a rendered, localizable string).

- New "Strategic direction (North Star)" section.
- v4.1 is now the structured-errors keystone: messageParameters on
  ParsedEmailAddress + a swappable MessageProvider interface (sketched),
  additive so invalid_reason stays English by default; and making testspec.yml
  code+params-normative for ports.
- DNS/MX -> v4.2, confusable-target -> v4.3. RFC 6854 groups reclassified as
  additive (Option B: flat emailAddresses + a groups() view) -> 4.3, off 5.0.
Moving the suite off the deprecated parse()/getInstance() and removing the
setter test left a few methods with no caller. Cover them, and add reachable
branch tests:

- getInstance() — deprecated but public until 5.0; assert the shared instance.
- ParseOptions::getMaxLocalPartLength() and the 5 fluent builders the toggle
  test was missing (withAllowObsRoute, withTrimSingleAddressWhitespace,
  withStrictMultiWhitespace, withRejectTrailingDot, withDetectConfusableDomain).
- Quoted UTF-8 local part under rfc5321 -> Utf8NotAllowedInLocalPart
  (validateLocalPart's UTF-8 gate).
- Quoted local part re-quoted after a normalizer rewrites it.

Every method in Parse and ParseOptions is now covered. Overall lines
95.49% -> 96.18%, methods 87.78% -> 91.11%.
The "coverage decreased" was measurement noise, not a real regression:
PropertyTest fuzzes from a time-based seed by default, so the coverage job's
line count (and the Codecov delta) jittered run-to-run — adding tests could
even show a lower number. Two seeded coverage runs of identical code differed
by ~0.6%.

- Pin SEED=12345 for the coverage job so the measured number is deterministic
  and comparable (this seed lands at 96.18%, above the prior ~95.84% base).
  The 8.1-8.6 matrix jobs stay unseeded so they keep fuzzing.
- Add two behavioral branch tests (mixed quoted/unquoted display name; NFC
  normalization of an unquoted local part under rfc6531).

Coverage is now reproducible; overall ~96.18% lines, every Parse/ParseOptions
method covered. 114 tests / 7234 assertions, PHPStan L8, Psalm, CS green.
Push coverage after the deterministic-seed fix:
- Add behavioral edge tests (mid-string quoted display-name word, etc.).
- Mark the two provably-unreachable defensive blocks @codeCoverageIgnore:
  the switch `default:` case (impossible now that $ctx->state is a ParserState
  enum with every case handled) and the ParserConfusion branch (a 500k-input
  fuzz confirmed it's dead). These are excluded from the denominator rather
  than faked with contrived internal-state tests.

Overall line coverage 96.18% -> 97.13% (deterministic, SEED=12345). PHPStan
L8, Psalm, CS green.
@mmucklo
mmucklo marked this pull request as ready for review August 29, 2026 03:47
Adds an explicit test for validateIpGlobalRange behavior via IP-literal
domains (private IPv4 -> IpNotInGlobalRange, link-local IPv6 ->
Ipv6NotInGlobalRange, global IPv4 accepted). Behavioral coverage of the
global-range path that no test asserted directly.

Project coverage 96.95% (up from the ~95.84% base); patch 95.17% (>70%
target). Remaining uncovered changed-lines are version-conditional
(PHP 8.1 IP-range fallback) or defensive branches preempted by earlier checks.
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