Repository navigation
Keep the function call stack in scope derivations - #6879
Open
ChrisAbner wants to merge 3 commits into
Open
ChrisAbner wants to merge 3 commits into
ChrisAbner wants to merge 3 commits into
Conversation
invalidateExpression() and invalidateMethodsOnExpression() rebuilt the scope with an empty $inFunctionCallsStack, so inside a closure passed as an argument, Scope::getFunctionCallStack() and getFunctionCallStackWithParameters() returned [] after any call that invalidated something (a void or @phpstan-impure method) until the end of the closure. Keep the stack, as entering a closure already does since be279db.
…ement
A throw point records its scope in the middle of a call, so a catch block
built from it inherited that call's stack. In
`try { class_exists(name()); } catch (...) { new Missing(); }` the catch
block counted as inside class_exists() and the missing class was not
reported. TryCatchHandler now gives the throw point scopes the stack of the
try statement before building the catch and finally scopes from them.
mergeWith(), enter/exitExpressionAssign(), set/unsetAllowedUndefinedExpression(), processFinallyScope(), processAlwaysIterableForeachScopeWithoutPollute(), generalizeWithVariableState(), withRecordedStatementDelta() and the Closure::bind()/call() derivations rebuilt the scope with an empty $inFunctionCallsStack. Inside a closure passed as an argument, any if/else, ternary, ??, isset(), loop or try/finally emptied Scope::getFunctionCallStack() for the rest of the closure. They now keep the stack; only entering a call, a class, a function or a namespace changes it.
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.
Closes phpstan/phpstan#15377
Scope::getFunctionCallStack()andgetFunctionCallStackWithParameters()were emptied inside a closure passed as an argument because many scope derivations rebuilt the scope with[]in the$inFunctionCallsStackslot. The issue's cases go throughinvalidateExpression()andinvalidateMethodsOnExpression(), but the same[]is inmergeWith(),enter/exitExpressionAssign(),set/unsetAllowedUndefinedExpression(),processFinallyScope(),processAlwaysIterableForeachScopeWithoutPollute(),generalizeWithVariableState(),withRecordedStatementDelta()and theClosure::bind()/call()derivations. So after anyif/else, ternary,??,isset(), loop ortry/finallyin the closure the stack was gone for the rest of it. This affects extensions that read the stack (the issue's transaction check, phpstan-drupal'sDeprecationHelperScope) and the core consumers,isInClassExists()andUnusedPrivatePropertyRule::isPropertySelfWrite(). With this PR the stack only changes when entering or leaving a call, a class, a function or a namespace. The closure-inference walk (enter*WithoutReflection()) keeps starting from[], becauseenterAnonymousFunction()puts the stack back on top of it.Keeping the stack in
mergeWith()is only correct if both merged scopes carry the same stack, and they did not: a throw point records its scope in the middle of a call, andTryCatchHandlerbuilt thecatchandfinallyscopes from those. That is also a separate false negative on 2.3.x: intry { class_exists(name()); } catch (...) { new Missing(); }the catch block counted as insideclass_exists()and the missing class was not reported. So the second commit gives the throw point scopes the stack of thetrystatement (MutatingScope::withFunctionCallStackOf()) before the catch and finally scopes are built from them. To check the invariant, I temporarily logged (onc539adc52) everymergeWith()call whose two scopes had different stacks: 851 such merges inmake phpstanon 2.3.x, and 0 with this PR inmake phpstan,NodeScopeResolverTest,tests/PHPStan/Rulesand the rest oftests/PHPStan/Analyser. The same[]→$this->inFunctionCallsStackchange was made for entering closures in be279db. The[]here goes back to 17bde93, which added a positional argument, and 5f91da6, and neither says why the stack would be cleared.MutatingScopeandTryCatchHandlerhave turbo mirrors, so every change is ported. The privatescopeWith(..., bool emptyCallStack)helper inMutatingScope.cpplost its flag, because both of its callers passedtrue.generated/MutatingScope.his regenerated with PHP 8.5.The PR has three commits: the two invalidations;
catch/finallytaking the stack oftry; and the remaining derivations. Each commit has its own tests that fail without it.Verification. The issue's reproduction project (its rule prints
getFunctionCallStackWithParameters()at eachprobe()), run withbin/phpstanfrom this branch, before (2.3.x atc539adc52) and after, with the same output with and without the extension:probe()[inTransaction][inTransaction]int[inTransaction][inTransaction]voidinterface method[][inTransaction]voidmethod of a concrete class[][inTransaction]voidfunction[inTransaction][inTransaction]@phpstan-impuremethod returningint[][inTransaction]And one construct per closure, stack after it:
inTransaction()if/else, ternary[][inTransaction]$n ?? 1,isset($n)[][inTransaction]foreach,while,foreachwithbreak[][inTransaction]try { ... } finally { ... }[][inTransaction]catch, one throw point insidevar_dump()[inTransaction, var_dump][inTransaction]catch, two throw points[][inTransaction]takesTwo($b ? 1 : 2, <here>)[takesTwo][inTransaction, takesTwo]\Closure::bind(static function () {}, null, null)[][inTransaction]try { class_exists(name()); } catch (...) { new Missing(); }(not in a closure)Instantiated class Missing not found.The new tests (
testBug15377in bothScopeFunctionCallStack*RuleTests,InstantiationRuleTest::testClassExistsInCatch) fail on the commit before theirs and pass after, with and without the extension (I checked the failing side with the extension loaded for the first commit). With and without the extension, these pass:tests/PHPStan/Rules(3620 tests),NodeScopeResolverTest(1893), the rest oftests/PHPStan/Analyser(1181),tests/PHPStan/Type(6599),tests/PHPStan/Reflectionand the remaining directories. The one error,ReflectionProviderGoldenTestwith noreflection-8.4.testdata file, only shows up when the directory is passed by hand, sincephpunit.xmlexcludes it.make phpstanand phpcs are clean. For the extension: strict build without warnings,smoke.phpALL OK,side-by-side.php(PHP 8.5) andsignature-parity.phpOK,walk-trace.phpidentical over all NSRT files,src/Analyserandsrc/Type/Constant, and, before the rebase,make sanitizeOK (smoke, arena-smoke, signature-parity and parser-corpus under UBSan). The issue's project and the construct cases give the same--error-format=rawoutput with and without the extension.Performance: before rebasing onto the current 2.3.x, I ran seven interleaved A/B pairs of
bin/phpstan analyse -c build/phpstan.neon srcwithout the extension (user CPU, result cache cleared), comparing 2.3.x with this branch. The output was identical, and this branch was 1.1% slower on average. The difference between pairs ranged from -5.8% to +7.3% on this machine, so the difference is within the noise. The closure-type cache, whose key includes the stack, had 552 misses instead of 550 out of 572 lookups.Not covered: I did not run the
levelsandexecgroups or the fullmake lint-turbo. Closures written in a callback body but not passed anywhere now keep the context of the enclosing call after anif, a loop and so on, as they already did before the first such statement (ClosureTypeResolver::buildParametersAndAcceptors()reads the top of the stack). No NSRT result changed. Code in acatchblock after a call insideclass_exists()and similar is now analysed as code outside that call, so it can report errors that were hidden before.If you'd rather review this in smaller pieces, the first commit alone fixes the cases from the issue, and the
catch/finallychange can go in its own PR.