Skip to content

Defer boolean-decomposition narrowing to the application point - #6163

Merged
ondrejmirtes merged 5 commits into
2.2.xfrom
extract-apply-specified-types
Jul 31, 2026
Merged

Defer boolean-decomposition narrowing to the application point#6163
ondrejmirtes merged 5 commits into
2.2.xfrom
extract-apply-specified-types

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Jul 31, 2026

Copy link
Copy Markdown
Member

The applySpecifiedTypes arc from the resolve-type-rewrite branch (the symbolic alternative-form entries already landed earlier — this completes the deferral), plus two conditional-holder soundness fixes built on it:

  • SpecifiedTypes carries conditional-expression-holder recipes and deferred augments; MutatingScope::filterBySpecifiedTypes() evaluates both against the applying scope — the application point of the narrowing — instead of the scope the composition ran on. Setters switch to clone so a copying setter can never silently drop a slot.
  • BooleanAndHandler/BooleanOrHandler compose ConditionalExpressionHolderRecipes, a DisjunctionBranchUnionAugment (either-branch union recovery) and a DisjunctionHolderProjectionAugment (disjunction-truthy holder projection) instead of running that math eagerly; the branch scopes are thunks, so deep boolean chains no longer derive every level's opposite-polarity scope. The decided-operand arms return the raw narrowing — sure-not entries stay symbolic and resolve where applied. The four eager helper methods are gone. Locally this is a −2.6% self-analysis win (ABBA, n=6 per side).
  • The deferral fixes the #14807-family leak shape with an intermediate if — regression-tested against both of its symptoms (identical.alwaysFalse on the property, function.alreadyNarrowedType on the in_array() set check).
  • The antecedent-swap fallback is now gated on the side's truthy narrowing being equivalent to its truth (isset()), not merely implied by it: non-strict in_array($x, $a)'s truthy narrowing (non-empty $a) can hold while the call is false, and the derived holder unsoundly pinned $x to null in a sibling branch where only $a !== [] was known.
  • Mutation-test follow-up: the projection's applying-scope certainty gate is pinned by a stored-boolean fixture with a Maybe-defined target; the per-branch certainty guards had no reachable divergent state (the branch scopes are filtered views of the applying scope) and are dropped.

Expectation updates (each matching the rewrite branch, reviewed individually): empty($foo) on a truthy-when-defined variable now proves $foo undefined in the branch (certainty No); a template-typed subject is no longer re-wrapped inside its own bound (T of T of ...); two TypeSpecifierTest rows expect the application-point subtraction form for empty($array).

Known downstream churn: Rector's AssignArrayToStringRector skip-fixture skip_reassigned_as_string.php.inc starts failing — its $where is conditionally assigned a truthy value, so inside if (empty($where)) PHPStan now correctly proves it undefined (previously maybe-defined with the eager array{}), which flips Rector's skip-heuristic. Their fixture/heuristic needs adapting to the more precise inference.

Everything else is churn-free: NodeScopeResolverTest 1696, full suite 17820, make phpstan, make cs.

Closes phpstan/phpstan#14908
Closes phpstan/phpstan#14966

🤖 Generated with Claude Code

https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7

ondrejmirtes and others added 2 commits July 31, 2026 14:45
SpecifiedTypes carries conditional-expression-holder recipes and deferred
augments; filterBySpecifiedTypes() evaluates both against the applying
scope - the application point of the narrowing - instead of the scope the
composition ran on. The setters switch to clone so new slots can never be
dropped by a copying setter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
BooleanAnd/BooleanOr compose recipes and deferred augments instead of
running the holder math, the either-branch union recovery and the
disjunction-truthy holder projection eagerly on the composition scope.
The decided-operand arms return the raw narrowing - the sure-not entries
stay symbolic and resolve where they are applied.

Expectation updates match the deferred evaluation: empty() on a variable
that is truthy whenever defined now proves the variable undefined in the
empty() branch, and a template-typed subject is no longer re-wrapped
inside its own bound (T of T of ...) by the branch-union recovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
ondrejmirtes and others added 3 commits July 31, 2026 15:04
Both false positives - the property-narrowing leak and the in_array()
set-narrowing leak past an intermediate if - are fixed by deferring the
boolean-decomposition holder math to the application point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
…e truth

The antecedent-swap fallback stands the inverted truthy narrowing in for
"this side of the && is true", which is only sound when the narrowing is
equivalent to the side's truth. isset() qualifies - it is exactly the
offset's non-nullness. A call like non-strict in_array($x, $a) does not:
its truthy narrowing (a non-empty $a) can hold while the call is false,
and the derived holder unsoundly narrowed the other operand ($x to null)
in a sibling branch where only $a !== [] was known.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
The applying-scope certainty gate of the disjunction-truthy holder
projection is pinned by a stored-boolean fixture with a Maybe-defined
target - a projected sure type would wrongly upgrade its certainty. The
per-branch certainty guards had no reachable divergent state: the branch
scopes are filtered views of the very scope the narrowing is applied on,
so their tracked certainty cannot drop below it.

The bug-14908 rule tests require PHP 8.1 (enums) at runtime, not just
for fixture linting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
@ondrejmirtes
ondrejmirtes merged commit 3dbc905 into 2.2.x Jul 31, 2026
738 of 745 checks passed
@ondrejmirtes
ondrejmirtes deleted the extract-apply-specified-types branch July 31, 2026 16:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment