Suppress property.nonObject and method.nonObject errors when property_exists()/method_exists() guard is present#5729
Conversation
…perty_exists()`/`method_exists()` guard is present - Add synthetic `property_exists()` call check in `AccessPropertiesCheck` before the `canAccessProperties()` error, so `mixed` narrowed to `class-string|(object&hasProperty(…))` no longer triggers a false positive - Extend the existing `property_exists()` guard check for undefined properties to also handle `Identifier` property names (previously only handled `Expr`) - Apply the same fix to `MethodCallCheck` for `method_exists()` guards - Update `PropertyExistsTypeSpecifyingExtension` and `MethodExistsTypeSpecifyingExtension` to also store the function call result as `true` in the `!isObject()->yes()` branch, so synthetic call checks can find the stored specification - Remove the now-incorrect assertion in `CallMethodsRuleTest` that expected the false positive "Cannot call method foo() on class-string|object"
e751452 to
f407edc
Compare
staabm
left a comment
There was a problem hiding this comment.
newly added tests using mixed need a // lint >= 8.0 comment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Done. Added |
| $context, | ||
| $scope, | ||
| ); | ||
| )->unionWith($this->createFuncCallSpec($node, $context, $scope)); |
There was a problem hiding this comment.
why do we not need the same fix at the return in line 68 of this file?
There was a problem hiding this comment.
Pushed. Here's a summary of the changes and the answer to staabm's review question:
Why line 68 doesn't need ->unionWith($this->createFuncCallSpec(...)):
The return at line 68 handles the case where the first argument to method_exists() is already a class-string. In this branch, the type narrows to class-string&hasMethod(foo), where canCallMethods() returns No (since ClassStringType returns No and HasMethodType returns Maybe, and lazyMaxMin without any Yes takes the minimum). Instance method calls on class-strings are genuinely invalid PHP — method_exists($className, 'foo') only validates the class has the method, not that $className->foo() is valid. NOT storing the func call spec is intentional so the synthetic check in MethodCallCheck doesn't suppress this legitimate error.
The !isObject()->yes() branch (line 88-99) is different: mixed narrows to class-string|(object&hasProperty(...)), and the object member makes instance access valid. There, the createFuncCallSpec is needed to suppress the false positive caused by the class-string member making canCallMethods() return Maybe.
Bug found and fixed: Investigating this question revealed that PropertyExistsTypeSpecifyingExtension stores the func call spec for ALL string types (including class-strings), which caused the new synthetic check in AccessPropertiesCheck to incorrectly suppress the property.nonObject error for class-strings. Fixed by adding a !$type->canAccessProperties()->no() guard to the synthetic check.
…erties The synthetic `property_exists()` guard check must also verify that instance property access is valid for the type. For class-strings, `property_exists()` checks if the CLASS has the property, but instance property access (`$x->prop`) on a string is invalid PHP regardless of the guard. Added class-string test cases for both property and method access to document that `method.nonObject` and `property.nonObject` errors are correctly reported when the type is a class-string, even inside a `method_exists()`/`property_exists()` guard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| $context, | ||
| $scope, | ||
| ); | ||
| )->unionWith($this->createFuncCallSpec($node, $context, $scope)); |
There was a problem hiding this comment.
why do we not need the same fix at the return in line 107 of this file?
Summary
When
property_exists($mixed, 'prop')is used as a guard and then$mixed->propis accessed, PHPStan incorrectly reported "Cannot access property $prop on class-string|object." This happened becauseproperty_exists()narrowsmixedtoclass-string|(object&hasProperty(prop)), and theclass-stringmember of the union causescanAccessProperties()to returnmaybe(), triggering theproperty.nonObjecterror. The same issue existed formethod_exists()withcanCallMethods().PR #5544 added
property_exists()guard handling inAccessStaticPropertiesCheckbut missed the instance property access case inAccessPropertiesCheck, and also missed the analogousmethod_exists()case inMethodCallCheck.Changes
src/Type/Php/PropertyExistsTypeSpecifyingExtension.php: In the!isObject()->yes()branch, also store theproperty_exists()call result astrue(viacreateFuncCallSpec) in addition to narrowing the argument type. This enables synthetic call checks to find the stored specification.src/Type/Php/MethodExistsTypeSpecifyingExtension.php: Same fix for the generic (non-string) branch.src/Rules/Properties/AccessPropertiesCheck.php: Add a syntheticproperty_exists()call check before thecanAccessProperties()error (line 122). Also extend the existingproperty_exists()guard check for undefined properties (previously only handledExprnames) to also handleIdentifiernames by constructing aString_node.src/Rules/Methods/MethodCallCheck.php: Add a syntheticmethod_exists()call check before thecanCallMethods()error. Also extend the existingmethod_exists()guard check for undefined methods to handleIdentifiernames.tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php: Remove the assertion for the now-suppressed false positive "Cannot call method foo() on class-string|object."Analogous cases probed
AccessStaticPropertiesCheck): Already fixed by PR Suppress undefined static property error whenproperty_exists()guard is present #5544. Verified no regression.AccessPropertiesInAssignRule): UsesAccessPropertiesCheck, automatically covered. Added dedicated test.object&hasProperty(prop)(noclass-stringunion), socanAccessProperties()returnsyes()and no false positive occurs. No fix needed.CallStaticMethodsRule): Not affected — static method calls on class-strings are valid PHP and handled differently.Root cause
Two independent issues combined to produce the false positive:
PropertyExistsTypeSpecifyingExtension(andMethodExistsTypeSpecifyingExtension) narrowed the argument type formixedtoclass-string|(object&hasProperty(…))but did NOT store the function call result astrue. This meant syntheticproperty_exists()/method_exists()call checks in rule code could not detect the guard.AccessPropertiesCheck(andMethodCallCheck) did not check forproperty_exists()/method_exists()guards before reporting thecanAccessProperties()/canCallMethods()error. The existing guard checks only applied to the "undefined property/method" errors and only for dynamic (Expr) names.Test
tests/PHPStan/Rules/Properties/data/bug-14667.php— Tests instance property access afterproperty_exists()withmixed, explicitmixed,object|string,object,$this,self, and chained conditions.tests/PHPStan/Rules/Properties/data/bug-14667-assign.php— Tests property assignment afterproperty_exists()withmixed, explicitmixed, andobject|string.tests/PHPStan/Analyser/nsrt/bug-14667.php— Verifies type narrowing producesclass-string|(object&hasProperty(prop))formixed.tests/PHPStan/Rules/Methods/data/bug-14667.php— Tests method call aftermethod_exists()withmixed, explicitmixed,object|string,object, and chained conditions.Fixes phpstan/phpstan#14667