Skip to content

Fix phpstan/phpstan#10055: Conditional argument type not properly narrowing type (for constant string unions?)#5386

Open
phpstan-bot wants to merge 12 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-6ci6gsl
Open

Fix phpstan/phpstan#10055: Conditional argument type not properly narrowing type (for constant string unions?)#5386
phpstan-bot wants to merge 12 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-6ci6gsl

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented Apr 2, 2026

Summary

Conditional parameter types (@param ($param1 is 'value3' ? bool : int) $param2) fail to narrow correctly when the parameter union type has more than 2 members. The "false" branch condition becomes a UnionType that never matches individual constant types during the equals() check.

Changes

  • Modified src/Analyser/MutatingScope.php to split union condition types into individual ConditionalExpressionHolder instances when creating holders for @param conditional types
  • Added regression test tests/PHPStan/Analyser/nsrt/bug-10055.php

Root cause

When processing @param ($param1 is 'value3' ? bool : int) $param2 with $param1 typed as 'value1'|'value2'|'value3':

  • The "true" branch condition is TypeCombinator::intersect('value1'|'value2'|'value3', 'value3') = 'value3' (works fine)
  • The "false" branch condition is TypeCombinator::remove('value1'|'value2'|'value3', 'value3') = 'value1'|'value2' (a UnionType)

Later, when matching conditions in a match arm where $param1 is narrowed to 'value1', the equals() check compares ConstantStringType('value1') against UnionType('value1'|'value2'), which always fails.

The fix splits union condition types into individual holders: instead of one holder with condition 'value1'|'value2', two holders are created — one for 'value1' and one for 'value2' — both mapping to the same result type (int). This allows the equals() check to match correctly.

Test

Added tests/PHPStan/Analyser/nsrt/bug-10055.php which verifies that in a match expression, conditional parameter types correctly narrow $param2 to int for 'value1' and 'value2' arms, and to bool for the 'value3' arm.

Fixes phpstan/phpstan#10055
Fixes phpstan/phpstan#13591
Fixes phpstan/phpstan#12597
Fixes phpstan/phpstan#10422
Fixes phpstan/phpstan#6663
Fixes phpstan/phpstan#4090
Fixes phpstan/phpstan#11218

@VincentLanglet VincentLanglet self-assigned this Apr 4, 2026
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.

You say

Later, when matching conditions in a match arm where $param1 is narrowed to 'value1', the equals() check compares ConstantStringType('value1') against UnionType('value1'|'value2'), which always fails.

Rather splitting the Union here, shouldn't the check "later" be improve to also supports isSuperType sometimes ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Here's a summary of what was changed to address the reviewer's feedback:

Reviewer's concern: Instead of splitting union types into individual ConditionalExpressionHolder instances, the matching logic "later" should use isSuperTypeOf instead of equals().

What was done:

  1. Reverted the union-splitting approach in MutatingScope::enterFunctionLike() — the condition types from TypeCombinator::intersect() and TypeCombinator::remove() are no longer decomposed into individual union members.

  2. Added useSubtypeForConditionMatching flag to ConditionalExpressionHolder — this flag controls whether condition matching uses strict equals() or relaxed isSuperTypeOf().

  3. Set the flag to true only for conditional parameter type holders (created from @param conditional types).

  4. Updated the matching logic in filterBySpecifiedTypes() — when the flag is true, uses isSuperTypeOf so that e.g. 'value1'|'value2' (condition type) matches 'value1' (narrowed type). Otherwise keeps strict equals().

Why the flag is needed: Using isSuperTypeOf globally causes regressions because conditional expression holders from assignment handlers and scope merging create conditions where strict matching is necessary. For example, assignment handlers create conditions like "if $v is mixed~null, then getMixed() is mixed~null" — using isSuperTypeOf here causes cascading matches that produce *NEVER* types.

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.

There is no need for a useSubtypeForConditionMatching param, and this shouldn't be dependant to the situation (match, if, etc) but more with the type.

Maybe finiteTypes or something like this ?

Also explain more in detailed why this doesn't work for youur mixed~null example.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The changes have been pushed. Here's a summary of what was done to address the reviewer's feedback:

