From 2ec19e23b0e0a7813a2855e4d7a18d61ddcb3ebc Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 23 Sep 2026 16:34:26 +0200 Subject: [PATCH 1/2] Keep a by-ref writeback's native type only when the written-back type fits it After a by-reference argument, the native type of the variable became the parameter's own type: its declaration, or for a builtin its signature map entry. PHP checks a declaration only on the way in and a signature map entry not at all, so neither describes what the call writes back when the two disagree. preg_match() with PREG_OFFSET_CAPTURE writes arrays into a slot the signature map declares as string[]. The guard `if (! preg_match(...))` then intersected the offset-capture shape with array, the native type became never, and with treatPhpDocTypesAsCertain: false isset($m[2]) reported "Offset 2 on *NEVER* in isset() always exists and is not nullable". The same happens to a userland @param-out that contradicts its declaration. The native type now falls back to mixed when the written-back type is not a subtype of the declaration. Where they agree, as in #6463's own cases, nothing changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Analyser/NodeScopeResolver.php | 11 ++++- .../nsrt/by-ref-writeback-native-type.php | 41 +++++++++++++++++++ .../PHPStan/Rules/Variables/IssetRuleTest.php | 7 ++++ .../data/preg-match-offset-capture-isset.php | 12 ++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/by-ref-writeback-native-type.php create mode 100644 tests/PHPStan/Rules/Variables/data/preg-match-offset-capture-isset.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index f8747d712cf..b19eb3145cd 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2368,11 +2368,18 @@ public function processArgs( } // what the call writes back is described by PHPDoc (@param, @param-out, - // a parameter-out extension) - natively only the parameter's own - // type declaration is guaranteed + // a parameter-out extension) - natively only the parameter's own type + // can describe it: its declaration, or for a builtin its signature map + // entry. PHP checks a declaration only on the way in and a signature + // map entry not at all, so it holds only when the written-back type + // fits inside it. preg_match() with PREG_OFFSET_CAPTURE writes arrays + // into a slot the signature map declares as string[]. $byRefNativeType = $currentParameter instanceof ExtendedParameterReflection ? $currentParameter->getNativeType() : $byRefType; + if (!$byRefNativeType->isSuperTypeOf($byRefType)->yes()) { + $byRefNativeType = new MixedType(); + } $scope = $this->processVirtualAssign( $scope, diff --git a/tests/PHPStan/Analyser/nsrt/by-ref-writeback-native-type.php b/tests/PHPStan/Analyser/nsrt/by-ref-writeback-native-type.php new file mode 100644 index 00000000000..6ebab6d0b0d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/by-ref-writeback-native-type.php @@ -0,0 +1,41 @@ +}, 1: array{'a', int<-1, max>}, 2?: array{'b', int<-1, max>}}", $m); + assertNativeType("array{0: array{non-falsy-string, int<-1, max>}, 1: array{'a', int<-1, max>}, 2?: array{'b', int<-1, max>}}", $m); +} + +function noFlags(string $s): void +{ + if (! preg_match('/(a)(b)?/', $s, $m)) { + return; + } + + assertType("array{0: non-falsy-string, 1: 'a', 2?: 'b'}", $m); + assertNativeType("array{0: non-falsy-string, 1: 'a', 2?: 'b'}", $m); +} + +/** @param-out string $v */ +function paramOutContradictsDeclaration(int &$v): void +{ + $v = 'now a string'; +} + +function userlandParamOut(): void +{ + $x = 1; + paramOutContradictsDeclaration($x); + + assertType('string', $x); + assertNativeType('mixed', $x); +} diff --git a/tests/PHPStan/Rules/Variables/IssetRuleTest.php b/tests/PHPStan/Rules/Variables/IssetRuleTest.php index 61a9e57ed0f..4d1a7c42d32 100644 --- a/tests/PHPStan/Rules/Variables/IssetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/IssetRuleTest.php @@ -671,4 +671,11 @@ public function testBug14416(): void $this->analyse([__DIR__ . '/data/bug-14416.php'], []); } + public function testPregMatchOffsetCaptureWithoutTreatPhpDocTypesAsCertain(): void + { + $this->treatPhpDocTypesAsCertain = false; + + $this->analyse([__DIR__ . '/data/preg-match-offset-capture-isset.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/preg-match-offset-capture-isset.php b/tests/PHPStan/Rules/Variables/data/preg-match-offset-capture-isset.php new file mode 100644 index 00000000000..5d7b461b896 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/preg-match-offset-capture-isset.php @@ -0,0 +1,12 @@ + Date: Wed, 23 Sep 2026 21:47:02 +0200 Subject: [PATCH 2/2] Check the by-ref native type only when the parameter declares one When the parameter is not an ExtendedParameterReflection, the native type is the written-back type itself, and the check has nothing to decide. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Analyser/NodeScopeResolver.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index b19eb3145cd..8776e7c4aba 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2374,11 +2374,12 @@ public function processArgs( // map entry not at all, so it holds only when the written-back type // fits inside it. preg_match() with PREG_OFFSET_CAPTURE writes arrays // into a slot the signature map declares as string[]. - $byRefNativeType = $currentParameter instanceof ExtendedParameterReflection - ? $currentParameter->getNativeType() - : $byRefType; - if (!$byRefNativeType->isSuperTypeOf($byRefType)->yes()) { - $byRefNativeType = new MixedType(); + $byRefNativeType = $byRefType; + if ($currentParameter instanceof ExtendedParameterReflection) { + $byRefNativeType = $currentParameter->getNativeType(); + if (!$byRefNativeType->isSuperTypeOf($byRefType)->yes()) { + $byRefNativeType = new MixedType(); + } } $scope = $this->processVirtualAssign(