Skip to content

Derive conditional expression guards from the branch set difference - #6380

Open
ondrejmirtes wants to merge 8 commits into
2.3.xfrom
dependent-types-remainder-guards
Open

Derive conditional expression guards from the branch set difference#6380
ondrejmirtes wants to merge 8 commits into
2.3.xfrom
dependent-types-remainder-guards

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Sep 5, 2026

Copy link
Copy Markdown
Member

When merging two branch scopes, the conditional-expression guard type is now the set difference TypeCombinator::remove(ourType, theirType) instead of requiring the whole branch-local type to differ from the merged type.

This has two effects:

  • A flag that is widened inside a branch, or whose type overlaps the other branch, can still act as a guard for the value that is unique to that branch. This fixes the "variable might not be defined" false positives where a later condition is strictly stronger than the recording condition.
  • A guard whose two branch types overlap is no longer recorded, which removes an unsound narrowing of a superglobal offset.

Includes the native turbo-ext/src/ScopeOps.cpp port of the guard-selection change and the make bump-turbo version bump, plus resolution of the self-analysis findings that the sharper certainty surfaces (7 provably-dead conditions removed in source, the benign loop-variable-reuse findings added to the baseline).

Regression tests added for each issue; the whole NodeScopeResolverTest stays green with and without the extension, and a local issue-bot run over all open-issue snippets shows the four fixes plus one collateral fix (#8360) and no regressions.

Closes phpstan/phpstan#13833
Closes phpstan/phpstan#9685
Closes phpstan/phpstan#7706
Closes phpstan/phpstan#14421
Closes phpstan/phpstan#8360

🤖 Generated with Claude Code

ondrejmirtes and others added 5 commits September 5, 2026 10:35
When merging two branches, a variable qualified as a guard for
conditional expressions only when its full our-branch type differed
from the merged type. That rejected sound guards whose branch type
survives the union unchanged (a bool widened over a nested branch,
1|null against a null pre-initializer), and it recorded overlapping
full-type guards that a later narrowing could match even though the
matched value may as well have come from the other branch.

The guard type now records TypeCombinator::remove(ourType, theirType),
the part of our type the other branch cannot produce. A representable
remainder is disjoint from the other branch, so matching it later
soundly selects this branch even when the full branch types overlap.
When the subtraction is not representable, remove() keeps the full
our-branch type and the merged-type exclusion applies to it as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGhnJQpUWRRpg6H5LuWJkA
Mirrors the previous commit in turbo-ext/src/ScopeOps.cpp: the guard
type in createConditionalExpressions() is TypeCombinator::remove() of
the two branch types, guards require a certainty-Yes their-branch
entry, and freshly created remainder holders are kept alive by an
owned array while the guard scratch table borrows them. NeverType
joins the native class-reference table for the empty-remainder check.

Verified with the freshly built extension: smoke test, side-by-side
method parity, signature parity, full NodeScopeResolverTest, and
byte-identical analysis output with the extension on and off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGhnJQpUWRRpg6H5LuWJkA
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGhnJQpUWRRpg6H5LuWJkA
The set-difference conditional guards now let inference prove more
cross-variable relationships, which surfaces real latent smells in
PHPStan's own source under `make phpstan`.

Remove the conditions the sharper narrowing proves unreachable, each a
no-op that only ever evaluated one way:

- NodeScopeResolver: the `$currentParameter === null` guard inside
  `if ($assignByReference)` - `$assignByReference` is only ever true when
  `$currentParameter !== null`.
- Do/While/Foreach loop handlers: the `$prevEntryScope !== null` /
  `$unrolledTotalKeys === null` conjuncts are already implied by the
  earlier `$replay*` conjuncts (all set together on the same path).
- SwitchHandler: `isset($branchFinalScopeResult)` is always true once
  `$prevScope !== null` (both are set in the same branch iteration).
- RichParser: `$tokenLine` is always set once
  `$openParenthesisCount > 0`, so the `?? 1` fallback is dead.
- SubstrDynamicReturnTypeExtension: `$substr` is a bool only on the
  version path where `substrReturnFalseInsteadOfEmptyString()` is true,
  so the inner version re-check and its empty-string branch are dead.
- OptimizedDirectorySourceLocator: the `variable.undefined` ignore no
  longer matches - `$file` is proven defined once
  `$fetchedFunctionNode !== null`.

The remaining findings are benign loop-variable reuse across adjacent
loops (foreach.valueOverwrite / keyOverwrite / for.variableOverwrite);
they are recorded in the baseline rather than reshaping readable code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGhnJQpUWRRpg6H5LuWJkA
…nding

The previous commit removed the empty-string branch under `is_bool($substr)`
as dead code, reasoning that `$substr` is a bool only where
`substrReturnFalseInsteadOfEmptyString()` is true. That is a real regression:
the branch is live when PHPStan itself runs on a runtime PHP whose native
`substr()` returns `false` (old runtime) while analysing a target version
where substr yields `''`. There the runtime `substr()` call produces a bool,
`substrReturnFalseInsteadOfEmptyString()` is false, and the else branch
simulates the modern result by returning ConstantStringType(''). PHPStan's own
inference models `substr()` per the analysed version and cannot see the old
runtime path, so it reports the guard as always-true - a finding that must be
baselined, not resolved by deleting the branch.

Restore the file to its exact pre-commit content and record the single
`if.alwaysTrue` finding in the baseline instead. The other seven dead-code
removals in the previous commit are genuinely dead and stay removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGhnJQpUWRRpg6H5LuWJkA
@phpstan-bot

Copy link
Copy Markdown
Collaborator

You've opened the pull request against the latest branch 2.3.x. PHPStan 2.3 is not going to be released for months. If your code is relevant on 2.2.x and you want it to be released sooner, please rebase your pull request and change its target to 2.2.x.

ondrejmirtes and others added 3 commits September 5, 2026 22:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGhnJQpUWRRpg6H5LuWJkA
…rty assignment

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGhnJQpUWRRpg6H5LuWJkA
…sion

The is_bool($substr) branch re-checked substrReturnFalseInsteadOfEmptyString(),
which the sharper conditional guards proved always-true when analysing on PHP >= 8
(there substr() never returns false, so $substr is only bool via substrOrFalse()).
The finding is genuinely version-specific and cannot live in the shared baseline.
Route the non-mb_substr path through substrOrFalse(), whose false|string result
does not depend on the runtime substr() semantics, and map false to the analysed
version's result. Behaviour is unchanged; drops the version-specific baseline entry.

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

Labels

None yet

Projects

None yet

2 participants