Repository navigation
Conversation
gnutix
force-pushed
the
class-constant-string-unions
branch
2 times, most recently
from
July 5, 2026 07:49
2a3ef96 to
a4ab8f8
Compare
gnutix
force-pushed
the
class-constant-string-unions
branch
2 times, most recently
from
July 5, 2026 11:40
6faefb4 to
59de55c
Compare
gnutix
force-pushed
the
class-constant-string-unions
branch
from
October 12, 2026 16:32
59de55c to
47b83ee
Compare
gnutix
force-pushed
the
class-constant-string-unions
branch
from
October 12, 2026 18:33
47b83ee to
53f4c75
Compare
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
force-pushed
the
class-constant-string-unions
branch
from
October 12, 2026 20:27
53f4c75 to
dbdc8d2
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #5976 (same root-cause family).
ClassConstantRulehad a legacy string bail-out that predatesType::isClassString()/Type::getClassStringObjectType(): for a$classString::CONSTfetch where the class expression is a constant class-name string, aclass-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
TypeExprrouting from #5976, adapted to class constants. Before callingRuleLevelHelper::findTypeToCheck(), a class-string-typed class expression is converted viagetClassStringObjectType()and wrapped innew TypeExpr($objectType)when the object type names at least one class that is not a trait. There is no separateisClassString()->yes()guard: for any type that is not entirely a class-string (objects, unions with non-class-string members, plainstring),getClassStringObjectType()yields no class name, so the conversion is skipped by the same no-class-name check that keeps plainclass-stringand 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 whencheckUnionTypesis off, whole-union check when it is on — and gives them the rest of the pipeline, including private/protected visibility.class-string,class-string<object>,class-string<T of object>) are not rerouted and stay silent, as before.RuleLevelHelperwould report the trait as an unknown class).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()guardsRouting class-strings through the rule surfaced a common idiom that was only silent because of the bail-out:
DefinedConstantTypeSpecifyingExtensiononly narrowed a constant argument (defined('Foo::BAR')narrowsFoo::BAR). It now also recognizes$expr . '::NAME',"$expr::NAME"and"{$expr}::NAME"when$expris a string, and narrows$expr::NAMEthe same way, so the rule's existinghasExpressionType()check skips the guarded fetch and its type becomesmixedinstead 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 aclass-string<SomeInterface>, is still reported — consistently with$object::CONSTon an interface-typed object.::classhandlingThe
classpseudo-constant is deliberately excluded from the conversion (strtolower($constantName) !== 'class').$string::classis a runtimeTypeError(Cannot use "::class" on string), unlike$object::class, and the rule already reports this asclassConstant.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::classandclass-string<Foo>::class, both of which keep emittingclassConstant.dynamicString.defined("$class::class")is not narrowed.Tests
New data file
class-constant-string-unions.phpmirrors #5976's matrix: single constant string, constant-string union with one member missing the constant,get_class()union, singleclass-string<C>,class-stringunion, plainclass-string(silent), the two::classpinning cases, enum-case access through both a constant string and aclass-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])overclass-string<SomeInterface>). Wired intoClassConstantRuleTestwithcheckUnionTypeson and off to pin level semantics, using the same flag-combination approach as the template.nsrt/defined-dynamic-class-constant.phppins the narrowed types.Ecosystem impact
The shopware integration test gets one new, correct error:
HooksReferenceGeneratorreads$hook::FUNCTIONSwith$hookaclass-string<InterfaceHook>, and the abstractInterfaceHookdoes not declareFUNCTIONS(only its subclasses do; the same access on anInterfaceHookobject is already reported). Companion baseline update: phpstan/phpstan#15472. The neos/flow error seen on an earlier revision is gone thanks to thedefined()narrowing described above.Alternatives
If you prefer, any of these can go the other way, in line with whatever is decided on #5976:
checkUnionTypesinstead of routing through the object type; with the current choice, class-string unions follow the object-union rules (reported at level 7+ only).T::CONSTreports for a class-string naming a trait (Cannot access constant CONST on trait T.) instead of staying silent.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.