Skip to content

Create cross-kind conditional expression holders in BooleanAnd/BooleanOr type specifier with truthy fallback for isset()#5760

Open
phpstan-bot wants to merge 7 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-49y396c
Open

Create cross-kind conditional expression holders in BooleanAnd/BooleanOr type specifier with truthy fallback for isset()#5760
phpstan-bot wants to merge 7 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-49y396c

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented May 25, 2026

Summary

After if (isset($data['key']) && !is_string($data['key'])) { throw ...; }, PHPStan did not narrow $data['key'] to string when subsequently accessed via isset() check or ?? operator. The root cause was that the BooleanAnd falsey handler only created conditional expression holders when both left and right arms had the same "kind" of type specification (both sureTypes or both sureNotTypes), but common patterns produce mismatched kinds.

Changes

  • Added processBooleanNotSureWithSureConditionalTypes() in src/Analyser/TypeSpecifier.php — creates conditional holders using conditions from sureNotTypes (left arm) and holders from sureTypes (right arm)
  • Added processBooleanSureWithNotSureConditionalTypes() in src/Analyser/TypeSpecifier.php — creates conditional holders using conditions from sureTypes (left arm) and holders from sureNotTypes (right arm)
  • Both functions are called (with both argument orderings) in both the BooleanAnd falsey handler and the BooleanOr truthy handler
  • Added truthy fallback: when the left/right arm produces empty SpecifiedTypes in falsey context (as isset() does for non-constant array dim fetch), the code recomputes in truthy context and swaps sureTypes/sureNotTypes to derive usable conditions
  • Added allExpressionsTrackable() guard to prevent the truthy fallback from creating incomplete conditions when non-trackable expressions (method calls) are involved
  • Added regression test covering: simple bool condition + is_string/is_int/is_array, !== null condition, instanceof condition, isset() with ?? coalesce, multiple keys, and array_key_exists()

Root cause

The TypeSpecifier creates conditional expression holders in the falsey branch of BooleanAnd (and truthy branch of BooleanOr) to encode relationships like "if A is true, then B has type X". These holders are created by processBooleanSureConditionalTypes (conditions from sureTypes, holders from sureTypes) and processBooleanNotSureConditionalTypes (conditions from sureNotTypes, holders from sureNotTypes).

For $a && !is_string($y) in falsey context:

  • Left arm ($a falsey): produces sureNotTypes {$a → truthy()}
  • Right arm (!is_string($y) falsey = is_string($y) truthy): produces sureTypes {$y → StringType}

Since left has sureNotTypes and right has sureTypes, neither existing function could create the holder "if $a is true, then $y is string". The new cross-kind functions handle this mismatch.

For isset($data['x']) specifically, the falsey context returns empty SpecifiedTypes for non-constant arrays, so even the cross-kind functions had nothing to work with. The truthy fallback addresses this by deriving conditions from the truthy narrowing of isset() (which includes HasOffsetType on the array variable).

Test

tests/PHPStan/Analyser/nsrt/bug-10644.php covers:

  • Original issue: isset($data['subtitle']) && !is_string(...) with ?? coalesce and isset() recheck
  • Simple boolean condition with is_string, is_int, is_array
  • !== null condition with is_string
  • instanceof condition with is_int
  • Multiple isset keys with different type guards
  • array_key_exists() with is_string

Fixes phpstan/phpstan#10644
Fixes phpstan/phpstan#11918
Fixes phpstan/phpstan#3385
Fixes phpstan/phpstan#6202
Fixes phpstan/phpstan#14455

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

Is there some refacto with processBooleanNotSureConditionalTypes and processBooleanSureConditionalTypes methods ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. The refactoring unifies the four methods (processBooleanSureConditionalTypes, processBooleanNotSureConditionalTypes, processBooleanNotSureWithSureConditionalTypes, processBooleanSureWithNotSureConditionalTypes) into a single processBooleanConditionalTypes method parameterized by two booleans ($conditionsAreSure, $holdersAreSure). This eliminates 190 lines of duplicated code while preserving the exact same call ordering in array_merge at both call sites. All tests pass and make phpstan reports no errors.

@VincentLanglet VincentLanglet self-assigned this May 26, 2026
Comment thread src/Analyser/TypeSpecifier.php Outdated
Comment thread src/Analyser/TypeSpecifier.php Outdated
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-49y396c branch 2 times, most recently from 6de7bd1 to ed816ea Compare May 27, 2026 12:03
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

