Narrow ctype_digit() argument to numeric-string instead of decimal-int-string#5822
Merged
staabm merged 3 commits intoJun 7, 2026
Merged
Conversation
…al-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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ctype_digit($x)returningtruewas narrowing$xtodecimal-int-string. This is unsound:ctype_digit('02')istrue, but'02'is not a decimal-int-string because of the leading zero. As a result PHPStan wrongly reported comparisons such as$x === '02'as always false inside actype_digit()guard.This change narrows the argument to
numeric-stringinstead, which is the tightest accessory type that is actually guaranteed: every string accepted byctype_digit()is a non-empty string composed solely of decimal digits, hence anumeric-string(which already implies non-empty).Changes
src/Type/Php/CtypeDigitFunctionTypeSpecifyingExtension.php:string & numeric-string(AccessoryNumericStringType) instead ofstring & decimal-int-string(AccessoryDecimalIntegerStringType).(string)cast branch, stop adding thedecimal-int-stringaccessory on top of the already-presentnumeric-string.AccessoryDecimalIntegerStringTypeimport.tests/PHPStan/Analyser/nsrt/bug-14792.php: new regression test asserting the narrowed type isnumeric-string.tests/PHPStan/Analyser/nsrt/ctype-digit.php: updated expectations fromdecimal-int-stringtonumeric-string.tests/PHPStan/Analyser/nsrt/callsite-cast-narrowing.php: updated the(string)cast expectations accordingly.Root cause
decimal-int-string(AccessoryDecimalIntegerStringType) represents strings that are canonical decimal integers — no redundant leading zeros ("0","1","-1","1234").ctype_digit()only guarantees that the string is non-empty and made of ASCII digits0-9; values like"02","00","007"passctype_digit()but are explicitly listed as non-decimal-int-strings. The extension was conflating "all digits" with "canonical decimal integer". The correct, sound over-approximation isnumeric-string.Analogous cases probed
RegexGroupParser(\d+,[0-9]+,\d{2},0[0-9], …) —src/Type/Regex/RegexGroupParser.phpnarrows regex subjects and capture groups todecimal-int-stringwhenever the matched text is "all digits". This has the same leading-zero unsoundness (preg_match('/^(\d+)$/', $x, $m)types$m[1]asdecimal-int-string, so$m[1] === '02'is wrongly flagged as always-false). I confirmed the bug with reproducers. A correct fix must keep the precisedecimal-int-stringfor patterns that structurally forbid leading zeros (e.g.[1-9][0-9]*, the cases added in the recently merged Narrow regex subject todecimal-int-stringwhen every alternation branch is a decimal integer #5814) while downgrading the genuinely-ambiguous ones tonumeric-string. That requires per-character-class leading-zero analysis (a class such as[5-90-3]can still match0), spanning ranges, quantifiers, alternations and anchors. Because that subsystem was introduced very recently and its own tests currently encode the leading-zero behavior, the regex fix is intentionally left to a dedicated change rather than bundled here.ctype_*functions (ctype_alpha,ctype_xdigit, …) — none of them narrow todecimal-int-string, so they are not affected.Test
tests/PHPStan/Analyser/nsrt/bug-14792.phpreproduces the reported case (ctype_digit($x)followed by$x === '02') and asserts$xisnumeric-string. It fails before the fix (inferreddecimal-int-string) and passes after.ctype-digit.phpandcallsite-cast-narrowing.phpexpectations were updated to the correctednumeric-stringtype and pass.Fixes phpstan/phpstan#14792