Skip to content

Consider class and constant finality in ClassConstantAccessType::getResult() for static::CONST PHPDoc types#5570

Open
phpstan-bot wants to merge 2 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-35asf4c
Open

Consider class and constant finality in ClassConstantAccessType::getResult() for static::CONST PHPDoc types#5570
phpstan-bot wants to merge 2 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-35asf4c

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

The ClassConstantAccessType (used for @return static::CONST PHPDoc types) was always resolving to the concrete constant value from the declaring class, regardless of whether the class or constant was final. This meant subclasses that override the constant would still show the parent class's value — e.g., $foo->test() returning 'foo' even though $foo could be a subclass with a different constant value.

Changes

  • src/Type/ClassConstantAccessType.php:
    • getResult() now checks class/constant finality before resolving:
      • Final class or final constant → returns concrete value via getValueType()
      • Non-final class with typed constant (PHPDoc or native type) → returns declared type via getValueType()
      • Non-final class with untyped, non-final constant → returns MixedType
    • Added isSubTypeOf() override that uses getValueType() directly (not the resolved type), ensuring the ClassConstantAccessType passes through TypehintHelper::decideType() and is preserved in the method's return type for later StaticType resolution
    • Added isAcceptedBy() override with the same approach for consistency
  • tests/PHPStan/Analyser/nsrt/bug-13828.php: Updated 3 assertions that incorrectly expected concrete constant values for non-final classes with untyped constants
  • tests/PHPStan/Analyser/nsrt/bug-6989.php: Updated 2 assertions where static::CONST was used as array shape keys on non-final classes — these now correctly resolve to non-empty-array<string> instead of array{key: string}
  • tests/PHPStan/Analyser/nsrt/bug-14556.php: New regression test from the issue's playground sample

Root cause

ClassConstantAccessType::getResult() unconditionally called getValueType(), which for untyped constants returns the concrete initializer value. This is correct only when the class is final (no subclass can override the constant) or the constant is final (PHP 8.2+). For non-final classes with non-final constants, static::CONST could resolve to a different value at runtime, so the type must be the declared type constraint (PHPDoc/native type) or mixed if none exists. This mirrors the existing logic in InitializerExprTypeResolver (lines 2569–2595) which already handles this correctly for static::CONST expressions in PHP code.

An additional subtlety: ClassConstantAccessType must survive TypehintHelper::decideType() to allow later StaticType resolution when the method is called on a specific (potentially final) class. The isSubTypeOf() and isAcceptedBy() overrides use the base class's getValueType() for compatibility checks, ensuring the type is preserved through decideType() while getResult() returns the correctly-constrained type.

Analogous cases probed

  • self::CONST and parent::CONST in PHPDocs: Not affected — TypeNodeResolver doesn't create ClassConstantAccessType for these, resolving them directly to concrete values (correct since self/parent always refer to the lexical class).
  • Wildcard patterns (static::FOO_*): These also resolve concrete values eagerly without ClassConstantAccessType, so they have a similar potential issue for non-final classes. However, fixing them requires a different approach (they don't use ClassConstantAccessType at all) and is out of scope for this PR.
  • Typed constants (PHPDoc or native type): Already correct — getValueType() returns the declared type, which is the right constraint for non-final classes.
  • Final constants: Already correct — the fix preserves the concrete value for final const.

Test

  • bug-14556.php: Regression test with FooBar (non-final, untyped constant → mixed), BarBaz (non-final subclass → mixed), and FinalBarBaz (final subclass → 'bar')
  • Updated assertions in bug-13828.php and bug-6989.php that were asserting the buggy behavior

Fixes phpstan/phpstan#14556

…Result()` for `static::CONST` PHPDoc types

- Fix `ClassConstantAccessType::getResult()` to check whether the class is
  final or the constant is final before returning the concrete constant value.
  For non-final classes with non-final, untyped constants, return `MixedType`
  instead of the concrete value, matching the behavior of
  `InitializerExprTypeResolver` for `static::CONST` expressions in code.
- Override `isSubTypeOf()` and `isAcceptedBy()` to use `getValueType()`
  directly, ensuring `ClassConstantAccessType` survives
  `TypehintHelper::decideType()` and can be resolved correctly after
  `StaticType` is substituted with the caller's concrete type.
- Update test assertions in `bug-13828.php` and `bug-6989.php` that
  incorrectly expected concrete constant values for non-final classes.
- Add regression test `bug-14556.php` for the reported issue.
Copy link
Copy Markdown
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also toPhpDocNode is wrong and assumes static but the actual type is $this->type and might be different. Write some tests for TypeToPhpDocNodeTest.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

I processed this review but have nothing to report.

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.

2 participants