From b409058baefef502616de178fd684559d5d5b609 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:30:39 +0000 Subject: [PATCH 1/3] Narrow `ctype_digit()` argument to `numeric-string` instead of `decimal-int-string` - `CtypeDigitFunctionTypeSpecifyingExtension` narrowed the argument of a truthy `ctype_digit()` call to `decimal-int-string`. That is unsound: `ctype_digit('02')` is `true`, but `'02'` is not a decimal-int-string (leading zero), so comparisons like `$x === '02'` were wrongly reported as always-false. - A string for which `ctype_digit()` returns `true` is always a non-empty all-digit string, i.e. a `numeric-string` (which already implies non-empty). That is the tightest accessory super-type available, so the narrowing now uses `AccessoryNumericStringType`. - Applied the same change to the `(string)` cast branch, which also added the `decimal-int-string` accessory on top of the already-present `numeric-string`. - Updated the affected type-inference expectations in `ctype-digit.php` and `callsite-cast-narrowing.php`. - Probed the parallel narrowing in `RegexGroupParser` (`\d+`, `[0-9]+`, etc. narrowed to `decimal-int-string`): it has the same leading-zero unsoundness, but a correct fix requires class-level leading-zero analysis across ranges/quantifiers/alternations and is left for a dedicated change to that recently introduced subsystem. --- ...ypeDigitFunctionTypeSpecifyingExtension.php | 9 ++++----- tests/PHPStan/Analyser/nsrt/bug-14792.php | 18 ++++++++++++++++++ .../Analyser/nsrt/callsite-cast-narrowing.php | 4 ++-- tests/PHPStan/Analyser/nsrt/ctype-digit.php | 6 +++--- 4 files changed, 27 insertions(+), 10 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14792.php diff --git a/src/Type/Php/CtypeDigitFunctionTypeSpecifyingExtension.php b/src/Type/Php/CtypeDigitFunctionTypeSpecifyingExtension.php index bc44dd244f..f4a9f2f8a3 100644 --- a/src/Type/Php/CtypeDigitFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/CtypeDigitFunctionTypeSpecifyingExtension.php @@ -12,7 +12,6 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; use PHPStan\ShouldNotHappenException; -use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\FunctionTypeSpecifyingExtension; @@ -55,9 +54,12 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n ]; if ($context->true()) { + // ctype_digit() is true for any non-empty string consisting solely of + // decimal digits, which includes leading-zero strings like "02" that are + // not decimal-int-strings. The closest accessory super-type is numeric-string. $types[] = new IntersectionType([ new StringType(), - new AccessoryDecimalIntegerStringType(), + new AccessoryNumericStringType(), ]); } @@ -69,9 +71,6 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n new StringType(), new AccessoryNumericStringType(), ]; - if ($context->true()) { - $accessories[] = new AccessoryDecimalIntegerStringType(); - } $castedType = new UnionType([ IntegerRangeType::fromInterval(0, null), new IntersectionType($accessories), diff --git a/tests/PHPStan/Analyser/nsrt/bug-14792.php b/tests/PHPStan/Analyser/nsrt/bug-14792.php new file mode 100644 index 0000000000..bdc7e6b931 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14792.php @@ -0,0 +1,18 @@ +|decimal-int-string|true', $mixed); + assertType('int<0, max>|numeric-string|true', $mixed); } else { assertType('mixed~(int<0, max>|numeric-string|true)', $mixed); } @@ -41,7 +41,7 @@ public function sayHello($mixed, int $int, string $string, $numericString, $nonE assertType('int', $int); if (ctype_digit((string) $string)) { - assertType('decimal-int-string', $string); + assertType('numeric-string', $string); } else { assertType('string', $string); } diff --git a/tests/PHPStan/Analyser/nsrt/ctype-digit.php b/tests/PHPStan/Analyser/nsrt/ctype-digit.php index 29ae49ee95..081b189796 100644 --- a/tests/PHPStan/Analyser/nsrt/ctype-digit.php +++ b/tests/PHPStan/Analyser/nsrt/ctype-digit.php @@ -14,7 +14,7 @@ public function foo(mixed $foo): void assertType('mixed', $foo); if (is_string($foo) && ctype_digit($foo)) { - assertType('decimal-int-string', $foo); + assertType('numeric-string', $foo); } else { assertType('mixed', $foo); } @@ -26,7 +26,7 @@ public function foo(mixed $foo): void } if (ctype_digit($foo)) { - assertType('int<48, 57>|int<256, max>|decimal-int-string', $foo); + assertType('int<48, 57>|int<256, max>|numeric-string', $foo); return; } @@ -36,7 +36,7 @@ public function foo(mixed $foo): void public function doString(string $string): void { if (ctype_digit($string) === true) { - assertType('decimal-int-string', $string); + assertType('numeric-string', $string); } else { assertType('string', $string); } From 75c43f8e393dedf0a800f53f9e9a971050bce0d7 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 7 Jun 2026 17:41:31 +0200 Subject: [PATCH 2/3] Update NonexistentOffsetInArrayDimFetchRuleTest.php --- .../Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index a87b9eaedd..3129028a6f 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1328,12 +1328,7 @@ public function testBug9240(): void public function testBug14758(): void { - $this->analyse([__DIR__ . '/data/bug-14758.php'], [ - [ - 'Offset decimal-int-string does not exist on array.', - 11, - ], - ]); + $this->analyse([__DIR__ . '/data/bug-14758.php'], []); } #[RequiresPhp('>= 8.4.0')] From c95ca8ae87645f6ff6c9606d2da59d2dfeb32291 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 7 Jun 2026 17:43:40 +0200 Subject: [PATCH 3/3] Update CtypeDigitFunctionTypeSpecifyingExtension.php --- src/Type/Php/CtypeDigitFunctionTypeSpecifyingExtension.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Type/Php/CtypeDigitFunctionTypeSpecifyingExtension.php b/src/Type/Php/CtypeDigitFunctionTypeSpecifyingExtension.php index f4a9f2f8a3..562dc20079 100644 --- a/src/Type/Php/CtypeDigitFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/CtypeDigitFunctionTypeSpecifyingExtension.php @@ -54,9 +54,6 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n ]; if ($context->true()) { - // ctype_digit() is true for any non-empty string consisting solely of - // decimal digits, which includes leading-zero strings like "02" that are - // not decimal-int-strings. The closest accessory super-type is numeric-string. $types[] = new IntersectionType([ new StringType(), new AccessoryNumericStringType(),