Skip to content

Check class constant access through constant class-name strings and class-string unions - #5980

Open
gnutix wants to merge 1 commit into
phpstan:2.3.xfrom
gnutix:class-constant-string-unions
Open

gnutix wants to merge 1 commit into
phpstan:2.3.xfrom
gnutix:class-constant-string-unions

Conversation

@gnutix

@gnutix gnutix commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #5976 (same root-cause family). ClassConstantRule had a legacy string bail-out that predates Type::isClassString() / Type::getClassStringObjectType(): for a $classString::CONST fetch where the class expression is a constant class-name string, a class-string<T>, or a union thereof, the rule silently returned once $classType->isString()->yes(), so a missing constant on one or all members went unreported.

Fix

Mirrors the TypeExpr routing from #5976, adapted to class constants. Before calling RuleLevelHelper::findTypeToCheck(), a class-string-typed class expression is converted via getClassStringObjectType() and wrapped in new TypeExpr($objectType) when the object type names at least one class that is not a trait. There is no separate isClassString()->yes() guard: for any type that is not entirely a class-string (objects, unions with non-class-string members, plain string), getClassStringObjectType() yields no class name, so the conversion is skipped by the same no-class-name check that keeps plain class-string and traits on the old path (a guard would be an equivalent mutant for Infection). This makes constant-string / class-string unions behave exactly like object-typed unions with respect to rule levels — per-member filtering when checkUnionTypes is off, whole-union check when it is on — and gives them the rest of the pipeline, including private/protected visibility.

  • Class-strings whose object type has no class name (plain class-string, class-string<object>, class-string<T of object>) are not rerouted and stay silent, as before.
  • Class-strings naming a trait are not rerouted either and stay silent, as before (RuleLevelHelper would report the trait as an unknown class).
  • There is no hasConstant() guard: ConstantStringType::hasConstant() answers for the class it names, so such a guard would skip exactly the constant strings whose constant exists, and lose the private/protected checks.

defined() guards

Routing class-strings through the rule surfaced a common idiom that was only silent because of the bail-out:

if (defined($class . '::ENTITY_CLASSNAME')) {
	return $class::ENTITY_CLASSNAME;
}

DefinedConstantTypeSpecifyingExtension only narrowed a constant argument (defined('Foo::BAR') narrows Foo::BAR). It now also recognizes $expr . '::NAME', "$expr::NAME" and "{$expr}::NAME" when $expr is a string, and narrows $expr::NAME the same way, so the rule's existing hasExpressionType() check skips the guarded fetch and its type becomes mixed instead of *ERROR* (a constant that exists keeps its type). This is the shape behind the neos/flow integration failure (ReflectionService::completeRepositoryAssignments()). Unguarded access to a constant that only implementations declare, through a class-string<SomeInterface>, is still reported — consistently with $object::CONST on an interface-typed object.

::class handling

The class pseudo-constant is deliberately excluded from the conversion (strtolower($constantName) !== 'class'). $string::class is a runtime TypeError (Cannot use "::class" on string), unlike $object::class, and the rule already reports this as classConstant.dynamicString. Rerouting a class-string through its object type would suppress that error, so the pseudo-constant is left on the existing string code path. Pinning tests cover $constantStringUnion::class and class-string<Foo>::class, both of which keep emitting classConstant.dynamicString. defined("$class::class") is not narrowed.

Tests

New data file class-constant-string-unions.php mirrors #5976's matrix: single constant string, constant-string union with one member missing the constant, get_class() union, single class-string<C>, class-string union, plain class-string (silent), the two ::class pinning cases, enum-case access through both a constant string and a class-string<Enum>, private/protected constants through a constant string, trait name (silent), defined() guards in the three forms plus an unguarded access, and the neos/flow shape (defined($c . '::X') && isset($map[$c::X]) over class-string<SomeInterface>). Wired into ClassConstantRuleTest with checkUnionTypes on and off to pin level semantics, using the same flag-combination approach as the template.

nsrt/defined-dynamic-class-constant.php pins the narrowed types.

Ecosystem impact

The shopware integration test gets one new, correct error: HooksReferenceGenerator reads $hook::FUNCTIONS with $hook a class-string<InterfaceHook>, and the abstract InterfaceHook does not declare FUNCTIONS (only its subclasses do; the same access on an InterfaceHook object is already reported). Companion baseline update: phpstan/phpstan#15472. The neos/flow error seen on an earlier revision is gone thanks to the defined() narrowing described above.

Alternatives

If you prefer, any of these can go the other way, in line with whatever is decided on #5976:

  • (a) gate unions on checkUnionTypes instead of routing through the object type; with the current choice, class-string unions follow the object-union rules (reported at level 7+ only).
  • (b) mirror what T::CONST reports for a class-string naming a trait (Cannot access constant CONST on trait T.) instead of staying silent.
  • (c) land the defined() narrowing as a separate PR before this one.

Companion PR (integration/e2e expectation, merge together): phpstan/phpstan#15477

🤖 Generated with Claude Code, model Fable 5.

@gnutix
gnutix changed the base branch from 2.1.x to 2.2.x July 4, 2026 20:01
@gnutix
gnutix force-pushed the class-constant-string-unions branch 2 times, most recently from 2a3ef96 to a4ab8f8 Compare July 5, 2026 07:49
@gnutix
gnutix force-pushed the class-constant-string-unions branch 2 times, most recently from 6faefb4 to 59de55c Compare July 5, 2026 11:40
@gnutix
gnutix force-pushed the class-constant-string-unions branch from 59de55c to 47b83ee Compare October 12, 2026 16:32
@gnutix
gnutix changed the base branch from 2.2.x to 2.3.x October 12, 2026 16:32
@gnutix
gnutix force-pushed the class-constant-string-unions branch from 47b83ee to 53f4c75 Compare October 12, 2026 18:33
gnutix added a commit to gnutix/phpstan that referenced this pull request Oct 12, 2026
…InterfaceHook>

phpstan/phpstan-src#5980 checks class constant access through class-strings.
Shopware's HooksReferenceGenerator reads $hook::FUNCTIONS with $hook a
class-string<InterfaceHook>, but the abstract InterfaceHook does not declare
FUNCTIONS (only its subclasses do). The same access on an InterfaceHook object
is already reported, so this is a true positive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QW8VaDtn3cMdQowPSh1xGG
…lass-string unions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gnutix
gnutix force-pushed the class-constant-string-unions branch from 53f4c75 to dbdc8d2 Compare October 12, 2026 20:27
gnutix added a commit to gnutix/phpstan that referenced this pull request Oct 13, 2026
…-string

phpstan/phpstan-src#5980 checks class constant access through
class-strings. HooksReferenceGenerator reads $hook::FUNCTIONS with
$hook typed class-string<InterfaceHook>; the abstract InterfaceHook
does not declare the constant, only its subclasses do. Same report as
for $object::FUNCTIONS on an InterfaceHook object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QW8VaDtn3cMdQowPSh1xGG
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.

1 participant