Skip to content

Narrow ctype_digit() argument to numeric-string instead of decimal-int-string#5822

Merged
staabm merged 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-cqdd8po
Jun 7, 2026
Merged

Narrow ctype_digit() argument to numeric-string instead of decimal-int-string#5822
staabm merged 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-cqdd8po

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

ctype_digit($x) returning true was narrowing $x to decimal-int-string. This is unsound: ctype_digit('02') is true, 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 a ctype_digit() guard.

This change narrows the argument to numeric-string instead, which is the tightest accessory type that is actually guaranteed: every string accepted by ctype_digit() is a non-empty string composed solely of decimal digits, hence a numeric-string (which already implies non-empty).

Changes

  • src/Type/Php/CtypeDigitFunctionTypeSpecifyingExtension.php:
    • In the truthy branch, narrow the argument to string & numeric-string (AccessoryNumericStringType) instead of string & decimal-int-string (AccessoryDecimalIntegerStringType).
    • In the (string) cast branch, stop adding the decimal-int-string accessory on top of the already-present numeric-string.
    • Removed the now-unused AccessoryDecimalIntegerStringType import.
  • tests/PHPStan/Analyser/nsrt/bug-14792.php: new regression test asserting the narrowed type is numeric-string.
  • tests/PHPStan/Analyser/nsrt/ctype-digit.php: updated expectations from decimal-int-string to numeric-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 digits 0-9; values like "02", "00", "007" pass ctype_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 is numeric-string.

Analogous cases probed

  • RegexGroupParser (\d+, [0-9]+, \d{2}, 0[0-9], …)src/Type/Regex/RegexGroupParser.php narrows regex subjects and capture groups to decimal-int-string whenever the matched text is "all digits". This has the same leading-zero unsoundness (preg_match('/^(\d+)$/', $x, $m) types $m[1] as decimal-int-string, so $m[1] === '02' is wrongly flagged as always-false). I confirmed the bug with reproducers. A correct fix must keep the precise decimal-int-string for patterns that structurally forbid leading zeros (e.g. [1-9][0-9]*, the cases added in the recently merged Narrow regex subject to decimal-int-string when every alternation branch is a decimal integer #5814) while downgrading the genuinely-ambiguous ones to numeric-string. That requires per-character-class leading-zero analysis (a class such as [5-90-3] can still match 0), 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.
  • Other ctype_* functions (ctype_alpha, ctype_xdigit, …) — none of them narrow to decimal-int-string, so they are not affected.

Test

  • tests/PHPStan/Analyser/nsrt/bug-14792.php reproduces the reported case (ctype_digit($x) followed by $x === '02') and asserts $x is numeric-string. It fails before the fix (inferred decimal-int-string) and passes after.
  • Existing ctype-digit.php and callsite-cast-narrowing.php expectations were updated to the corrected numeric-string type and pass.

Fixes phpstan/phpstan#14792

phpstan-bot and others added 3 commits June 7, 2026 15:30
…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.
@staabm staabm merged commit e17e049 into phpstan:2.2.x Jun 7, 2026
656 of 670 checks passed
@staabm staabm deleted the create-pull-request/patch-cqdd8po branch June 7, 2026 16:10
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.

ctype_digit($x) is true does not imply that $x is decimal-int-string

2 participants