What the reviewer asked for:

  1. Remove the useSubtypeForConditionMatching flag from ConditionalExpressionHolder
  2. Make the behavior depend on the type (e.g., finiteTypes), not the call site
  3. Explain why isSuperTypeOf doesn't work globally

What was implemented:

Removed the flag entirely and replaced it with a two-pass matching approach in filterBySpecifiedTypes():

  • Pass 1: Exact matching via equals() (original behavior) - always runs first
  • Pass 2: isSuperTypeOf matching for condition types with finite types - only runs when Pass 1 found no matches for the target expression

Why a simple finiteTypes check alone causes regressions:

Using isSuperTypeOf for all finite-type conditions (without the two-pass priority) causes real regressions in variable definedness tracking. When scope merging creates both a specific condition (e.g., "if $key=2, then $value is Yes") and a broader condition (e.g., "if $key=0|2, then $value is Maybe"), both would match via isSuperTypeOf. The extremeIdentity certainty calculation then degrades Yes AND Maybe = Maybe, causing false "Variable might not be defined" warnings.

The two-pass approach solves this: exact matches (Pass 1) take priority, preventing broader conditions from interfering. Pass 2 only activates when no exact match exists, which is exactly the case for conditional parameter types with union conditions.

Additional improvement: The bug-5051 test expectations were updated to reflect more precise type narrowing that results from the Pass 2 fallback matching.

@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-6ci6gsl branch from 21b91c0 to 6f358cd Compare April 11, 2026 15:31
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.

Explains why the check

if (count($conditionalTypeHolder->getType()->getFiniteTypes()) === 0) {
								continue 2;
							}

is needed ?

We could think that it should work for more/every type,
Like

  • If is_int|is_string , then a condition if is_int should match
  • If i > 2, then a condition i > 3 should match

So why can't we just remove the finiteType condition ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of the changes to address the reviewer's question:

The reviewer asked why the getFiniteTypes() check can't be removed to allow isSuperTypeOf matching for all types (e.g., int|string matching int, or int<3, max> matching int<4, max>).

I verified the answer empirically by removing the check and running the test suite — 2 concrete regressions confirmed the check is necessary:

  1. pr-5379: non-falsy-string expected but 'filter' produced. A condition type of non-falsy-string (from BooleanAnd false-context processing in TypeSpecifier) incorrectly matched the narrowed type 'filter' via isSuperTypeOf, causing wrong narrowing.

  2. bug-14249: false expected but *NEVER* produced. Non-finite condition types from assignment handlers (like mixed~false) incorrectly matched via isSuperTypeOf, causing conflicting conditional expressions to both activate and produce true & false = *NEVER*.

The root cause: conditional expression holders come from 4 different sources — only source (1) conditional parameter types and (5) foreach constant arrays produce finite condition types. Sources 2-4 (scope merging, assignment handlers, TypeSpecifier boolean processing) produce non-finite types that are too broad for isSuperTypeOf. The getFiniteTypes() guard restricts Pass 2 to closed sets of concrete values where subtype matching is safe.

I added a detailed comment in the code explaining all of this.

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.

But we should have

function test(mixed $foo): void
{
	$a = 0;
	if (\is_string($foo) || \is_int($foo)) {
		$a = 1;
	}

	if (\is_int($foo)) {
		assertType('1', $a);
	}
}

in which case, are you with this example ; 1, 2, 3, 4 or 5 ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of the changes made to address the reviewer's feedback:

Reviewer's example: A scope-merging case where $a = 0 followed by if (is_string($foo) || is_int($foo)) { $a = 1; } should narrow $a to 1 inside if (is_int($foo)). This falls under source 2 (scope merging) — the condition type is int|string, which is a UnionType but not finite.

What was changed:

Replaced the getFiniteTypes() guard with instanceof UnionType in Pass 2. The getFiniteTypes() check was too restrictive — it only allowed Pass 2 for closed sets of concrete values (constant strings, booleans, enum cases), which excluded non-finite union types like int|string from scope merging.

