From 36c7aafc9ff5f29ea5c6356e8b4ca1b023109cc4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 03:55:34 +0000 Subject: [PATCH 1/8] Fix phpstan/phpstan#6934: nullsafe operator does not narrow type for method args - In nullsafe method call chains like $a?->b?->method($a), the variable $a was not narrowed to non-null when processing method arguments - Added narrowNullsafeVarChain() to NonNullabilityHelper to walk a nullsafe chain and narrow each intermediate var - Modified MethodCallHandler to apply nullsafe chain narrowing before processing arguments of virtual nullsafe method calls, then revert after - New regression test in tests/PHPStan/Analyser/nsrt/bug-6934.php --- .../Helper/NonNullabilityHelper.php | 21 +++++++++++++++ .../ExprHandler/MethodCallHandler.php | 15 +++++++++++ tests/PHPStan/Analyser/nsrt/bug-6934.php | 26 +++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-6934.php diff --git a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php index 02e197a28eb..589ae6e3d7c 100644 --- a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php +++ b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php @@ -115,6 +115,27 @@ public function revertNonNullability(MutatingScope $scope, array $specifiedExpre return $scope; } + /** + * Walk a nullsafe chain (NullsafePropertyFetch/NullsafeMethodCall) and narrow + * each intermediate var to non-null. Used so that method call arguments can see + * the narrowed types without affecting the scope during var processing. + */ + public function narrowNullsafeVarChain(MutatingScope $scope, Expr $expr): EnsuredNonNullabilityResult + { + $specifiedExpressions = []; + $currentExpr = $expr; + while ($currentExpr instanceof Expr\NullsafePropertyFetch || $currentExpr instanceof Expr\NullsafeMethodCall) { + $result = $this->ensureShallowNonNullability($scope, $scope, $currentExpr->var); + $scope = $result->getScope(); + foreach ($result->getSpecifiedExpressions() as $specifiedExpression) { + $specifiedExpressions[] = $specifiedExpression; + } + $currentExpr = $currentExpr->var; + } + + return new EnsuredNonNullabilityResult($scope, $specifiedExpressions); + } + /** * @param Closure(MutatingScope, Expr): MutatingScope $callback */ diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 56af96b2ed6..bc5cf5f0dfa 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -14,6 +14,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; +use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; @@ -59,6 +60,7 @@ final class MethodCallHandler implements ExprHandler public function __construct( private DynamicThrowTypeExtensionProvider $dynamicThrowTypeExtensionProvider, private MethodCallReturnTypeHelper $methodCallReturnTypeHelper, + private NonNullabilityHelper $nonNullabilityHelper, #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] private bool $implicitThrows, #[AutowiredParameter] @@ -139,6 +141,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || ($returnType instanceof NeverType && $returnType->isExplicit()); } + // For virtual nullsafe method calls, narrow the vars in the nullsafe + // chain so arguments see non-null types. E.g. in $a?->b?->method($a), + // $a must be non-null when method() is reached. + $nullsafeNarrowingResult = null; + if ($expr->getAttribute('virtualNullsafeMethodCall') === true) { + $nullsafeNarrowingResult = $this->nonNullabilityHelper->narrowNullsafeVarChain($scope, $expr->var); + $scope = $nullsafeNarrowingResult->getScope(); + } + $argsResult = $nodeScopeResolver->processArgs( $stmt, $methodReflection, @@ -152,6 +163,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); $scope = $argsResult->getScope(); + if ($nullsafeNarrowingResult !== null) { + $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nullsafeNarrowingResult->getSpecifiedExpressions()); + } + if ($methodReflection !== null) { $methodThrowPoint = $this->getMethodThrowPoint($methodReflection, $parametersAcceptor, $expr, $scope); if ($methodThrowPoint !== null) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-6934.php b/tests/PHPStan/Analyser/nsrt/bug-6934.php new file mode 100644 index 00000000000..9983490e429 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-6934.php @@ -0,0 +1,26 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug6934; + +use DOMNode; +use function PHPStan\Testing\assertType; + +function removeFromParent(?DOMNode $node): void { + $node?->parentNode?->removeChild($node); +} + +function testNarrowing(?DOMNode $node): void { + $node?->parentNode?->removeChild(assertType('DOMNode', $node)); +} + +class Foo { + public function doSomething(): string { + return 'hello'; + } +} + +function testNullsafeChainArgs(?Foo $foo): void { + $foo?->doSomething(assertType('Bug6934\Foo', $foo)); +} From 658bce3bbefe04cc5401151eb4664a582c38cd9a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 30 Mar 2026 06:35:22 +0000 Subject: [PATCH 2/8] Address review: add rule test and post-chain type assertions for bug-6934 - Add CallMethodsRule test to verify no parameter type error on $node?->parentNode?->removeChild($node) - Assert $node remains DOMNode|null after the nullsafe chain - Assert $foo remains Foo|null after the nullsafe chain Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-6934.php | 2 ++ tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php | 9 +++++++++ tests/PHPStan/Rules/Methods/data/bug-6934.php | 11 +++++++++++ 3 files changed, 22 insertions(+) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-6934.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-6934.php b/tests/PHPStan/Analyser/nsrt/bug-6934.php index 9983490e429..7827e1bbe3f 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6934.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6934.php @@ -9,6 +9,7 @@ function removeFromParent(?DOMNode $node): void { $node?->parentNode?->removeChild($node); + assertType('DOMNode|null', $node); } function testNarrowing(?DOMNode $node): void { @@ -23,4 +24,5 @@ public function doSomething(): string { function testNullsafeChainArgs(?Foo $foo): void { $foo?->doSomething(assertType('Bug6934\Foo', $foo)); + assertType('Bug6934\Foo|null', $foo); } diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 26ffbe0fc9e..fc0133aae34 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3945,6 +3945,15 @@ public function testBug7369(): void ]); } + #[RequiresPhp('>= 8.0')] + public function testBug6934(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-6934.php'], []); + } + public function testBug11463(): void { $this->checkThisOnly = false; diff --git a/tests/PHPStan/Rules/Methods/data/bug-6934.php b/tests/PHPStan/Rules/Methods/data/bug-6934.php new file mode 100644 index 00000000000..6da70cbe13a --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-6934.php @@ -0,0 +1,11 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug6934Rule; + +use DOMNode; + +function removeFromParent(?DOMNode $node): void { + $node?->parentNode?->removeChild($node); +} From 10db966d23ae45af271d1fc2ea2cf716ceb4d2c4 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 30 Mar 2026 07:06:12 +0000 Subject: [PATCH 3/8] Remove unnecessary #[RequiresPhp] from testBug6934 RuleTestCase uses php-parser which can parse nullsafe syntax on any PHP version, so #[RequiresPhp('>= 8.0')] is not needed. Other nullsafe tests in this file (e.g. testNullSafe) run without this attribute. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index fc0133aae34..0c7113ccf83 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3945,7 +3945,6 @@ public function testBug7369(): void ]); } - #[RequiresPhp('>= 8.0')] public function testBug6934(): void { $this->checkThisOnly = false; From 9f728c1d10d4afe0477ad17a68ffac17094ea0c8 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 30 Mar 2026 07:17:57 +0000 Subject: [PATCH 4/8] Add back #[RequiresPhp('>= 8.0')] to testBug6934 The test data file uses DOMNode which has version-dependent property types via #[LanguageLevelTypeAware] in phpstorm-stubs. On PHP < 8.0, the analysis may produce different errors since DOMNode::$parentNode lacks a native type declaration. The // lint >= 8.0 comment in the test data file is only processed by TypeInferenceTestCase, not by RuleTestCase::analyse(), so #[RequiresPhp] is needed to skip the test on older PHP versions. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 0c7113ccf83..fc0133aae34 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3945,6 +3945,7 @@ public function testBug7369(): void ]); } + #[RequiresPhp('>= 8.0')] public function testBug6934(): void { $this->checkThisOnly = false; From e82198a0c05980b030d535cc6d722da0ec399029 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 30 Mar 2026 11:14:10 +0200 Subject: [PATCH 5/8] Deduplicate files --- tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php | 2 +- tests/PHPStan/Rules/Methods/data/bug-6934.php | 11 ----------- 2 files changed, 1 insertion(+), 12 deletions(-) delete mode 100644 tests/PHPStan/Rules/Methods/data/bug-6934.php diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index fc0133aae34..8c4428341dd 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3951,7 +3951,7 @@ public function testBug6934(): void $this->checkThisOnly = false; $this->checkNullables = true; $this->checkUnionTypes = true; - $this->analyse([__DIR__ . '/data/bug-6934.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-6934.php'], []); } public function testBug11463(): void diff --git a/tests/PHPStan/Rules/Methods/data/bug-6934.php b/tests/PHPStan/Rules/Methods/data/bug-6934.php deleted file mode 100644 index 6da70cbe13a..00000000000 --- a/tests/PHPStan/Rules/Methods/data/bug-6934.php +++ /dev/null @@ -1,11 +0,0 @@ -= 8.0 - -declare(strict_types = 1); - -namespace Bug6934Rule; - -use DOMNode; - -function removeFromParent(?DOMNode $node): void { - $node?->parentNode?->removeChild($node); -} From 03a0d6b253801bd89f2a45e682fde3921a6bf959 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 30 Mar 2026 11:56:56 +0200 Subject: [PATCH 6/8] Update bug-6934.php --- tests/PHPStan/Analyser/nsrt/bug-6934.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-6934.php b/tests/PHPStan/Analyser/nsrt/bug-6934.php index 7827e1bbe3f..7829cdec569 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6934.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6934.php @@ -17,7 +17,7 @@ function testNarrowing(?DOMNode $node): void { } class Foo { - public function doSomething(): string { + public function doSomething($mixed): string { return 'hello'; } } From 53b1634cb58445d3a39c631bdac03a7860aae318 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 30 Mar 2026 12:33:59 +0200 Subject: [PATCH 7/8] Add assert --- tests/PHPStan/Analyser/nsrt/bug-6934.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-6934.php b/tests/PHPStan/Analyser/nsrt/bug-6934.php index 7829cdec569..a2cdc660bd5 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6934.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6934.php @@ -10,6 +10,7 @@ function removeFromParent(?DOMNode $node): void { $node?->parentNode?->removeChild($node); assertType('DOMNode|null', $node); + assertType('DOMNode|null', $node?->parentNode); } function testNarrowing(?DOMNode $node): void { From 9f51d3929e576bfe001c8dd5519e6a09e60f8820 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Tue, 31 Mar 2026 00:02:15 +0200 Subject: [PATCH 8/8] Add test --- tests/PHPStan/Analyser/nsrt/bug-6934.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-6934.php b/tests/PHPStan/Analyser/nsrt/bug-6934.php index a2cdc660bd5..bbf2e023cf2 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6934.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6934.php @@ -9,12 +9,15 @@ function removeFromParent(?DOMNode $node): void { $node?->parentNode?->removeChild($node); + $node?->removeChild($node); + assertType('DOMNode|null', $node); assertType('DOMNode|null', $node?->parentNode); } function testNarrowing(?DOMNode $node): void { $node?->parentNode?->removeChild(assertType('DOMNode', $node)); + $node?->removeChild(assertType('DOMNode', $node)); } class Foo {