Skip to content

Preserve property narrowing in match expression results - #6708

Merged
staabm merged 2 commits into
phpstan:2.3.xfrom
drewmt:fix/match-property-narrowing
Oct 14, 2026
Merged

staabm merged 2 commits into
phpstan:2.3.xfrom
drewmt:fix/match-property-narrowing

Conversation

@drewmt

@drewmt drewmt commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes phpstan/phpstan#15433.

match (true) currently loses narrowing when an arm returns a mixed property. Normalizing a property to an array infers mixed, while the equivalent if/else correctly infers array<mixed>. This also affects ordinary properties, not only PHP 8.4 interface property hooks.

ExpressionResult::getKeepVoidType() returns the raw type whenever isVoid() is not no. For mixed, that answer is maybe, so the method bypasses the already-narrowed expression holder. The raw type is now kept only when void-to-null projection actually removes a void component. Otherwise the normal holder-aware type read preserves narrowing, including repeatable calls returning mixed. Reusing projectVoidToNull() shares its non-void/non-union fast path, and an identity check avoids comparing unchanged types. The native implementation mirrors the change.

The regressions cover ordinary and hooked properties, reversed conditions, integer narrowing, native types, and unchanged variable/if controls. Added pure function, instance-method and static-method cases catch the same narrowing loss for calls returning mixed. Unit controls verify explicit void and void-containing unions are preserved, while ordinary reads still project void to null. The original property fixtures failed before the initial fix; the call cases fail before the review follow-up in both PHP and Turbo.

Validation:

  • The focused regressions, ExpressionResult controls, existing match and void inference cases pass in PHP and Turbo: 72 tests, 253 assertions in each mode.
  • Full configured PHP 8.4 suite with and without Turbo: 22,467 tests, 96,899 assertions, 106 existing skips in each mode. Full PHP 8.3 suite: 22,319 tests, 96,649 assertions, 204 existing skips.
  • Strict native build and differential smoke test pass; signature parity compares 16,390 members. Method parity, 472-line walk-trace identity, both self-analysis modes, identical CLI output, scoped PHP coding standards, syntax and whitespace checks pass.
  • Full-suite runs use the upstream parallel runner with four workers. Initial serial runs exhausted their 2 GB per-process limit near the end; no tests were disabled or expectations changed.
  • Local clang-tidy 21 and cross-platform/shared-core CI gates have not been run locally. Upstream CI is still required.

@VincentLanglet
VincentLanglet requested a review from staabm October 12, 2026 22:32
@staabm
staabm force-pushed the fix/match-property-narrowing branch from ecc8927 to 0660e88 Compare October 13, 2026 05:23
@staabm
staabm requested a balanced review from Copilot October 13, 2026 05:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The expected Turbo version does not match the rebased native-source commit SHA.

1 open finding
What changed in this PR

Preserves narrowed property types when computing match expression results, fixing #15433.

Changes:

  • Uses holder-aware type reads where void-to-null projection is unnecessary.
  • Mirrors behavior in Turbo and adds property regression coverage.
  • Updates the expected Turbo extension version.
File Description
src/​Analyser/​ExpressionResult.php Preserves narrowed holder types.
turbo-ext/​src/​ExpressionResult.cpp Mirrors the PHP implementation.
tests/​PHPStan/​Analyser/​nsrt/​bug-15433.php Tests ordinary properties and controls.
tests/​PHPStan/​Analyser/​nsrt/​bug-15433-property-hooks.php Tests PHP 8.4 property hooks.
src/​Turbo/​TurboExtensionEnabler.php Updates the expected native version.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Turbo/TurboExtensionEnabler.php Outdated
@staabm
staabm force-pushed the fix/match-property-narrowing branch from 0660e88 to 5f101c9 Compare October 13, 2026 05:31
Comment thread src/Analyser/ExpressionResult.php
@staabm

staabm commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

please double check whether the failling CI checks are related

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Repeatable calls returning mixed still bypass their narrowed expression holders.

2 open findings
1 resolved since last review

🧠 Review effort: Balanced

Comment thread src/Analyser/ExpressionResult.php
Comment on lines +464 to +467
zend_long projects = projectsVoidToNull(nativeTypesPromoted);
if (UNEXPECTED(projects < 0)) return zv::Val();
if (projects == 0) {
return nativeTypesPromoted ? getNativeType() : getType();
@drewmt

drewmt commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

I checked the five failures on 5f101c9. The cache exit-code mismatch (expected 1, got 0) also occurs on the exact upstream base cdbfc455: baseline job.

The Rector, Rector downgrade, Laravel collection and PHP 7.4 str_contains failures have identical diagnostics and fixture diffs to pin-only #6696. Those comparisons are historical, not exact-current-base downstream runs; push CI skips those integrations. I found no new diagnostic attributable to this patch.

Update, October 13: CI on 89bb51a0e has completed with 961 successful, 6 failed and 8 skipped checks. The five failures above match the same baselines. The additional phpstan-dba job fails while loading database fixtures: MySQL at 127.0.0.1:3306 refuses the connection, before analysis runs. Shared-core and sanitizer gates pass. No new patch regression is established; local PHP 8.4, Turbo and PHP 8.3 suites also pass.

@VincentLanglet
VincentLanglet requested a review from staabm October 13, 2026 09:25
// Only a call's value read can project void to null. Other expressions
// must honour their narrowed holder even when the raw type is mixed
// (whose isVoid() is maybe, not evidence of an actual void arm).
if (!$this->projectsVoidToNull($nativeTypesPromoted)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this if-block still serve a purpose?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is no longer needed for correctness after the projection comparison below, but it keeps the non-projecting fast path. With a tracked holder, getType()/getNativeType() can return the narrowed type without resolving the raw callback; I checked both flavours and removing this guard changes that from zero callback calls to one, with the same final type.

@staabm
staabm force-pushed the fix/match-property-narrowing branch from 89bb51a to 867f37c Compare October 14, 2026 04:51
@staabm
staabm merged commit c066ed5 into phpstan:2.3.x Oct 14, 2026
968 of 973 checks passed
@staabm

staabm commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

thank you!

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.

match expression type resolution regression (2.3)

4 participants