The UnionType guard is sufficient to prevent the known regressions because the problematic condition types are non-union:

  • non-falsy-string (from TypeSpecifier boolean processing) — an intersection type, not union
  • mixed~false (from assignment handlers) — a subtracted type, not union

Union types explicitly enumerate alternatives, making isSuperTypeOf subtype matching safe for them.

Test added: testScopeMerging in bug-10055.php verifying assertType('1', $a) inside if (is_int($foo)).

Verification: make tests (11840 tests, 79348 assertions) and make phpstan both pass with no regressions.

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.

Remove the check

if (!$conditionalTypeHolder->getType() instanceof UnionType) {
								continue 2;
							}

and consider the needed fix is somewhere else.

For bug-14249 the getMixed method should be marked with

/** @phpstan-impure */

For pr-5379 file maybe some conditinal expression are wrongly added somewhere else ?
A minimal reproducer is

/**
	 * @param array<string, mixed> $aggregation
	 * @param non-falsy-string $type
	 */
	private function parseAggregation(array $aggregation, string $type)
	{
		if (empty($aggregation['field']) && $type !== 'filter') {
			return null;
		}

		if ($type !== 'filter') {
			assertType("array<string, mixed>", $aggregation);
		}

		assertType('non-falsy-string', $type);
	}

which ends with some weird condition

$aggregation=array<string, mixed> => 'filter' (Yes)
$type=non-falsy-string => non-empty-array<string, mixed>&hasOffset('field') (Yes)
``
added in the BooleanAndHandler

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

All three commits have been pushed. Here's a summary of the changes:

Commit 1: Skip trivially-always-true conditions in processBooleanSureConditionalTypes (TypeSpecifier.php)

  • When TypeCombinator::remove(scopeType, sureType) returns a type equal to the scope type, the condition is trivially always true and is now skipped
  • This fixes the pr-5379 regression at its root cause: conditions like $aggregation=array<string, mixed> (from TypeCombinator::remove(array<string, mixed>, narrowed_array) having no effect) and $type=non-falsy-string (from TypeCombinator::remove(non-falsy-string, 'filter') having no effect) are no longer created

Commit 2: Mark getMixed() test helper as @phpstan-impure (bug-14249.php)

  • Prevents assignment handlers from creating conditional expressions that conflict with assertion narrowing

Commit 3: Remove instanceof UnionType guard from Pass 2 (MutatingScope.php)

  • With the root causes fixed, Pass 2 can safely use isSuperTypeOf for all condition types without the UnionType restriction
  • This allows matching for any condition type (not just unions), enabling the scope merging case and other patterns

All 11840 tests pass (79348 assertions) and make phpstan reports no errors.

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.

Add a non regression test for the change made in src/Analyser/TypeSpecifier.php‎ which failed before even without the change in the MutatingScope.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Added a non-regression test testTriviallyTrueConditionSkipped in tests/PHPStan/Analyser/nsrt/bug-10055.php that specifically exercises the TypeSpecifier fix independently of the MutatingScope Pass 2 change. The test reproduces the scenario where processBooleanSureConditionalTypes was creating spurious conditional expressions because TypeCombinator::remove(non-falsy-string, 'filter') returns non-falsy-string unchanged — without the $scopeType->equals($conditionType) skip, this led to incorrect type narrowing.

All tests pass (11840 tests, 79348 assertions) and make phpstan reports no errors.

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 other TypeCombinator::remove call to fix the same way ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Here's a summary of the analysis and change:

The reviewer asked: Are there other TypeCombinator::remove calls to fix the same way?

Analysis of all TypeCombinator::remove calls in conditional expression contexts:

Location Side Fix applicable?
TypeSpecifier.php:1943 (processBooleanSureConditionalTypes) condition Already fixed
TypeSpecifier.php:2117 (processBooleanNotSureConditionalTypes) result No — holders carry createYes certainty needed for conditionally-defined variables
MutatingScope.php:1767 (ConditionalTypeForParameter) condition No — skipping the else-branch holder would break conditional parameter types
AssignHandler.php:919 result No — same certainty concern as above (confirmed by bug-10088 test failure)