Comment thread tests/PHPStan/Analyser/nsrt/bug-10644.php Outdated
VincentLanglet and others added 3 commits May 29, 2026 10:54
…leanOr` type specifier with truthy fallback for `isset()`

- Add `processBooleanNotSureWithSureConditionalTypes()` which builds conditions
  from sureNotTypes and holders from sureTypes (cross-kind pairing)
- Add `processBooleanSureWithNotSureConditionalTypes()` which builds conditions
  from sureTypes and holders from sureNotTypes (reverse cross-kind pairing)
- Call both new functions (with both argument orderings) in BooleanAnd falsey
  and BooleanOr truthy conditional holder creation
- Add truthy fallback: when left/right falsey types produce empty SpecifiedTypes
  (e.g. isset() on non-constant array dim fetch), recompute in truthy context
  and swap sureTypes/sureNotTypes to derive conditions for holders
- Guard the truthy fallback with `allExpressionsTrackable()` to prevent
  over-narrowing when non-trackable expressions (method calls) are involved
- Fixes patterns like `if ($a && !is_string($y)) { throw; }` followed by
  `if ($a) { /* $y is now string */ }` and
  `if (isset($data['x']) && !is_string($data['x'])) { throw; }` followed by
  `$data['x'] ?? ''` being correctly inferred as string
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@VincentLanglet VincentLanglet changed the base branch from 2.1.x to 2.2.x May 29, 2026 08:54
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-49y396c branch from 4902674 to 5a09a66 Compare May 29, 2026 08:54
Comment thread src/Analyser/TypeSpecifier.php
…holders

The truthy-narrowing fallback for empty falsey SpecifiedTypes (added for
isset() on non-constant array dim fetches) previously ran unconditionally
inside the `$context->false()` block. In a mixed truthy-and-false context
(produced by negation) it would run in addition to the existing falsey
re-derivation, supplementing holders the truthy branch had already filled.

Make it an explicit `else` branch so the two re-derivation strategies are
mutually exclusive: the mixed truthy-and-false context re-derives from the
falsey narrowing, while the pure false context falls back to the truthy
narrowing. Add comments documenting both branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

No test was added for the latest commit

…ed context

Negating a `&&` condition specifies the inner BooleanAnd in a mixed
truthy-and-false context, which is the path refined by the previous commit
(the isset() truthy fallback must not fire there; holders are re-derived from
the falsey narrowing instead). These cases produced `mixed` before the PR and
now narrow correctly through that path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. I addressed VincentLanglet's review ("No test was added for the latest commit").

What the latest commit changed

Commit 70f347f4d made the isset() truthy fallback an explicit else branch so it only fires in a pure false context — not in a mixed truthy-and-false context (which negate() produces when a && condition is negated). In the mixed context, holders are re-derived from the falsey narrowing instead.

What I added

A new nsrt test file tests/PHPStan/Analyser/nsrt/negated-boolean-and-conditional-holders.php that exercises exactly that mixed-context path via negated && conditions:

  • !(isset($data['k']) && !is_string(...)) with ?? coalesce and isset() recheck
  • !($a && !is_string($y)) / !is_int (simple bool condition)
  • !($x !== null && !is_string($y)) (!== null condition)
  • !(array_key_exists(...) && !is_string(...))

I verified these are meaningful regression tests:

  • Pre-PR baseline → they infer mixed (the bug).
  • Current code → they narrow to string/int.
  • They run through the mixed truthy-and-false branch that the latest commit refined.

Verification

  • New test passes via NodeScopeResolverTest (1623/1623).
  • make tests — 12549 tests, 86351 assertions, OK.
  • make phpstan — no errors.

Committed (6d1c6d6c2) and pushed.

One note: the latest commit produces no observable output difference on these (or other patterns I tried) — old and new both narrow correctly here. The commit was a defensive correctness change ensuring the two re-derivation strategies are mutually exclusive, so this test locks in the mixed-context behavior rather than demonstrating a before/after divergence at the commit boundary.

@VincentLanglet VincentLanglet requested a review from staabm May 29, 2026 13:14
Copy link
Copy Markdown
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

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

the newly added test in 6d1c6d6 does not fail when 70f347f is reverted

… too

The truthy-narrowing fallback (added for isset()/array_key_exists() on array
dim fetches, whose falsey narrowing is empty) was gated behind an `else` so it
only ran in a pure false context. But the mixed truthy-and-false context —
reached by negating a `=== true` comparison, e.g.
`(isset($data[$key]) && !is_string($data[$key])) === true` — also leaves those
arms empty after the falsey re-derivation, so gating the fallback there dropped
the narrowing and the `?? 'fallback'` expression widened back to `mixed~null`
instead of `string`.

Run the fallback unconditionally after the falsey re-derivation. It only fills
arms the re-derivation left empty, so it never overrides a non-empty falsey
result — which addresses the original concern about the two blocks conflicting.

Add boolean-and-conditional-holders-mixed-context.php covering the `=== true`
mixed-context path (which fails when the fallback is gated to pure-false only),
and correct the misleading header of negated-boolean-and-conditional-holders.php
(`!(...)` specifies a pure falsey context, not a mixed one).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed as a1eb395ce. Here's a summary of how I addressed staabm's review.

The review concern

the newly added test in 6d1c6d6c2 does not fail when 70f347f4d is reverted

This was correct, and investigating why revealed something more important: 70f347f4d was a regression, not a defensive fix.

Investigation

I reverted 70f347f4d's source change and ran the full NodeScopeResolverTest (1623 tests) — everything still passed, confirming the commit had no observable effect on the existing corpus.

Digging into the context bitmask explained why. negate() only produces the mixed truthy-and-false context (where both truthy() and false() hold) from negate(createTrue()), i.e. from a === true comparison. The negated && tests added in 6d1c6d6c2 use if (!(...)), which specifies the inner && in a pure falsey context — so they never exercised the path 70f347f4d changed. The commit's own message and the test's header comment were mistaken about this.

Constructing the real mixed-context case ((isset($data[$key]) && !is_string($data[$key])) === true) exposed the regression:

  • With 70f347f4d: $data[$key] ?? 'fallback'mixed~null (narrowing lost)
  • Without it:string (correct — the key is unset or a string)

The else-gating stopped the isset truthy fallback from running in the mixed context, where the falsey re-derivation is also empty.

Fix

  • Reverted the gating in TypeSpecifier.php: the truthy fallback now runs after the falsey re-derivation in both contexts. staabm's original "might override" concern doesn't actually occur — the fallback is guarded by getSureTypes() === [] && getSureNotTypes() === [], so it only fills arms left empty, never overrides a non-empty falsey result. I documented this in the code comment.
  • Added boolean-and-conditional-holders-mixed-context.php — a genuine regression test for the === true mixed-context path. I verified it fails (3 assertions, mixed~null vs string) when the gating is reintroduced, and passes with the fix.
  • Corrected the misleading header of negated-boolean-and-conditional-holders.php (!(...) is a pure falsey context).

Verification

  • make tests — 12550 tests, 86352 assertions, OK
  • make phpstan — no errors
  • New test confirmed to fail under the gated version and pass with the fix.

@VincentLanglet
Copy link
Copy Markdown
Contributor

VincentLanglet commented May 29, 2026

I think we could keep it like this (and maybe remove some comments ?) @staabm

  • The code is simpler
  • It fixes multiple issues
  • There is no known regression
  • It starts to hallucinate

We can still open a new issue and run the bot on it when a regression will be found

@VincentLanglet VincentLanglet requested a review from staabm May 29, 2026 14:12
@staabm
Copy link
Copy Markdown
Contributor

staabm commented May 29, 2026

@VincentLanglet agree. and yes, please less verbose comments ;)

Comment thread src/Analyser/TypeSpecifier.php Outdated
Comment on lines +740 to +745
// Fallback for any arm still empty: when the falsey narrowing produces
// nothing (e.g. isset() on an array dim fetch) derive conditions from the
// truthy narrowing instead, swapping sure/sureNot types. This only fills
// arms the block above left empty — it never overrides a non-empty falsey
// re-derivation — and is needed in both the pure false context and the
// mixed truthy-and-false context.
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.

Use less verbose comments

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done. I addressed VincentLanglet's review ("Use less verbose comments").

Changes

Trimmed the two block comments in the BooleanAnd falsey handler of src/Analyser/TypeSpecifier.php:

  • The mixed truthy-and-false re-derivation comment went from 2 lines to 1.
  • The truthy-narrowing fallback comment went from 6 lines to 2 — keeping the essential "why" (empty arms from isset() on an array dim fetch, swap sure/sureNot) and dropping the prose about override-safety and context coverage that staabm and VincentLanglet found too verbose.

No logic changed.

Verification

  • make phpstan — no errors
  • NodeScopeResolverTest — 1624/1624 pass

Committed as 1b02efbdf and pushed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants