Skip to content

Check every union member for restricted usage class names - #7011

Open
bbrala wants to merge 1 commit into
phpstan:2.3.xfrom
bbrala:fix-15681-union-gaps
Open

bbrala wants to merge 1 commit into
phpstan:2.3.xfrom
bbrala:fix-15681-union-gaps

Conversation

@bbrala

@bbrala bbrala commented Nov 9, 2026 •

Copy link
Copy Markdown

I made the same PR as the referenced fix, was too late with opening so you fixed it already. But while walking though my work i also notices some extra ways the related issue might appear. Not sure if you want this, if this is overkill, but it seems the bug is still present in some combinations.


Follow-up to #7006 (phpstan/phpstan#15681).

#7006 fixed restricted usage on ?Foo, Foo|false and class-string<Foo>. Some cases are still not reported:

  1. Instance access on unions with a classless object member is missed at every level:
    /** @param (callable(): void)|Foo $a */   $a->deprecated();
    /** @param iterable<int>|Foo $b */         $b->deprecated();
    /** @param object{a: int}|Foo $c */        $c->deprecated();
    /** @param TObject|Foo $d (TObject of object) */  $d->deprecated();
  2. Static access on Foo|false, Foo|string and Foo|int|null is reported at levels 2–6 but not at level 7 and above, so a higher level reports fewer errors:
    /** @param Foo|false $x */
    $x::deprecatedStatic(); $x::DEPRECATED; $x::deprecatedStatic(...);
  3. class-string<Foo>|false used statically is missed at every level.
  4. Static properties on such unions are missed at every level, because the findTypeToCheck() callback in RestrictedStaticPropertyUsageRule checks hasInstanceProperty() and should check hasStaticProperty(). This now matches AccessStaticPropertiesCheck.

Root cause

  • UnionType::getObjectClassNames() returns [] as soon as one member has no class names. After TypeCombinator::intersect($type, new ObjectWithoutClassType()), members like callable-object, iterable&object, object shapes and object templates are still in the union, so the whole result is empty.
  • On the static side, getObjectTypeOrClassStringObjectType() on the whole union turns false/int into ErrorType and string into object. Foo|false therefore becomes ErrorType and yields no class names. Below level 7, findTypeToCheck() happens to filter those members out first, which is why the result depends on the level.

Fix

A small helper, RestrictedUsageObjectClassNames, collects class names member by member:

  • get(Type) is used by the 4 instance rules in place of the intersect.
  • getFromClassExpression(Type) is used by the 4 static rules. It applies getObjectTypeOrClassStringObjectType() per member. The static rules still go through RuleLevelHelper::findTypeToCheck(); only how class names are read from its result changes.

Why not reuse something existing:

  • intersect(..., object) isn't enough, for the reason above: it leaves classless object members in the union, so cases 1 and 3 stay unreported.
  • TypeUtils::flattenTypes() expands constant arrays (2^N optional keys). These rules run for every method call and property fetch on arbitrary types, so that's the wrong cost profile.
  • Inlining would repeat the same loop in 8 rules. phpstan-src already has stateless static helpers like this, for example AllowedArrayKeysTypes and PhpDocLineHelper.

The loop is one instanceof check plus getObjectClassNames() per member, so it costs less than the intersect it replaces. On Drupal core with phpstan-deprecation-rules the difference in analysis time was within noise.

Behaviour changes

  • The cases above are now reported: instance access at every level, static access from level 2 up.
  • At levels 2–6, static access on unions that findTypeToCheck() can't filter, such as 'Foo'|false or class-string<Foo>|false, is now reported. The class really is a possible receiver there.
  • Levels 0–1 are unchanged: checkThisOnly still skips $expr::….
  • There are no new reports for mixed, object, object|Foo (which collapses to object), non-class strings or plain class-string.

Tests

The new cases are appended to tests/PHPStan/Rules/RestrictedUsage/data/bug-15681.php, so existing line numbers don't change. Each of the 8 testBug15681() methods has new expectations. All 8 fail on 2.3.x without the src/ changes. The phpstan-deprecation-rules test suite passes unchanged.

Open question, not addressed here: at levels 0–1 the static rules don't check $var::deprecatedStatic() at all, not even for a plain Foo $var. Instance access ($var->deprecated()) is checked at every level. Projects that run deprecation-rules at a low level only to find deprecations will miss these calls. Is that intended?

The restricted usage rules (method, property, static, class constant,
callable, string cast) missed the class of a union member in several cases:

- Static access on Foo|false, Foo|string, Foo|int|null was lost at level 7+,
  because the whole union was converted at once.
- Instance access on callable|Foo, iterable<int>|Foo, Foo|object{a: int}
  and TObject|Foo found no class name, because the intersection with object
  left a member without a class.
- class-string<Foo>|false static access was never checked.
- RestrictedStaticPropertyUsageRule looked for an instance property instead
  of a static one when picking the type to check, so $orFalse::$prop was
  missed.

Class names are now collected per union member.

Follow-up to phpstan#7006 (phpstan/phpstan#15681)
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