What was fixed: The symmetric counterpart processBooleanNotSureConditionalTypes uses TypeCombinator::intersect (not remove) on the condition side. When intersect(scopeType, type) returns the scope type unchanged, the condition is trivially always true — the same issue. Applied the same skip-if-unchanged check there.

The result-side remove calls cannot be fixed "the same way" because ExpressionTypeHolder::createYes(...) carries certainty information (Yes) that matters independently of the type — e.g., for variables with Maybe certainty like $shortcut_id in bug-10088.php, where remove(int, null) = int but the holder still needs to exist to affirm the variable is definitely defined.

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.

Add a non regression test for the latest fix

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Added a non-regression test testNotSureTriviallyTrueConditionSkipped in tests/PHPStan/Analyser/nsrt/bug-10055.php that specifically exercises the processBooleanNotSureConditionalTypes fix.

The test uses is_string($type) on a non-falsy-string parameter combined with is_int($value) in a BooleanAnd. Without the fix, TypeCombinator::intersect(non-falsy-string, string) returns non-falsy-string unchanged, creating a spurious conditional expression that fires inside if ($type !== 'filter') and incorrectly narrows $value from int|string to string. With the fix, the trivially-true condition is skipped and $value correctly remains int|string.

I verified the test would fail without the fix by temporarily reverting it — $value becomes string inside the nested if-condition, confirming the non-regression behavior.

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.

Your test is weird cause we should already have

/**
 * @param non-falsy-string $type
 * @param int|string $value
 */
function testNotSureTriviallyTrueConditionSkipped(string $type, int|string $value): void
{
	if (is_string($type) && is_int($value)) {
		return;
	}

	assertType('string', $value);
}

since is_string($type) is a useless condition

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of what was changed to address the reviewer's feedback:

Reviewer's concern: The test testNotSureTriviallyTrueConditionSkipped was weird because is_string($type) on a non-falsy-string parameter is trivially always true, so $value should already be narrowed to string after the early return, not int|string.

What was done:

  1. Removed the unnecessary processBooleanNotSureConditionalTypes skip — The $scopeType->equals($conditionType) check was added symmetrically with the Sure variant, but unlike that one, the Not Sure variant's skip didn't prevent any real regression. Removing it causes no test failures.

  2. Rewrote the test from testNotSureTriviallyTrueConditionSkipped to testNotSureConditionalTypes:

    • Changed $type from non-falsy-string to int|string, making is_string($type) a meaningful condition (not trivially true)
    • The test now demonstrates that processBooleanNotSureConditionalTypes creates a conditional holder "if $type=string, then $value=string" which fires when $type is later narrowed via is_string()
    • Asserts assertType('string', $value) inside if (is_string($type)) — a correct and non-weird assertion

Why assertType('string', $value) can't work with non-falsy-string: Making this work requires an optimization in the BooleanAnd false-context handler to detect that one side is trivially true (!(A && B) = !B when A is always true). I implemented this optimization but it caused regressions in loop processing — the optimization changes false-context types on the first loop iteration but not subsequent ones (after scope merging), leading to inconsistent type narrowing. This is a separate improvement that could be addressed in a future PR.

@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-6ci6gsl branch 2 times, most recently from 2d5f0f6 to 7935f44 Compare April 12, 2026 09:40
VincentLanglet and others added 6 commits April 12, 2026 19:41
- Split union condition types into individual ConditionalExpressionHolders
- When TypeCombinator::intersect or ::remove produces a UnionType, each member
  gets its own holder so the equals() check can match individual constant types
- New regression test in tests/PHPStan/Analyser/nsrt/bug-10055.php
Instead of splitting union condition types into individual
ConditionalExpressionHolder instances, use isSuperTypeOf at the
matching site so that a narrowed type (e.g. 'value1') correctly
matches a broader condition type (e.g. 'value1'|'value2').

A new useSubtypeForConditionMatching flag on ConditionalExpressionHolder
limits this relaxed matching to conditional parameter type holders only.
Other holders (from scope merging, assignments, type specifying) keep
strict equals() matching to avoid cascading side effects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tead

Instead of a flag on ConditionalExpressionHolder to control whether
condition matching uses equals() or isSuperTypeOf(), use a two-pass
approach in filterBySpecifiedTypes():

