Skip to content

Implement AccessoryDecimalIntegerStringType::tryRemove() and preserve accessories when re-unioning '0'#5816

Merged
staabm merged 10 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-2f7geta
Jun 12, 2026
Merged

Implement AccessoryDecimalIntegerStringType::tryRemove() and preserve accessories when re-unioning '0'#5816
staabm merged 10 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-2f7geta

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Removing the constant string '0' from a decimal-int-string did not change the
type — PHPStan still considered the result possibly falsy, even though "0" is the
only falsy decimal integer string. After if ($s !== '0') (or any truthy
narrowing), $s should be decimal-int-string&non-falsy-string. This PR makes that
narrowing work and also fixes the inverse simplification so unioning '0' back in
collapses to the original type.

Changes

  • src/Type/Accessory/AccessoryDecimalIntegerStringType.php: dropped
    NonRemoveableTypeTrait and implemented tryRemove(). For a non-inverse
    decimal-int-string, removing the constant '0' returns
    string & decimal-int-string & non-falsy-string. The check uses
    Type::getConstantStrings() rather than instanceof ConstantStringType.
  • src/Type/TypeCombinator.php: replaced the describe()-based '0' | …
    union-simplification branches with a new downgradeNonFalsyStringToNonEmpty()
    helper that preserves all accessory refinements (numeric-string,
    decimal-int-string, lowercase-string, …) while downgrading the non-falsy flag to
    non-empty, only adding AccessoryNonEmptyStringType when no remaining accessory
    already implies non-emptiness. Bumped the corresponding instanceof IntersectionType
    baseline count.
  • tests/PHPStan/Analyser/nsrt/decimal-int-string.php: added regression assertions.

Root cause

Accessory string types encode refinements that interact with falsy-ness.
AccessoryNonEmptyStringType and AccessoryNumericStringType already convert to a
non-falsy variant when '0' is removed (their tryRemove()), but
AccessoryDecimalIntegerStringType used NonRemoveableTypeTrait and so silently
ignored the removal — the same family bug, one sibling left unfixed.

The mirror operation (union) had a parallel gap: compareTypesInUnion() only
recognized the exact non-falsy-string description when folding '0' back in, so
any non-falsy string that also carried another accessory
(numeric-string&non-falsy-string, the decimal-int variant, lowercase, …) was left
as an un-simplified '0' | (…&non-falsy-string) union. Matching on describe() is
exactly the fragile pattern called out in CLAUDE.md; the new helper works
structurally on the intersection members instead.

Test

tests/PHPStan/Analyser/nsrt/decimal-int-string.php now asserts:

  • decimal-int-string narrows to decimal-int-string&non-falsy-string after
    !== '0', != '0', and truthy checks; the negative branch is '0'.
  • non-decimal-int-string is unchanged by removing '0' (it never contains '0').
  • '0' | … round-trips: decimal-int-string, numeric-string and
    non-empty-string each collapse back to themselves after $x !== '0' ? $x : '0'.

All three round-trip and removal assertions fail on 2.2.x without the source
changes and pass with them. Full make tests and make phpstan are green.

Fixes phpstan/phpstan#14785

Comment thread tests/PHPStan/Analyser/nsrt/decimal-int-string.php
Comment thread tests/PHPStan/Analyser/nsrt/decimal-int-string.php
@staabm staabm force-pushed the create-pull-request/patch-2f7geta branch from 4cf1a1b to 6746dc6 Compare June 7, 2026 07:52
Comment thread src/Type/Accessory/AccessoryDecimalIntegerStringType.php
@staabm staabm requested a review from VincentLanglet June 7, 2026 08:25
@staabm staabm force-pushed the create-pull-request/patch-2f7geta branch from d323c7a to e4337cb Compare June 12, 2026 05:19
phpstan-bot and others added 10 commits June 12, 2026 09:02
…ve accessories when re-unioning `'0'`

- `AccessoryDecimalIntegerStringType` previously used `NonRemoveableTypeTrait`, so
  removing the constant string `'0'` from a `decimal-int-string` left the type
  unchanged. `"0"` is the only falsy decimal integer string, so removing it now
  yields `decimal-int-string&non-falsy-string` (mirrors the existing handling in
  `AccessoryNonEmptyStringType`/`AccessoryNumericStringType`).
- Generalized the `'0' | non-falsy-string` union simplification in
  `TypeCombinator::compareTypesInUnion()`. The old code matched only the bare
  `non-falsy-string` via `describe()` string comparison, so
  `'0' | (numeric-string&non-falsy-string)` and the decimal-int-string variant
  were left as unsimplified unions. New helper `downgradeNonFalsyStringToNonEmpty()`
  drops the `AccessoryNonFalsyStringType`, keeps every other accessory, and only
  re-adds `AccessoryNonEmptyStringType` when the remaining accessories do not
  already guarantee non-emptiness — so the round trip collapses back to
  `decimal-int-string` / `numeric-string` / `non-empty-string`.
- Probed sibling accessory string types: `AccessoryLowercaseStringType`,
  `AccessoryUppercaseStringType` and `AccessoryLiteralStringType` can still be the
  empty string, so removing `'0'` must not make them non-falsy — they correctly
  remain `NonRemoveableTypeTrait`. The inverse `non-decimal-int-string` does not
  contain `'0'`, so its removal is a no-op.
…sy-string

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… for removing '0'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…Remove()

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-2f7geta branch from 84b5fb4 to a15e6b7 Compare June 12, 2026 07:02
@staabm

staabm commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

@VincentLanglet please have a look when time allows :)

@staabm

staabm commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

CI errors are pre-existing

Comment thread src/Type/Accessory/AccessoryDecimalIntegerStringType.php
@VincentLanglet VincentLanglet requested a review from staabm June 12, 2026 08:59
@staabm staabm merged commit aa4ea74 into phpstan:2.2.x Jun 12, 2026
671 of 675 checks passed
@staabm staabm deleted the create-pull-request/patch-2f7geta branch June 12, 2026 08:59
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.

removing '0' from decimal-int-string should make it non-falsey

3 participants