Skip to content

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

Open
phpstan-bot wants to merge 9 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-2f7geta
Open

Implement AccessoryDecimalIntegerStringType::tryRemove() and preserve accessories when re-unioning '0'#5816
phpstan-bot wants to merge 9 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
phpstan-bot and others added 4 commits June 7, 2026 09:52
…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>
@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
phpstan-bot and others added 2 commits June 7, 2026 08:32
…Remove()

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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

2 participants