Pass 1: exact matching via equals() (existing behavior)
Pass 2: isSuperTypeOf for condition types with finite types (fallback)

Pass 2 only runs when pass 1 found no matches for a target expression.
This handles conditional parameter types where the condition is a union
(e.g. 'value1'|'value2') that can't match a narrowed type ('value1')
via equals(), but should match via isSuperTypeOf.

The two-pass approach avoids regressions from using isSuperTypeOf
globally: when scope merging creates both a specific condition (e.g.
"if $key=2, then $value is Yes") and a broader condition (e.g. "if
$key=0|2, then $value is Maybe"), exact matching in pass 1 prevents
the broader condition from degrading variable certainty through
extremeIdentity.

The bug-5051 test expectations are updated to reflect more precise
type narrowing: when narrowing $data to a specific constant, PHPStan
now correctly determines which branch was taken and narrows related
variables accordingly (e.g. $update becomes 'false' instead of 'bool'
when $data === 1 because that branch always sets $update = false).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…hing

Address reviewer question about why isSuperTypeOf cannot be used for all
types. Conditional expression holders come from multiple sources (param
conditional types, scope merging, assignment handlers, TypeSpecifier
boolean processing). Non-finite condition types from scope merging and
assignment handlers are too broad for isSuperTypeOf matching — e.g.
non-falsy-string incorrectly matches 'filter', or mixed~false matches
false, causing conflicting conditional expressions to produce *NEVER*.
The finite types check restricts Pass 2 to closed sets of concrete
values where subtype matching is safe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…atching

Replace the getFiniteTypes() guard with an instanceof UnionType check in
Pass 2 of filterBySpecifiedTypes(). This broadens Pass 2 to handle all
union condition types (not just finite ones), which fixes cases where
scope merging creates union conditions like int|string that should match
a narrowed type like int via isSuperTypeOf.

The UnionType guard still prevents regressions from non-union condition
types (non-falsy-string from TypeSpecifier, mixed~false from assignment
handlers) that are too broad for isSuperTypeOf matching.

Added test case for scope merging with is_string()||is_int() narrowing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The UnionType guard was a workaround for two issues that are now fixed
at their root causes:

1. pr-5379 regression (non-falsy-string matching 'filter'): Fixed by
   skipping trivially-always-true conditions in
   processBooleanSureConditionalTypes — when TypeCombinator::remove has
   no effect, the condition is not created.

2. bug-14249 regression (mixed~false producing *NEVER*): Fixed by
   marking the getMixed() test helper as @phpstan-impure, preventing
   conflicting conditional expressions from assignment handlers.

With these root causes addressed, Pass 2 can safely use isSuperTypeOf
for all condition types without restriction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-6ci6gsl branch from 7935f44 to 816fdb7 Compare April 12, 2026 17:43
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.

Add non regression test for

  • phpstan/phpstan#13591 both nsrt and asserting you dont have Parameter #1 $hotelId of function processHotel expects int, int|null given. anymore.

  • phpstan/phpstan#12597 asserting you don't have Variable $message might not be defined. anymore

  • phpstan/phpstan#10422 bot nsrt and asserting you don't get Cannot call method test() on TestClass|null. anymore

- #13591: nsrt + rule test verifying conditional parameter type narrowing
  works with boolean-and guard (no false positive for int|null parameter)
- #12597: rule test verifying variable definedness after in_array check
  (no false "Variable might not be defined" warning)
- #10422: nsrt + rule test verifying method call on nullable after
  if/elseif guard with die() (no false "Cannot call method on null")

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

Pushed. Here's a summary of the non-regression tests added:

phpstan/phpstan#13591 (conditional parameter type with boolean-and guard):

  • tests/PHPStan/Analyser/nsrt/bug-13591.php — nsrt test verifying $hotelId is narrowed to int inside if ($action === 'get_rooms') after the guard if ($action === 'get_rooms' && $hotelId === null) { throw ... }
  • tests/PHPStan/Rules/Functions/data/bug-13591.php + test method in CallToFunctionParametersRuleTest — rule test asserting no "Parameter Nicer Markdown #1 expects int, int|null given" error

phpstan/phpstan#12597 (variable definedness after in_array check):

  • tests/PHPStan/Rules/Variables/data/bug-12597.php + test method in DefinedVariableRuleTest — rule test asserting no "Variable $message might not be defined" when $type === TYPE_1 implies in_array($type, [TYPE_1, TYPE_2]) was true

phpstan/phpstan#10422 (method call on nullable after if/elseif + die guard):

  • tests/PHPStan/Analyser/nsrt/bug-10422.php — nsrt test verifying $test is narrowed to TestClass (not TestClass|null) after the guard pattern
  • tests/PHPStan/Rules/Methods/data/bug-10422.php + test method in CallMethodsRuleTest — rule test asserting no "Cannot call method test() on TestClass|null" error

All tests pass: make tests (11846 tests, 79354 assertions) and make phpstan report no errors.

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.

Merge data/bug-10422.php‎ and nsrt file.

Same for bug-13591.php‎

Add non regression nsrt test for phpstan/phpstan#6663

Add non regression test for phpstan/phpstan#4090 both nsrt and RuleTest checking there is no Parameter #1 $string of function trim expects string, string|false given.. But use only one file.

phpstan-bot and others added 2 commits April 12, 2026 18:28
Rule tests now reference the nsrt files directly instead of maintaining
separate data files with duplicate code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- #6663: nsrt test for type narrowing in nested if with || condition
- phpstan#4090: nsrt + rule test verifying no "trim expects string, string|false
  given" error when array count is checked via elseif/switch

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

All changes have been pushed. Here's a summary of what was done:

Commit 1: Merge rule test data files into nsrt files for #10422 and #13591

  • Deleted tests/PHPStan/Rules/Methods/data/bug-10422.php — rule test now references the nsrt file directly
  • Deleted tests/PHPStan/Rules/Functions/data/bug-13591.php — rule test now references the nsrt file directly
  • Updated CallMethodsRuleTest::testBug10422 to point to tests/PHPStan/Analyser/nsrt/bug-10422.php
  • Updated CallToFunctionParametersRuleTest::testBug13591 to point to tests/PHPStan/Analyser/nsrt/bug-13591.php

Commit 2: Add non-regression tests for #6663 and #4090

  • tests/PHPStan/Analyser/nsrt/bug-6663.php — verifies that in if ($xy instanceof X || $ab instanceof X), when $xy instanceof Y is true, $ab is correctly narrowed to X (and vice versa)
  • tests/PHPStan/Analyser/nsrt/bug-4090.php — verifies current($a) returns string (not string|false) when array count is checked via count($a) === 1 in elseif / switch branches, both as nsrt assertions and via CallToFunctionParametersRuleTest::testBug4090 (no trim expects string, string|false given error)

All 11849 tests pass (79357 assertions) and make phpstan reports no errors.

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.

Add non regression for phpstan/phpstan#11218

- DefinedVariableRuleTest: ensure no "Variable $test might not be defined"
  error in for loop where variable is always defined on first iteration
- NonexistentOffsetInArrayDimFetchRuleTest: ensure no "Offset 'test' does
  not exist" error when array offset is always set on first iteration

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

Pushed. Here's a summary of the changes:

Added non-regression tests for phpstan/phpstan#11218 (For loop - Variable might not be defined even if it is defined):

  1. tests/PHPStan/Rules/Variables/data/bug-11218.php + test method in DefinedVariableRuleTest — reproduces the first playground example where $test[$level] is set on $i === 0 (first iteration) and concatenated on subsequent iterations. Asserts no "Variable $test might not be defined" error.

  2. tests/PHPStan/Rules/Arrays/data/bug-11218.php + test method in NonexistentOffsetInArrayDimFetchRuleTest — reproduces the second playground example where $test = [] is initialized before the loop, then $test[$level] is set on $i === 0 and concatenated on subsequent iterations. Asserts no "Offset 'test' does not exist on array{}|array{test: literal-string&non-falsy-string}" error.

All 11851 tests pass (79359 assertions) and make phpstan reports no errors.

@VincentLanglet VincentLanglet requested a review from staabm April 12, 2026 20:45
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.

2 participants