diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 7f3e22e7019..dd3631d7759 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -442,6 +442,199 @@ jobs: echo "$INFO" ../bashunit -a equals 'true' "$(echo "$INFO" | jq --raw-output '.resultCacheUsed')" ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + - script: | + cd e2e/result-cache-value-dependency + composer install + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'UsesCache.php:7:Service cache does not exist.' "$OUTPUT" + ../bashunit -a contains "UsesLocale.php:10:Function ResultCacheE2EValueDependency\usesLocale() should return 'none' but returns 'cs'." "$OUTPUT" + ../bashunit -a contains "UsesLocaleToo.php:10:Function ResultCacheE2EValueDependency\usesLocaleToo() should return 'none' but returns 'cs'." "$OUTPUT" + ../bashunit -a contains 'Holder.php:17:Method ResultCacheE2EValueDependency\Holder::isDebug() should return int but returns string.' "$OUTPUT" + ../bashunit -a contains 'UsesHolder.php:9:Function ResultCacheE2EValueDependency\usesHolder() should return int but returns string.' "$OUTPUT" + ../bashunit -a not_contains 'UsesMailer.php' "$OUTPUT" + # The extensions read container.json, which the result cache knows nothing about. A rule and a + # dynamic return type extension both declare the service they ask about - it is recorded once. + # A change of its value re-analyses only the file asking about it. + php -r '$c = json_decode(file_get_contents("container.json"), true); $c["services"]["mailer"] = "ResultCacheE2EValueDependency\\Logger"; file_put_contents("container.json", json_encode($c, JSON_PRETTY_PRINT));' + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'UsesMailer.php:7:Call to an undefined method ResultCacheE2EValueDependency\Logger::send().' "$OUTPUT" + # Values nobody asked about do not re-analyse anything. + php -r '$c = json_decode(file_get_contents("container.json"), true); $c["parameters"]["timezone"] = "UTC"; $c["services"]["router"] = "ResultCacheE2EValueDependency\\Logger"; file_put_contents("container.json", json_encode($c, JSON_PRETTY_PRINT));' + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '0' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + # A value that was missing is watched too. + php -r '$c = json_decode(file_get_contents("container.json"), true); $c["services"]["cache"] = "ResultCacheE2EValueDependency\\Logger"; file_put_contents("container.json", json_encode($c, JSON_PRETTY_PRINT));' + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a not_contains 'Service cache does not exist.' "$OUTPUT" + # Every file asking about the same value is re-analysed. + php -r '$c = json_decode(file_get_contents("container.json"), true); $c["parameters"]["locale"] = "en"; file_put_contents("container.json", json_encode($c, JSON_PRETTY_PRINT));' + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '2' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains "UsesLocale.php:10:Function ResultCacheE2EValueDependency\usesLocale() should return 'none' but returns 'en'." "$OUTPUT" + ../bashunit -a contains "UsesLocaleToo.php:10:Function ResultCacheE2EValueDependency\usesLocaleToo() should return 'none' but returns 'en'." "$OUTPUT" + # Scope::getFile() is not the file to re-analyse when the extension runs outside the walk of the + # analysed file: the type of Holder::$debug is inferred from the constructor on a scope of + # src/Holder.php, whenever the property is first needed, and it is cached. So it is what + # src/Holder.php declares that depends on the value: a change of it re-analyses src/Holder.php and + # the files depending on it. + php -r '$c = json_decode(file_get_contents("container.json"), true); $c["parameters"]["debug"] = "1"; file_put_contents("container.json", json_encode($c, JSON_PRETTY_PRINT));' + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '2' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a not_contains 'Holder.php' "$OUTPUT" + # Inside a trait, the value is asked about in the analysis of the class using it - that's the + # file re-analysed, and the error is reported in the trait. + php -r '$c = json_decode(file_get_contents("container.json"), true); $c["services"]["logger"] = "ResultCacheE2EValueDependency\\Mailer"; file_put_contents("container.json", json_encode($c, JSON_PRETTY_PRINT));' + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '2' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'UsesLogger.php:7:Call to an undefined method ResultCacheE2EValueDependency\Mailer::log().' "$OUTPUT" + ../bashunit -a contains 'LoggerTrait.php (in context of class ResultCacheE2EValueDependency\UsesLoggerTrait):10:Call to an undefined method ResultCacheE2EValueDependency\Mailer::log().' "$OUTPUT" + - script: | + cd e2e/result-cache-file-dependency + composer install + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains "UsesFunction.php:10:Function ResultCacheE2EFileDependency\usesFunction() should return 'none' but returns 'function-1'." "$OUTPUT" + ../bashunit -a contains "UsesMethod.php:10:Function ResultCacheE2EFileDependency\usesMethod() should return 'none' but returns 'method-1'." "$OUTPUT" + ../bashunit -a contains "UsesStaticMethod.php:10:Function ResultCacheE2EFileDependency\usesStaticMethod() should return 'none' but returns 'static-method-1'." "$OUTPUT" + ../bashunit -a contains 'Holder.php:17:Method ResultCacheE2EFileDependency\Holder::get() should return int but returns string.' "$OUTPUT" + ../bashunit -a contains 'UsesHolder.php:9:Function ResultCacheE2EFileDependency\usesHolder() should return int but returns string.' "$OUTPUT" + ../bashunit -a contains 'UsesDeprecatedConfig.php:7:Deprecated config asks for an error.' "$OUTPUT" + ../bashunit -a not_contains 'Config asks for an error.' "$OUTPUT" + # The paths declared with the deprecated RuleErrorBuilder::fileDependency() are declared on the + # scope of the rule's node. + printf 'quiet\n' > data/deprecated.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a not_contains 'Deprecated config asks for an error.' "$OUTPUT" + # A rule declares its dependency on src/Config.php whether it reports an error or not. The file + # declares no symbols, so no change of it reaches its exported nodes - any change re-analyses the + # files of the rule's nodes anyway, next to the changed file itself: src/UsesConfig.php and + # src/ConfigHolder.php. Not src/UsesConfigHolder.php depending on src/ConfigHolder.php - what + # src/ConfigHolder.php declares did not change. + printf " src/Config.php + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '3' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'UsesConfig.php:7:Config asks for an error.' "$OUTPUT" + ../bashunit -a contains 'ConfigHolder.php:10:Config asks for an error.' "$OUTPUT" + printf " src/Config.php + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '3' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'UsesConfig.php:7:Config asks for an error.' "$OUTPUT" + # Dynamic return type extensions of a function, a method and a static method declared in + # src/functions.php and src/Repository.php read a data file each. A change of one re-analyses the + # file with the call - not the one declaring the function or the method. + printf 'function-2\n' > data/function.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains "UsesFunction.php:10:Function ResultCacheE2EFileDependency\usesFunction() should return 'none' but returns 'function-2'." "$OUTPUT" + printf 'method-2\n' > data/method.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains "UsesMethod.php:10:Function ResultCacheE2EFileDependency\usesMethod() should return 'none' but returns 'method-2'." "$OUTPUT" + printf 'static-method-2\n' > data/static-method.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains "UsesStaticMethod.php:10:Function ResultCacheE2EFileDependency\usesStaticMethod() should return 'none' but returns 'static-method-2'." "$OUTPUT" + # Scope::getFile() is not the file to re-analyse when the extension runs outside the walk of the + # analysed file: the type of Holder::$value is inferred from the constructor on a scope of + # src/Holder.php, whenever the property is first needed - in the analysis of src/Holder.php, or of + # src/UsesHolder.php reading it - and it is cached. So it is what src/Holder.php declares that + # depends on data/holder.txt: a change of it re-analyses src/Holder.php and the files depending on it. + printf 'int\n' > data/holder.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '2' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a not_contains 'Holder.php:17:Method ResultCacheE2EFileDependency\Holder::get() should return int but returns string.' "$OUTPUT" + ../bashunit -a not_contains 'UsesHolder.php:9:Function ResultCacheE2EFileDependency\usesHolder() should return int but returns string.' "$OUTPUT" + # Deleting a declared file re-analyses the files depending on it too, and it stays watched while + # it is missing: creating it again re-analyses them again. + rm data/function.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains "UsesFunction.php:10:Function ResultCacheE2EFileDependency\usesFunction() should return 'none' but returns 'missing'." "$OUTPUT" + printf 'function-3\n' > data/function.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains "UsesFunction.php:10:Function ResultCacheE2EFileDependency\usesFunction() should return 'none' but returns 'function-3'." "$OUTPUT" + # The same for what src/Holder.php declares. + rm data/holder.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '2' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Holder.php:17:Method ResultCacheE2EFileDependency\Holder::get() should return int but returns string.' "$OUTPUT" + ../bashunit -a contains 'UsesHolder.php:9:Function ResultCacheE2EFileDependency\usesHolder() should return int but returns string.' "$OUTPUT" + printf 'int\n' > data/holder.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '2' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a not_contains 'Holder.php:17:Method ResultCacheE2EFileDependency\Holder::get() should return int but returns string.' "$OUTPUT" + ../bashunit -a not_contains 'UsesHolder.php:9:Function ResultCacheE2EFileDependency\usesHolder() should return int but returns string.' "$OUTPUT" + # And for a declared file that is analysed itself: deleted, it is no longer analysed, and the files + # of the rule's nodes are re-analysed; created again, all three are - and as with any new file, so + # are the three files with errors. + rm src/Config.php + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '2' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a not_contains 'Config asks for an error.' "$OUTPUT" + printf " src/Config.php + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '6' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'UsesConfig.php:7:Config asks for an error.' "$OUTPUT" - script: | cd e2e/result-cache-scanned-trait ../../bin/phpstan analyse diff --git a/compiler/composer.json b/compiler/composer.json index 5308eec70a4..5728895533f 100644 --- a/compiler/composer.json +++ b/compiler/composer.json @@ -6,7 +6,7 @@ "require": { "php": "^8.2", "nette/neon": "^3.0.0", - "ondrejmirtes/simple-downgrader": "^2.2.7", + "ondrejmirtes/simple-downgrader": "^2.2.9", "seld/phar-utils": "^1.2", "symfony/console": "^5.4.43", "symfony/filesystem": "^5.4.43", diff --git a/compiler/composer.lock b/compiler/composer.lock index 1e626ef2c79..8aec64f836a 100644 --- a/compiler/composer.lock +++ b/compiler/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7aa6a7377310ec2e6ee27d299b976e5b", + "content-hash": "09f6ef2c6b1386f0089ec2727990b60d", "packages": [ { "name": "nette/neon", @@ -222,16 +222,16 @@ }, { "name": "ondrejmirtes/simple-downgrader", - "version": "2.2.7", + "version": "2.2.9", "source": { "type": "git", "url": "https://github.com/ondrejmirtes/simple-downgrader.git", - "reference": "140f86600b499e1c460761196a12fab9aeb7ec33" + "reference": "b12a15de3c3aaed7c15bbb6f26fd734557fee0e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ondrejmirtes/simple-downgrader/zipball/140f86600b499e1c460761196a12fab9aeb7ec33", - "reference": "140f86600b499e1c460761196a12fab9aeb7ec33", + "url": "https://api.github.com/repos/ondrejmirtes/simple-downgrader/zipball/b12a15de3c3aaed7c15bbb6f26fd734557fee0e7", + "reference": "b12a15de3c3aaed7c15bbb6f26fd734557fee0e7", "shasum": "" }, "require": { @@ -265,9 +265,9 @@ "description": "Simple Downgrader", "support": { "issues": "https://github.com/ondrejmirtes/simple-downgrader/issues", - "source": "https://github.com/ondrejmirtes/simple-downgrader/tree/2.2.7" + "source": "https://github.com/ondrejmirtes/simple-downgrader/tree/2.2.9" }, - "time": "2026-06-12T17:10:38+00:00" + "time": "2026-09-30T19:26:40+00:00" }, { "name": "phpstan/phpdoc-parser", diff --git a/e2e/result-cache-file-dependency/.gitignore b/e2e/result-cache-file-dependency/.gitignore new file mode 100644 index 00000000000..61ead86667c --- /dev/null +++ b/e2e/result-cache-file-dependency/.gitignore @@ -0,0 +1 @@ +/vendor diff --git a/e2e/result-cache-file-dependency/composer.json b/e2e/result-cache-file-dependency/composer.json new file mode 100644 index 00000000000..e23fc929433 --- /dev/null +++ b/e2e/result-cache-file-dependency/composer.json @@ -0,0 +1,5 @@ +{ + "autoload-dev": { + "classmap": ["extension/"] + } +} diff --git a/e2e/result-cache-file-dependency/composer.lock b/e2e/result-cache-file-dependency/composer.lock new file mode 100644 index 00000000000..ba8d41762cc --- /dev/null +++ b/e2e/result-cache-file-dependency/composer.lock @@ -0,0 +1,18 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "d751713988987e9331980363e24189ce", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/e2e/result-cache-file-dependency/data/deprecated.txt b/e2e/result-cache-file-dependency/data/deprecated.txt new file mode 100644 index 00000000000..05c31926438 --- /dev/null +++ b/e2e/result-cache-file-dependency/data/deprecated.txt @@ -0,0 +1 @@ +report diff --git a/e2e/result-cache-file-dependency/data/function.txt b/e2e/result-cache-file-dependency/data/function.txt new file mode 100644 index 00000000000..0500baafb89 --- /dev/null +++ b/e2e/result-cache-file-dependency/data/function.txt @@ -0,0 +1 @@ +function-1 diff --git a/e2e/result-cache-file-dependency/data/holder.txt b/e2e/result-cache-file-dependency/data/holder.txt new file mode 100644 index 00000000000..8e27be7d615 --- /dev/null +++ b/e2e/result-cache-file-dependency/data/holder.txt @@ -0,0 +1 @@ +text diff --git a/e2e/result-cache-file-dependency/data/method.txt b/e2e/result-cache-file-dependency/data/method.txt new file mode 100644 index 00000000000..7326a69525c --- /dev/null +++ b/e2e/result-cache-file-dependency/data/method.txt @@ -0,0 +1 @@ +method-1 diff --git a/e2e/result-cache-file-dependency/data/static-method.txt b/e2e/result-cache-file-dependency/data/static-method.txt new file mode 100644 index 00000000000..f8005c2a28a --- /dev/null +++ b/e2e/result-cache-file-dependency/data/static-method.txt @@ -0,0 +1 @@ +static-method-1 diff --git a/e2e/result-cache-file-dependency/extension/ConfigRule.php b/e2e/result-cache-file-dependency/extension/ConfigRule.php new file mode 100644 index 00000000000..4084bff6f42 --- /dev/null +++ b/e2e/result-cache-file-dependency/extension/ConfigRule.php @@ -0,0 +1,53 @@ + + */ +final class ConfigRule implements Rule +{ + + public function getNodeType(): string + { + return FuncCall::class; + } + + /** + * @param Scope&DependencyEmitter $scope + */ + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Name || $node->name->getLast() !== 'checkConfig') { + return []; + } + + $configFile = dirname(__DIR__) . '/src/Config.php'; + $scope->fileDependency($configFile); + + if (!is_file($configFile)) { + return []; + } + + $contents = file_get_contents($configFile); + if ($contents === false || !str_contains($contents, 'report')) { + return []; + } + + return [ + RuleErrorBuilder::message('Config asks for an error.')->identifier('resultCacheE2E.config')->build(), + ]; + } + +} diff --git a/e2e/result-cache-file-dependency/extension/DataFile.php b/e2e/result-cache-file-dependency/extension/DataFile.php new file mode 100644 index 00000000000..bbfd65d75ca --- /dev/null +++ b/e2e/result-cache-file-dependency/extension/DataFile.php @@ -0,0 +1,38 @@ +fileDependency($file); + + if (!is_file($file)) { + return new ConstantStringType('missing'); + } + + $contents = file_get_contents($file); + $contents = $contents === false ? '' : trim($contents); + if ($contents === 'int') { + return new IntegerType(); + } + + return new ConstantStringType($contents); + } + +} diff --git a/e2e/result-cache-file-dependency/extension/DataFunctionReturnTypeExtension.php b/e2e/result-cache-file-dependency/extension/DataFunctionReturnTypeExtension.php new file mode 100644 index 00000000000..7c1da2957c3 --- /dev/null +++ b/e2e/result-cache-file-dependency/extension/DataFunctionReturnTypeExtension.php @@ -0,0 +1,28 @@ +getName(), ['ResultCacheE2EFileDependency\functionData', 'ResultCacheE2EFileDependency\holderData'], true); + } + + /** + * @param Scope&DependencyEmitter $scope + */ + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + return DataFile::type($functionReflection->getName() === 'ResultCacheE2EFileDependency\functionData' ? 'function' : 'holder', $scope); + } + +} diff --git a/e2e/result-cache-file-dependency/extension/DataMethodReturnTypeExtension.php b/e2e/result-cache-file-dependency/extension/DataMethodReturnTypeExtension.php new file mode 100644 index 00000000000..261ef7975eb --- /dev/null +++ b/e2e/result-cache-file-dependency/extension/DataMethodReturnTypeExtension.php @@ -0,0 +1,33 @@ +getName() === 'methodData'; + } + + /** + * @param Scope&DependencyEmitter $scope + */ + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + return DataFile::type('method', $scope); + } + +} diff --git a/e2e/result-cache-file-dependency/extension/DataStaticMethodReturnTypeExtension.php b/e2e/result-cache-file-dependency/extension/DataStaticMethodReturnTypeExtension.php new file mode 100644 index 00000000000..ca24d00c950 --- /dev/null +++ b/e2e/result-cache-file-dependency/extension/DataStaticMethodReturnTypeExtension.php @@ -0,0 +1,33 @@ +getName() === 'staticMethodData'; + } + + /** + * @param Scope&DependencyEmitter $scope + */ + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): Type + { + return DataFile::type('static-method', $scope); + } + +} diff --git a/e2e/result-cache-file-dependency/extension/DeprecatedConfigRule.php b/e2e/result-cache-file-dependency/extension/DeprecatedConfigRule.php new file mode 100644 index 00000000000..91dd1a584b5 --- /dev/null +++ b/e2e/result-cache-file-dependency/extension/DeprecatedConfigRule.php @@ -0,0 +1,44 @@ + + */ +final class DeprecatedConfigRule implements Rule +{ + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Name || $node->name->getLast() !== 'checkDeprecatedConfig') { + return []; + } + + $configFile = dirname(__DIR__) . '/data/deprecated.txt'; + $contents = @file_get_contents($configFile); + if ($contents === false || !str_contains($contents, 'report')) { + return []; + } + + return [ + // @phpstan-ignore method.deprecated + RuleErrorBuilder::message('Deprecated config asks for an error.')->identifier('resultCacheE2E.deprecatedConfig')->fileDependency($configFile)->build(), + ]; + } + +} diff --git a/e2e/result-cache-file-dependency/phpstan.neon b/e2e/result-cache-file-dependency/phpstan.neon new file mode 100644 index 00000000000..b3735e51982 --- /dev/null +++ b/e2e/result-cache-file-dependency/phpstan.neon @@ -0,0 +1,28 @@ +parameters: + level: 8 + tmpDir: tmp + paths: + - src + inferPrivatePropertyTypeFromConstructor: true + +services: + - + class: ResultCacheE2EFileDependency\ConfigRule + tags: + - phpstan.rules.rule + - + class: ResultCacheE2EFileDependency\DeprecatedConfigRule + tags: + - phpstan.rules.rule + - + class: ResultCacheE2EFileDependency\DataFunctionReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + - + class: ResultCacheE2EFileDependency\DataMethodReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: ResultCacheE2EFileDependency\DataStaticMethodReturnTypeExtension + tags: + - phpstan.broker.dynamicStaticMethodReturnTypeExtension diff --git a/e2e/result-cache-file-dependency/src/Config.php b/e2e/result-cache-file-dependency/src/Config.php new file mode 100644 index 00000000000..3a97c20de7f --- /dev/null +++ b/e2e/result-cache-file-dependency/src/Config.php @@ -0,0 +1,3 @@ +value = holderData(); + } + + public function get(): int + { + return $this->value; + } + +} diff --git a/e2e/result-cache-file-dependency/src/Repository.php b/e2e/result-cache-file-dependency/src/Repository.php new file mode 100644 index 00000000000..0c2de3048e6 --- /dev/null +++ b/e2e/result-cache-file-dependency/src/Repository.php @@ -0,0 +1,18 @@ +check(); +} diff --git a/e2e/result-cache-file-dependency/src/UsesDeprecatedConfig.php b/e2e/result-cache-file-dependency/src/UsesDeprecatedConfig.php new file mode 100644 index 00000000000..294725546df --- /dev/null +++ b/e2e/result-cache-file-dependency/src/UsesDeprecatedConfig.php @@ -0,0 +1,8 @@ + $holder->value, null, Holder::class); + + return $read($holder); +} diff --git a/e2e/result-cache-file-dependency/src/UsesMethod.php b/e2e/result-cache-file-dependency/src/UsesMethod.php new file mode 100644 index 00000000000..e21699ca8a9 --- /dev/null +++ b/e2e/result-cache-file-dependency/src/UsesMethod.php @@ -0,0 +1,11 @@ +methodData(); +} diff --git a/e2e/result-cache-file-dependency/src/UsesStaticMethod.php b/e2e/result-cache-file-dependency/src/UsesStaticMethod.php new file mode 100644 index 00000000000..de55cb7e7e6 --- /dev/null +++ b/e2e/result-cache-file-dependency/src/UsesStaticMethod.php @@ -0,0 +1,11 @@ +, parameters: array} + */ + private static function read(): array + { + $contents = file_get_contents(dirname(__DIR__) . '/container.json'); + /** @var array{services: array, parameters: array} $container */ + $container = json_decode($contents === false ? '{}' : $contents, true); + + return $container; + } + +} diff --git a/e2e/result-cache-value-dependency/extension/HasServiceValueExtension.php b/e2e/result-cache-value-dependency/extension/HasServiceValueExtension.php new file mode 100644 index 00000000000..d4b65bc637b --- /dev/null +++ b/e2e/result-cache-value-dependency/extension/HasServiceValueExtension.php @@ -0,0 +1,25 @@ +getName() === 'ResultCacheE2EValueDependency\parameter'; + } + + /** + * @param Scope&DependencyEmitter $scope + */ + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $arg = $functionCall->getArgs()[0]->value ?? null; + if (!$arg instanceof String_) { + return null; + } + + $scope->valueDependency(ParameterValueExtension::class, $arg->value); + $value = Container::getParameter($arg->value); + if ($value === null) { + return null; + } + + return is_numeric($value) ? new ConstantIntegerType((int) $value) : new ConstantStringType($value); + } + +} diff --git a/e2e/result-cache-value-dependency/extension/ParameterValueExtension.php b/e2e/result-cache-value-dependency/extension/ParameterValueExtension.php new file mode 100644 index 00000000000..b610dbd03ea --- /dev/null +++ b/e2e/result-cache-value-dependency/extension/ParameterValueExtension.php @@ -0,0 +1,25 @@ +getName() === 'ResultCacheE2EValueDependency\service'; + } + + /** + * @param Scope&DependencyEmitter $scope + */ + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $arg = $functionCall->getArgs()[0]->value ?? null; + if (!$arg instanceof String_) { + return null; + } + + // the same value ServiceRule declares - recorded once + $scope->valueDependency(HasServiceValueExtension::class, $arg->value); + $class = Container::getService($arg->value); + + return $class !== null ? new ObjectType($class) : null; + } + +} diff --git a/e2e/result-cache-value-dependency/extension/ServiceRule.php b/e2e/result-cache-value-dependency/extension/ServiceRule.php new file mode 100644 index 00000000000..10a313ac8d2 --- /dev/null +++ b/e2e/result-cache-value-dependency/extension/ServiceRule.php @@ -0,0 +1,45 @@ + + */ +final class ServiceRule implements Rule +{ + + public function getNodeType(): string + { + return FuncCall::class; + } + + /** + * @param Scope&DependencyEmitter $scope + */ + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Name || $node->name->getLast() !== 'service' || !isset($node->getArgs()[0]) || !$node->getArgs()[0]->value instanceof String_) { + return []; + } + + $id = $node->getArgs()[0]->value->value; + $scope->valueDependency(HasServiceValueExtension::class, $id); + if (Container::getService($id) !== null) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Service %s does not exist.', $id))->identifier('resultCacheE2E.service')->build(), + ]; + } + +} diff --git a/e2e/result-cache-value-dependency/phpstan.neon b/e2e/result-cache-value-dependency/phpstan.neon new file mode 100644 index 00000000000..107ca730da8 --- /dev/null +++ b/e2e/result-cache-value-dependency/phpstan.neon @@ -0,0 +1,28 @@ +parameters: + level: 8 + tmpDir: tmp + paths: + - src + inferPrivatePropertyTypeFromConstructor: true + +services: + - + class: ResultCacheE2EValueDependency\HasServiceValueExtension + tags: + - phpstan.resultCacheValueExtension + - + class: ResultCacheE2EValueDependency\ParameterValueExtension + tags: + - phpstan.resultCacheValueExtension + - + class: ResultCacheE2EValueDependency\ServiceRule + tags: + - phpstan.rules.rule + - + class: ResultCacheE2EValueDependency\ServiceReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + - + class: ResultCacheE2EValueDependency\ParameterReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension diff --git a/e2e/result-cache-value-dependency/src/Holder.php b/e2e/result-cache-value-dependency/src/Holder.php new file mode 100644 index 00000000000..3db3d1581f9 --- /dev/null +++ b/e2e/result-cache-value-dependency/src/Holder.php @@ -0,0 +1,20 @@ +debug = parameter('debug'); + } + + public function isDebug(): int + { + return $this->debug; + } + +} diff --git a/e2e/result-cache-value-dependency/src/Logger.php b/e2e/result-cache-value-dependency/src/Logger.php new file mode 100644 index 00000000000..608bcc9d945 --- /dev/null +++ b/e2e/result-cache-value-dependency/src/Logger.php @@ -0,0 +1,12 @@ +log(); + } + +} diff --git a/e2e/result-cache-value-dependency/src/Mailer.php b/e2e/result-cache-value-dependency/src/Mailer.php new file mode 100644 index 00000000000..ba8cdfe536a --- /dev/null +++ b/e2e/result-cache-value-dependency/src/Mailer.php @@ -0,0 +1,12 @@ + $holder->debug, null, Holder::class); + + return $read($holder); +} diff --git a/e2e/result-cache-value-dependency/src/UsesLocale.php b/e2e/result-cache-value-dependency/src/UsesLocale.php new file mode 100644 index 00000000000..42ba9d469a0 --- /dev/null +++ b/e2e/result-cache-value-dependency/src/UsesLocale.php @@ -0,0 +1,11 @@ +log(); +} diff --git a/e2e/result-cache-value-dependency/src/UsesLoggerTrait.php b/e2e/result-cache-value-dependency/src/UsesLoggerTrait.php new file mode 100644 index 00000000000..f1101031234 --- /dev/null +++ b/e2e/result-cache-value-dependency/src/UsesLoggerTrait.php @@ -0,0 +1,10 @@ +send(); +} diff --git a/e2e/result-cache-value-dependency/src/functions.php b/e2e/result-cache-value-dependency/src/functions.php new file mode 100644 index 00000000000..556da1231d6 --- /dev/null +++ b/e2e/result-cache-value-dependency/src/functions.php @@ -0,0 +1,16 @@ + [], 'dependents' => []]; $packageDependencies = []; $exportedNodes = []; $allProcessedFiles = []; @@ -101,6 +102,7 @@ public function analyse( $collectedData = array_merge($collectedData, $fileAnalyserResult->getCollectedData()); $dependencies[$file] = $fileAnalyserResult->getDependencies(); $usedTraitDependencies[$file] = $fileAnalyserResult->getUsedTraitDependencies(); + $valueDependencies = ValueDependencyCollector::merge($valueDependencies, $fileAnalyserResult->getValueDependencies()); $packageDependencies[$file] = $fileAnalyserResult->getPackageDependencies(); $fileExportedNodes = $fileAnalyserResult->getExportedNodes(); @@ -143,6 +145,7 @@ public function analyse( collectedData: $collectedData, dependencies: $internalErrorsCount === 0 ? $dependencies : null, usedTraitDependencies: $internalErrorsCount === 0 ? $usedTraitDependencies : null, + valueDependencies: $internalErrorsCount === 0 ? $valueDependencies : null, packageDependencies: $internalErrorsCount === 0 ? $packageDependencies : null, exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $reachedInternalErrorsCountLimit, diff --git a/src/Analyser/AnalyserResult.php b/src/Analyser/AnalyserResult.php index 78a44ae2601..4b814297f14 100644 --- a/src/Analyser/AnalyserResult.php +++ b/src/Analyser/AnalyserResult.php @@ -9,6 +9,7 @@ /** * @phpstan-import-type LinesToIgnore from FileAnalyserResult * @phpstan-import-type CollectorData from CollectedData + * @phpstan-import-type ValueDependencies from ValueDependencyCollector */ final class AnalyserResult { @@ -27,6 +28,7 @@ final class AnalyserResult * @param list $internalErrors * @param array>|null $dependencies * @param array>|null $usedTraitDependencies + * @param ValueDependencies|null $valueDependencies see ValueDependencyCollector * @param array>|null $packageDependencies * @param array> $exportedNodes * @param list $processedFiles @@ -42,6 +44,7 @@ public function __construct( private array $collectedData, private ?array $dependencies, private ?array $usedTraitDependencies, + private ?array $valueDependencies, private ?array $packageDependencies, private array $exportedNodes, private bool $reachedInternalErrorsCountLimit, @@ -165,6 +168,14 @@ public function getUsedTraitDependencies(): ?array return $this->usedTraitDependencies; } + /** + * @return ValueDependencies|null + */ + public function getValueDependencies(): ?array + { + return $this->valueDependencies; + } + /** * @return array>|null */ diff --git a/src/Analyser/AnalyserResultFinalizer.php b/src/Analyser/AnalyserResultFinalizer.php index fd883666f87..c40bc1066bb 100644 --- a/src/Analyser/AnalyserResultFinalizer.php +++ b/src/Analyser/AnalyserResultFinalizer.php @@ -151,6 +151,7 @@ public function finalize(AnalyserResult $analyserResult, bool $onlyFiles, bool $ collectedData: $analyserResult->getCollectedData(), dependencies: $analyserResult->getDependencies(), usedTraitDependencies: $analyserResult->getUsedTraitDependencies(), + valueDependencies: $analyserResult->getValueDependencies(), packageDependencies: $analyserResult->getPackageDependencies(), exportedNodes: $analyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), @@ -173,6 +174,7 @@ private function mergeFilteredPhpErrors(AnalyserResult $analyserResult): Analyse collectedData: $analyserResult->getCollectedData(), dependencies: $analyserResult->getDependencies(), usedTraitDependencies: $analyserResult->getUsedTraitDependencies(), + valueDependencies: $analyserResult->getValueDependencies(), packageDependencies: $analyserResult->getPackageDependencies(), exportedNodes: $analyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), @@ -240,6 +242,7 @@ private function addUnmatchedIgnoredErrors( collectedData: $analyserResult->getCollectedData(), dependencies: $analyserResult->getDependencies(), usedTraitDependencies: $analyserResult->getUsedTraitDependencies(), + valueDependencies: $analyserResult->getValueDependencies(), packageDependencies: $analyserResult->getPackageDependencies(), exportedNodes: $analyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), diff --git a/src/Analyser/DependencyEmitter.php b/src/Analyser/DependencyEmitter.php new file mode 100644 index 00000000000..02cbb38d39a --- /dev/null +++ b/src/Analyser/DependencyEmitter.php @@ -0,0 +1,46 @@ + $extensionClass + */ + public function valueDependency(string $extensionClass, string $key): void; + + /** + * The analysis of the current file depends on the contents of $file - a data file, a template, + * a docblock in another PHP file - that is read on its own, without PHPStan knowing about it. + * The current file is then re-analysed whenever $file is created, changed in any way, or deleted. + * + * The path should be absolute. The file does not have to exist. + */ + public function fileDependency(string $file): void; + +} diff --git a/src/Analyser/FileAnalyser.php b/src/Analyser/FileAnalyser.php index f0b72370438..3aac6d4d46a 100644 --- a/src/Analyser/FileAnalyser.php +++ b/src/Analyser/FileAnalyser.php @@ -70,6 +70,7 @@ public function __construct( private LocalIgnoresProcessor $localIgnoresProcessor, #[AutowiredParameter] private bool $reportIgnoresWithoutComments, + private ValueDependencyCollector $valueDependencyCollector, ) { } @@ -104,6 +105,7 @@ public function analyseFile( $exportedNodes = []; $linesToIgnore = []; $unmatchedLineIgnores = []; + $this->valueDependencyCollector->startFile($file); if (is_file($file)) { try { $this->collectErrors($analysedFiles); @@ -255,6 +257,7 @@ public function analyseFile( $fileCollectedData, array_values(array_unique($fileDependencies)), array_values(array_unique($usedTraitFileDependencies)), + $this->valueDependencyCollector->finishFile(), array_values(array_unique($filePackageDependencies)), $exportedNodes, $linesToIgnore, diff --git a/src/Analyser/FileAnalyserCallback.php b/src/Analyser/FileAnalyserCallback.php index 10982cb075e..4afa0929abd 100644 --- a/src/Analyser/FileAnalyserCallback.php +++ b/src/Analyser/FileAnalyserCallback.php @@ -107,7 +107,7 @@ public function __invoke(Node $node, Scope $scope): void $parserNodes = $this->parserNodes; - /** @var Scope&NodeCallbackInvoker&CollectedDataEmitter $scope */ + /** @var Scope&NodeCallbackInvoker&CollectedDataEmitter&DependencyEmitter $scope */ if ($node instanceof Node\Stmt\Trait_) { foreach (array_keys($this->linesToIgnore[$this->file] ?? []) as $lineToIgnore) { if ($lineToIgnore < $node->getStartLine() || $lineToIgnore > $node->getEndLine()) { @@ -182,10 +182,9 @@ public function __invoke(Node $node, Scope $scope): void foreach ($ruleErrors as $ruleError) { if ($ruleError instanceof FileDependenciesRuleError) { - // The rule says its verdict depends on files the dependency graph cannot know about, - // because they hold no symbol PHPStan reflects - a path named in the code, for one. + // the deprecated way of DependencyEmitter::fileDependency() foreach ($ruleError->getFileDependencies() as $fileDependency) { - $this->fileDependencies[] = $fileDependency; + $scope->fileDependency($fileDependency); } } diff --git a/src/Analyser/FileAnalyserResult.php b/src/Analyser/FileAnalyserResult.php index 5d9c5f15226..693a01335fd 100644 --- a/src/Analyser/FileAnalyserResult.php +++ b/src/Analyser/FileAnalyserResult.php @@ -9,6 +9,7 @@ * @phpstan-type Identifier = array{name: string, comment: string|null} * @phpstan-type LinesToIgnore = array|null>> * @phpstan-import-type CollectorData from CollectedData + * @phpstan-import-type ValueDependencies from ValueDependencyCollector */ final class FileAnalyserResult { @@ -21,6 +22,7 @@ final class FileAnalyserResult * @param CollectorData $collectedData * @param list $dependencies * @param list $usedTraitDependencies + * @param ValueDependencies $valueDependencies see ValueDependencyCollector * @param list $packageDependencies * @param list $exportedNodes * @param LinesToIgnore $linesToIgnore @@ -35,6 +37,7 @@ public function __construct( private array $collectedData, private array $dependencies, private array $usedTraitDependencies, + private array $valueDependencies, private array $packageDependencies, private array $exportedNodes, private array $linesToIgnore, @@ -100,6 +103,14 @@ public function getUsedTraitDependencies(): array return $this->usedTraitDependencies; } + /** + * @return ValueDependencies + */ + public function getValueDependencies(): array + { + return $this->valueDependencies; + } + /** * @return list */ diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 20da5ff28ab..b426b23fdd2 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -150,7 +150,7 @@ use const PHP_INT_MIN; #[ShadowedByTurboExtension(implementation: __DIR__ . '/../../turbo-ext/src/MutatingScope.cpp')] -class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter +class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter, DependencyEmitter { private const COMPLEX_UNION_TYPE_MEMBER_LIMIT = 8; @@ -6071,4 +6071,15 @@ public function emitCollectedData(string $collectorType, mixed $data): void $nodeCallback(new EmitCollectedDataNode($collectorType, $data), $this); } + public function valueDependency(string $extensionClass, string $key): void + { + // a scope without a node callback is outside the walk of the analysed file - see ValueDependencyCollector + $this->container->getByType(ValueDependencyCollector::class)->record($extensionClass, $key, $this, $this->nodeCallback !== null); + } + + public function fileDependency(string $file): void + { + $this->container->getByType(ValueDependencyCollector::class)->recordFile($file, $this, $this->nodeCallback !== null); + } + } diff --git a/src/Analyser/ResultCache/FileResultCacheValueExtension.php b/src/Analyser/ResultCache/FileResultCacheValueExtension.php new file mode 100644 index 00000000000..046a62ee8bc --- /dev/null +++ b/src/Analyser/ResultCache/FileResultCacheValueExtension.php @@ -0,0 +1,53 @@ +pathTransformer = new ResultCachePathTransformer($anchorDirectory); + } + + public function getValue(string $key): string + { + $hash = $this->fileContentHasher->hash($key); + if ($hash === false) { + return self::MISSING_FILE; + } + + return $hash; + } + + public function keyToResultCache(string $key): string + { + return $this->pathTransformer->relativizePath($key); + } + + public function keyFromResultCache(string $storedKey): string + { + return $this->pathTransformer->absolutizePath($storedKey); + } + +} diff --git a/src/Analyser/ResultCache/ResultCache.php b/src/Analyser/ResultCache/ResultCache.php index e7a80d025bc..99889f0a63d 100644 --- a/src/Analyser/ResultCache/ResultCache.php +++ b/src/Analyser/ResultCache/ResultCache.php @@ -24,6 +24,7 @@ final class ResultCache * @param CollectorData $collectedData * @param array> $dependencies * @param array> $usedTraitDependencies + * @param array, list}> $valueDependencies id => [extension class, key, current value, analysis dependents, declarations dependents] - see ValueDependencyCollector * @param array> $packageDependencies * @param array> $exportedNodes the decoded ones - see $cachedExportedNodes for the rest * @param array $projectExtensionFiles @@ -42,6 +43,7 @@ public function __construct( private array $collectedData, private array $dependencies, private array $usedTraitDependencies, + private array $valueDependencies, private array $packageDependencies, private array $exportedNodes, private CachedExportedNodes $cachedExportedNodes, @@ -142,6 +144,14 @@ public function getUsedTraitDependencies(): array return $this->usedTraitDependencies; } + /** + * @return array, list}> + */ + public function getValueDependencies(): array + { + return $this->valueDependencies; + } + /** * @return array> */ diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 3fa5c4807dc..ea9bb477d4a 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\AnalyserResult; use PHPStan\Analyser\Error; use PHPStan\Analyser\FileAnalyserResult; +use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Collectors\CollectedData; use PHPStan\Command\Output; use PHPStan\Dependency\ExportedNode\ExportedTraitNode; @@ -87,6 +88,7 @@ /** * @phpstan-import-type LinesToIgnore from FileAnalyserResult + * @phpstan-import-type ValueDependencies from ValueDependencyCollector * @phpstan-import-type CollectorData from CollectedData */ #[GenerateFactory(interface: ResultCacheManagerFactory::class)] @@ -103,7 +105,7 @@ final class ResultCacheManager */ private const EXTENSIONS_NOT_INVALIDATING_CACHE = ['xdebug', 'blackfire', 'phpstan_turbo']; - private const CACHE_VERSION = 'v21-exportedNodesIndex'; + private const CACHE_VERSION = 'v22-valueDependencies'; /** * The recorded hash of a dependency that does not exist. A rule can depend on a path rather than on @@ -224,6 +226,7 @@ public function __construct( private PhpVersion $phpVersion, private ComposerPhpVersionFactory $composerPhpVersionFactory, private FileStatSignatures $fileStatSignatures, + private ValueDependencyCollector $valueDependencyCollector, ) { } @@ -276,6 +279,7 @@ private function fullAnalysis( collectedData: [], dependencies: [], usedTraitDependencies: [], + valueDependencies: [], packageDependencies: [], exportedNodes: [], cachedExportedNodes: CachedExportedNodes::createEmpty(), @@ -390,6 +394,19 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $currentFileHashes = $this->hashAnalysedFiles($analysedFileStats, $data['dependencies']); $fileStatSignaturesChanged = $this->fileStatSignaturesDiffer($data['dependencies']); $data['packageDependencies'] = $transformer->absolutizeFileKeyed($data['packageDependencies'] ?? []); + try { + $cachedValueDependencies = $this->decodeValueDependencies($data['valueDependencies'] ?? ['paths' => [], 'entries' => []], $transformer); + } catch (Throwable $e) { + @unlink($cacheFilePath); + + return $this->fullAnalysis( + sprintf('Result cache not used because an error occurred while loading the cache file: %s', $e->getMessage()), + $allAnalysedFiles, + $this->getMeta($allAnalysedFiles, $projectConfigArray), + $currentFileHashes, + $output, + ); + } $errorsCallback = $data['errorsCallback']; $data['errorsCallback'] = static fn (): array => $transformer->absolutizeErrors($errorsCallback()); @@ -963,6 +980,35 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? ); } + // The values declared through DependencyEmitter::valueDependency() - see ValueDependencyCollector. + // The files depending on one that is different now are re-analysed, and so are the ones + // depending on one whose extension is no longer registered, which is then forgotten. + $allAnalysedFilesSet = array_fill_keys($allAnalysedFiles, true); + $valueDependenciesToReturn = []; + $valueDependenciesChanged = false; + foreach ($cachedValueDependencies as $cachedValueDependency) { + $extension = $this->valueDependencyCollector->getExtension($cachedValueDependency['extensionClass']); + $key = $extension !== null ? $extension->keyFromResultCache($cachedValueDependency['storedKey']) : null; + $currentValue = $extension !== null && $key !== null ? $extension->getValue($key) : null; + if ($currentValue !== $cachedValueDependency['value']) { + $valueDependenciesChanged = true; + foreach ($this->getValueDependentFilesToAnalyse($cachedValueDependency['analysis'], $cachedValueDependency['declarations'], $invertedDependencies, $allAnalysedFilesSet) as $valueDependentFile) { + $filesToAnalyse[] = $valueDependentFile; + } + } + if ($key === null || $currentValue === null) { + continue; + } + + $valueDependenciesToReturn[ValueDependencyCollector::getId($cachedValueDependency['extensionClass'], $key)] = [ + $cachedValueDependency['extensionClass'], + $key, + $currentValue, + $cachedValueDependency['analysis'], + $cachedValueDependency['declarations'], + ]; + } + if ($newFileAppeared || $notAnalysedFileSymbolsChanged) { foreach (array_keys($filteredErrors) as $fileWithError) { $filesToAnalyse[] = $fileWithError; @@ -1000,7 +1046,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? )); } - $this->restoredCacheUnchanged = !$metaDifferent && !$dependencyFilesChanged && !$fileStatSignaturesChanged; + $this->restoredCacheUnchanged = !$metaDifferent && !$dependencyFilesChanged && !$fileStatSignaturesChanged && !$valueDependenciesChanged; $this->restoredStubFiles = $cachedStubFiles; return new ResultCache( @@ -1016,6 +1062,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? collectedData: $filteredCollectedData, dependencies: $invertedDependenciesToReturn, usedTraitDependencies: $invertedUsedTraitDependenciesToReturn, + valueDependencies: $valueDependenciesToReturn, packageDependencies: $packageDependencies, exportedNodes: $filteredExportedNodes, cachedExportedNodes: $cachedExportedNodes->only($keptCachedExportedNodes), @@ -1167,7 +1214,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $projectConfigArray = $this->getPathTransformer()->relativizeProjectConfig($projectConfigArray); $meta['projectConfig'] = Neon::encode($projectConfigArray); } - $doSave = function (array $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, ?array $dependencies, ?array $usedTraitDependencies, ?array $packageDependencies, array $exportedNodes, CachedExportedNodes $cachedExportedNodes, array $projectExtensionFiles) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): bool { + $doSave = function (array $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, ?array $dependencies, ?array $usedTraitDependencies, ?array $valueDependencies, ?array $packageDependencies, array $exportedNodes, CachedExportedNodes $cachedExportedNodes, array $projectExtensionFiles) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): bool { if ($onlyFiles) { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because only files were passed as analysed paths.'); @@ -1192,6 +1239,12 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache } return false; } + if ($valueDependencies === null) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache was not saved because of error in value dependencies.'); + } + return false; + } if (count($internalErrors) > 0) { if ($output->isVeryVerbose()) { @@ -1245,7 +1298,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache return true; } - $this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles, $resultCache->getCurrentFileHashes(), $meta, $stubFiles); + $this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles, $resultCache->getCurrentFileHashes(), $meta, $stubFiles); if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache is saved.'); @@ -1261,7 +1314,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache if ($analyserResult->getDependencies() !== null) { $projectExtensionFiles = $this->getProjectExtensionFiles($projectConfigArray, $analyserResult->getDependencies()); } - $saved = $doSave($freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $analyserResult->getLinesToIgnore(), $analyserResult->getUnmatchedLineIgnores(), $freshCollectedDataByFile, $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), $analyserResult->getPackageDependencies(), $this->addNonAnalysedExportedNodes($analyserResult->getExportedNodes(), $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), CachedExportedNodes::createEmpty()), CachedExportedNodes::createEmpty(), $projectExtensionFiles); + $saved = $doSave($freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $analyserResult->getLinesToIgnore(), $analyserResult->getUnmatchedLineIgnores(), $freshCollectedDataByFile, $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), $analyserResult->getValueDependencies(), $analyserResult->getPackageDependencies(), $this->addNonAnalysedExportedNodes($analyserResult->getExportedNodes(), $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), CachedExportedNodes::createEmpty()), CachedExportedNodes::createEmpty(), $projectExtensionFiles); } else { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because it was not requested.'); @@ -1276,6 +1329,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $collectedDataByFile = $this->mergeCollectedData($resultCache, $freshCollectedDataByFile); $dependencies = $this->mergeDependencies($resultCache->getDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getDependencies()); $usedTraitDependencies = $this->mergeDependencies($resultCache->getUsedTraitDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getUsedTraitDependencies()); + $valueDependencies = $this->mergeValueDependencies($resultCache->getValueDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getValueDependencies()); $packageDependencies = $this->mergePackageDependencies($resultCache->getPackageDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getPackageDependencies()); // the re-analysed files take their fresh nodes, what is left of the cached ones stays undecoded $cachedExportedNodes = $resultCache->getCachedExportedNodes()->without(array_fill_keys($resultCache->getFilesToAnalyse(), true)); @@ -1305,7 +1359,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $projectExtensionFiles[$file] = [$hash, true, $className]; } } - $saved = $doSave($errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles); + $saved = $doSave($errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles); } $flatErrors = []; @@ -1333,6 +1387,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache collectedData: $collectedDataByFile, dependencies: $dependencies, usedTraitDependencies: $usedTraitDependencies, + valueDependencies: $valueDependencies, packageDependencies: $packageDependencies, exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), @@ -1454,6 +1509,193 @@ private function mergeDependencies(array $resultCacheDependencies, array $filesT return $newDependencies; } + /** + * The fresh "analysis" dependents of a re-analysed file replace its cached ones, like in + * mergeDependencies(). The "declarations" ones are kept: the file they are about may have been + * inferred during the analysis of another file that was not re-analysed (see + * ValueDependencyCollector), and one left over only means a file re-analysed needlessly. The + * fresh values are the ones the analysis saw. + * + * @param array, list}> $resultCacheValueDependencies + * @param string[] $filesToAnalyse + * @param ValueDependencies|null $freshValueDependencies + * @return ValueDependencies|null + */ + private function mergeValueDependencies(array $resultCacheValueDependencies, array $filesToAnalyse, ?array $freshValueDependencies): ?array + { + if ($freshValueDependencies === null) { + return null; + } + + $values = []; + $dependents = []; + foreach ($resultCacheValueDependencies as $id => [$extensionClass, $key, $value, $analysis, $declarations]) { + $values[$id] = [$extensionClass, $key, $value]; + foreach (['analysis' => $analysis, 'declarations' => $declarations] as $kind => $dependentFiles) { + foreach ($dependentFiles as $dependentFile) { + $dependents[$dependentFile] ??= ['analysis' => [], 'declarations' => []]; + $dependents[$dependentFile][$kind][] = $id; + } + } + } + + foreach ($filesToAnalyse as $file) { + $files = [$file]; + if (array_key_exists($file, $this->fileReplacements)) { + $files[] = $this->fileReplacements[$file]; + } + foreach ($files as $reanalysedFile) { + if (!array_key_exists($reanalysedFile, $dependents)) { + continue; + } + + $dependents[$reanalysedFile]['analysis'] = []; + } + } + + $merged = ValueDependencyCollector::merge(['values' => [], 'dependents' => $dependents], $freshValueDependencies); + $merged['values'] = $freshValueDependencies['values'] + $values; + + return $merged; + } + + /** + * @param list $analysisDependentFiles + * @param list $declarationDependentFiles + * @param array, usedTraitDependentFiles?: list}> $invertedDependencies + * @param array $allAnalysedFiles + * @return list + */ + private function getValueDependentFilesToAnalyse(array $analysisDependentFiles, array $declarationDependentFiles, array $invertedDependencies, array $allAnalysedFiles): array + { + $files = []; + foreach ($analysisDependentFiles as $file) { + if (!array_key_exists($file, $allAnalysedFiles)) { + continue; + } + $files[] = $file; + } + + // What these files declare was derived from the value, and the files depending on them see it + // without having asked for the value themselves. + foreach ($declarationDependentFiles as $declaringFile) { + if (array_key_exists($declaringFile, $allAnalysedFiles)) { + $files[] = $declaringFile; + } + + $declaringFileData = $invertedDependencies[$declaringFile] ?? null; + if ($declaringFileData === null) { + continue; + } + + foreach (array_merge($declaringFileData['dependentFiles'], $declaringFileData['usedTraitDependentFiles'] ?? []) as $file) { + if (!is_file($file)) { + continue; + } + $files[] = $file; + } + } + + return $files; + } + + /** + * One entry for each value, with the files depending on it in a table of paths like the + * dependency graph's, and the key as its extension stores it. + * + * @param ValueDependencies $valueDependencies + * @return array{paths: list, entries: list, list}>} + */ + private function encodeValueDependencies(array $valueDependencies, ResultCachePathTransformer $transformer): array + { + $dependentsById = []; + foreach ($valueDependencies['dependents'] as $dependentFile => ['analysis' => $analysis, 'declarations' => $declarations]) { + foreach ($analysis as $id) { + $dependentsById[$id]['analysis'][] = $dependentFile; + } + foreach ($declarations as $id) { + $dependentsById[$id]['declarations'][] = $dependentFile; + } + } + ksort($dependentsById); + + $ids = []; + $paths = []; + $entries = []; + foreach ($dependentsById as $id => $dependents) { + if (!array_key_exists($id, $valueDependencies['values'])) { + continue; + } + + [$extensionClass, $key, $value] = $valueDependencies['values'][$id]; + $extension = $this->valueDependencyCollector->getExtension($extensionClass); + if ($extension === null) { + continue; + } + + $dependentIds = []; + foreach (['analysis', 'declarations'] as $kind) { + $pathIds = []; + $dependentFiles = array_values(array_unique($dependents[$kind] ?? [])); + sort($dependentFiles); + foreach ($dependentFiles as $dependentFile) { + if (!isset($ids[$dependentFile])) { + $ids[$dependentFile] = count($paths); + $paths[] = $dependentFile; + } + $pathIds[] = $ids[$dependentFile]; + } + $dependentIds[$kind] = $pathIds; + } + + $entries[] = [$extensionClass, $extension->keyToResultCache($key), $value, $dependentIds['analysis'], $dependentIds['declarations']]; + } + + $relativePaths = []; + foreach ($paths as $path) { + $relativePaths[] = $transformer->relativizePath($path); + } + + return ['paths' => $relativePaths, 'entries' => $entries]; + } + + /** + * @param mixed $encoded + * @return list, declarations: list}> + */ + private function decodeValueDependencies($encoded, ResultCachePathTransformer $transformer): array + { + if (!is_array($encoded) || !is_array($encoded['paths'] ?? null) || !is_array($encoded['entries'] ?? null)) { + throw new RuntimeException('The value dependencies are malformed.'); + } + + $paths = []; + foreach ($encoded['paths'] as $path) { + $paths[] = $transformer->absolutizePath($path); + } + + $valueDependencies = []; + foreach ($encoded['entries'] as [$extensionClass, $storedKey, $value, $analysisIds, $declarationIds]) { + $analysis = []; + foreach ($analysisIds as $pathId) { + $analysis[] = $paths[$pathId]; + } + $declarations = []; + foreach ($declarationIds as $pathId) { + $declarations[] = $paths[$pathId]; + } + $valueDependencies[] = [ + 'extensionClass' => $extensionClass, + 'storedKey' => $storedKey, + 'value' => $value, + 'analysis' => $analysis, + 'declarations' => $declarations, + ]; + } + + return $valueDependencies; + } + /** * @param array> $freshExportedNodes * @return array> @@ -1569,6 +1811,7 @@ private function mergeUnmatchedLineIgnores(ResultCache $resultCache, array $fres * @param CollectorData $collectedData * @param array> $dependencies * @param array> $usedTraitDependencies + * @param ValueDependencies $valueDependencies * @param array> $packageDependencies * @param array> $exportedNodes * @param array $projectExtensionFiles @@ -1585,6 +1828,7 @@ private function save( array $collectedData, array $dependencies, array $usedTraitDependencies, + array $valueDependencies, array $packageDependencies, array $exportedNodes, CachedExportedNodes $cachedExportedNodes, @@ -1672,6 +1916,7 @@ private function save( $unmatchedLineIgnores = $transformer->relativizeCompoundKeyed($unmatchedLineIgnores); $collectedData = $transformer->relativizeCollectedData($collectedData); $dependencyGraph = $this->encodeDependencyGraph($invertedDependencies, $transformer); + $encodedValueDependencies = $this->encodeValueDependencies($valueDependencies, $transformer); unset($invertedDependencies); $packageDependencies = $transformer->relativizeFileKeyed($packageDependencies); $projectExtensionFiles = $transformer->relativizeFileKeyed($projectExtensionFiles); @@ -1715,6 +1960,7 @@ private function save( // cacheVersion check that makes it discard the file, and fails on a missing section. $this->writeArrayFrame($handle, $file, 'dependencies', []); $this->writeValueFrame($handle, $file, 'dependencyGraph', $dependencyGraph); + $this->writeValueFrame($handle, $file, 'valueDependencies', $encodedValueDependencies); $this->writeArrayFrame($handle, $file, 'packageDependencies', $packageDependencies); $this->writeExportedNodes($handle, $file, $exportedNodes, $cachedExportedNodes, $transformer); fclose($handle); diff --git a/src/Analyser/ResultCache/ResultCacheValueExtension.php b/src/Analyser/ResultCache/ResultCacheValueExtension.php new file mode 100644 index 00000000000..8ae87e00335 --- /dev/null +++ b/src/Analyser/ResultCache/ResultCacheValueExtension.php @@ -0,0 +1,57 @@ +valueDependency(MyExtension::class, $key); + * ``` + * + * The result cache then records the value, and re-analyses the files that declared it when it + * is different, and only them. Compared to ResultCacheMetaExtension, which discards the whole + * result cache on any change, only what was actually asked about matters, and only for the + * files asking. + * + * To register it in the configuration file use the `phpstan.resultCacheValueExtension` service tag: + * + * ``` + * services: + * - + * class: App\PHPStan\MyExtension + * tags: + * - phpstan.resultCacheValueExtension + * ``` + * + * @api + */ +#[ExtensionInterface(tag: self::EXTENSION_TAG)] +interface ResultCacheValueExtension +{ + + public const EXTENSION_TAG = 'phpstan.resultCacheValueExtension'; + + /** + * The current value for the key - compared with the one recorded by the last analysis. + * A long value is better hashed. + */ + public function getValue(string $key): string; + + /** + * The key as the result cache stores it. A key that is a file path is best stored relative to + * the project, so that the result cache survives a moved checkout. + */ + public function keyToResultCache(string $key): string; + + /** + * The key back from the result cache - the reverse of keyToResultCache(). + */ + public function keyFromResultCache(string $storedKey): string; + +} diff --git a/src/Analyser/ValueDependencyCollector.php b/src/Analyser/ValueDependencyCollector.php new file mode 100644 index 00000000000..c9f600059f1 --- /dev/null +++ b/src/Analyser/ValueDependencyCollector.php @@ -0,0 +1,189 @@ +, + * dependents: array, declarations: list}>, + * } + */ +#[AutowiredService] +final class ValueDependencyCollector +{ + + private ?string $analysedFile = null; + + /** @var array id => [extension class, key, value] */ + private array $values = []; + + /** @var array, declarations: array}> dependent file => ids */ + private array $dependents = []; + + /** + * @param ExtensionsCollection $valueExtensions + */ + public function __construct( + #[AutowiredExtensions(of: ResultCacheValueExtension::class)] + private ExtensionsCollection $valueExtensions, + private FileHelper $fileHelper, + ) + { + } + + public static function getId(string $extensionClass, string $key): string + { + return $extensionClass . "\0" . $key; + } + + public function startFile(string $analysedFile): void + { + $this->analysedFile = $analysedFile; + $this->values = []; + $this->dependents = [$analysedFile => ['analysis' => [], 'declarations' => []]]; + } + + /** + * @param class-string $extensionClass + */ + public function record(string $extensionClass, string $key, Scope $scope, bool $insideWalk): void + { + if ($this->analysedFile === null) { + return; + } + + $id = self::getId($extensionClass, $key); + if (!array_key_exists($id, $this->values)) { + $this->values[$id] = [$extensionClass, $key, $this->getRegisteredExtension($extensionClass)->getValue($key)]; + } + + $this->dependents[$this->analysedFile]['analysis'][$id] = true; + if ($insideWalk) { + return; + } + + $scopeFile = $scope->getFile(); + if (!array_key_exists($scopeFile, $this->dependents)) { + $this->dependents[$scopeFile] = ['analysis' => [], 'declarations' => []]; + } + $this->dependents[$scopeFile]['declarations'][$id] = true; + } + + /** + * DependencyEmitter::fileDependency() - a dependency on the contents of a file, through + * FileResultCacheValueExtension. + */ + public function recordFile(string $file, Scope $scope, bool $insideWalk): void + { + $file = $this->fileHelper->normalizePath($file); + if ($insideWalk && $file === $this->analysedFile) { + // the analysed file is re-analysed when it changes anyway + return; + } + + $this->record(FileResultCacheValueExtension::class, $file, $scope, $insideWalk); + } + + /** + * @return ValueDependencies always with an entry for the analysed file + */ + public function finishFile(): array + { + $dependents = []; + foreach ($this->dependents as $dependentFile => ['analysis' => $analysis, 'declarations' => $declarations]) { + $dependents[$dependentFile] = [ + 'analysis' => array_keys($analysis), + 'declarations' => array_keys($declarations), + ]; + } + + $values = $this->values; + $this->analysedFile = null; + $this->values = []; + $this->dependents = []; + + return ['values' => $values, 'dependents' => $dependents]; + } + + /** + * The dependencies recorded during the analysis of one file can be about another file too, + * so the results of several files are merged, not overwritten. A value seen first stays. + * + * @param ValueDependencies $dependencies + * @param ValueDependencies $newDependencies + * @return ValueDependencies + */ + public static function merge(array $dependencies, array $newDependencies): array + { + $values = $dependencies['values'] + $newDependencies['values']; + $dependents = $dependencies['dependents']; + foreach ($newDependencies['dependents'] as $dependentFile => ['analysis' => $analysis, 'declarations' => $declarations]) { + $dependents[$dependentFile] = [ + 'analysis' => array_values(array_unique(array_merge($dependents[$dependentFile]['analysis'] ?? [], $analysis))), + 'declarations' => array_values(array_unique(array_merge($dependents[$dependentFile]['declarations'] ?? [], $declarations))), + ]; + } + + return ['values' => $values, 'dependents' => $dependents]; + } + + public function getExtension(string $extensionClass): ?ResultCacheValueExtension + { + foreach ($this->valueExtensions->getAll() as $extension) { + if ($extension instanceof $extensionClass) { + return $extension; + } + } + + return null; + } + + private function getRegisteredExtension(string $extensionClass): ResultCacheValueExtension + { + $extension = $this->getExtension($extensionClass); + if ($extension === null) { + throw new ShouldNotHappenException(sprintf( + '%s is not registered as a result cache value extension. Register it with the %s service tag.', + $extensionClass, + ResultCacheValueExtension::EXTENSION_TAG, + )); + } + + return $extension; + } + +} diff --git a/src/Command/AnalyseApplication.php b/src/Command/AnalyseApplication.php index e7d6566c238..f5868057130 100644 --- a/src/Command/AnalyseApplication.php +++ b/src/Command/AnalyseApplication.php @@ -8,6 +8,7 @@ use PHPStan\Analyser\FileAnalyserResult; use PHPStan\Analyser\Ignore\IgnoredErrorHelper; use PHPStan\Analyser\ResultCache\ResultCacheManagerFactory; +use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Collectors\CollectedData; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Internal\BytesHelper; @@ -29,6 +30,7 @@ /** * @phpstan-import-type CollectorData from CollectedData * @phpstan-import-type LinesToIgnore from FileAnalyserResult + * @phpstan-import-type ValueDependencies from ValueDependencyCollector */ #[AutowiredService] final class AnalyseApplication @@ -120,6 +122,7 @@ public function analyse( collectedData: $intermediateAnalyserResult->getCollectedData(), dependencies: $intermediateAnalyserResult->getDependencies(), usedTraitDependencies: $intermediateAnalyserResult->getUsedTraitDependencies(), + valueDependencies: $intermediateAnalyserResult->getValueDependencies(), packageDependencies: $intermediateAnalyserResult->getPackageDependencies(), exportedNodes: $intermediateAnalyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $intermediateAnalyserResult->hasReachedInternalErrorsCountLimit(), @@ -226,6 +229,7 @@ private function runAnalyser( collectedData: [], dependencies: [], usedTraitDependencies: [], + valueDependencies: ['values' => [], 'dependents' => []], packageDependencies: [], exportedNodes: [], reachedInternalErrorsCountLimit: false, @@ -314,6 +318,10 @@ private function switchTmpFileInAnalyserResult( if ($analyserResult->getUsedTraitDependencies() !== null) { $usedTraitDependencies = $this->switchTmpFileInDependencies($analyserResult->getUsedTraitDependencies(), $insteadOfFile, $tmpFile); } + $valueDependencies = null; + if ($analyserResult->getValueDependencies() !== null) { + $valueDependencies = $this->switchTmpFileInValueDependencies($analyserResult->getValueDependencies(), $insteadOfFile, $tmpFile); + } $packageDependencies = null; if ($analyserResult->getPackageDependencies() !== null) { $packageDependencies = $this->switchTmpFileInDependencies($analyserResult->getPackageDependencies(), $insteadOfFile, $tmpFile); @@ -339,6 +347,7 @@ private function switchTmpFileInAnalyserResult( collectedData: $newCollectedData, dependencies: $dependencies, usedTraitDependencies: $usedTraitDependencies, + valueDependencies: $valueDependencies, packageDependencies: $packageDependencies, exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), @@ -377,6 +386,20 @@ private function switchTmpFileInDependencies(array $dependencies, string $instea return $newDependencies; } + /** + * @param ValueDependencies $dependencies + * @return ValueDependencies + */ + private function switchTmpFileInValueDependencies(array $dependencies, string $insteadOfFile, string $tmpFile): array + { + $dependents = []; + foreach ($dependencies['dependents'] as $dependentFile => $ids) { + $dependents[$dependentFile === $tmpFile ? $insteadOfFile : $dependentFile] = $ids; + } + + return ['values' => $dependencies['values'], 'dependents' => $dependents]; + } + /** * @param list $errors * @return list diff --git a/src/Command/AnalyserRunner.php b/src/Command/AnalyserRunner.php index 59e0dbb2d87..496bdee4c82 100644 --- a/src/Command/AnalyserRunner.php +++ b/src/Command/AnalyserRunner.php @@ -68,6 +68,7 @@ public function runAnalyser( collectedData: [], dependencies: [], usedTraitDependencies: [], + valueDependencies: ['values' => [], 'dependents' => []], packageDependencies: [], exportedNodes: [], reachedInternalErrorsCountLimit: false, diff --git a/src/Command/FixerWorkerRunner.php b/src/Command/FixerWorkerRunner.php index 3b4ace9a3f8..cb162b221b4 100644 --- a/src/Command/FixerWorkerRunner.php +++ b/src/Command/FixerWorkerRunner.php @@ -345,6 +345,7 @@ private function runAnalyser(LoopInterface $loop, array $files, array $allAnalys collectedData: [], dependencies: [], usedTraitDependencies: [], + valueDependencies: ['values' => [], 'dependents' => []], packageDependencies: [], exportedNodes: [], reachedInternalErrorsCountLimit: false, diff --git a/src/Parallel/ParallelAnalyser.php b/src/Parallel/ParallelAnalyser.php index 936335499de..c1e07733662 100644 --- a/src/Parallel/ParallelAnalyser.php +++ b/src/Parallel/ParallelAnalyser.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\AnalyserResult; use PHPStan\Analyser\Error; use PHPStan\Analyser\InternalError; +use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Cache\ArenaCache; use PHPStan\Command\CommandHelper; use PHPStan\Command\Output; @@ -122,6 +123,7 @@ public function analyse( $collectedData = []; $dependencies = []; $usedTraitDependencies = []; + $valueDependencies = ['values' => [], 'dependents' => []]; $packageDependencies = []; $reachedInternalErrorsCountLimit = false; $exportedNodes = []; @@ -134,7 +136,7 @@ public function analyse( $useFork = $this->forkParallelChecker->isSupported(); $server = new TcpServer('127.0.0.1:0', $loop); - $this->processPool = new ProcessPool($server, static function () use ($deferred, &$jobs, &$internalErrors, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$packageDependencies, &$exportedNodes, &$peakMemoryUsages, &$allProcessedFiles, $arenaName): void { + $this->processPool = new ProcessPool($server, static function () use ($deferred, &$jobs, &$internalErrors, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$valueDependencies, &$packageDependencies, &$exportedNodes, &$peakMemoryUsages, &$allProcessedFiles, $arenaName): void { if ($arenaName !== null) { ArenaCache::destroy(); } @@ -161,6 +163,7 @@ public function analyse( collectedData: $collectedData, dependencies: $internalErrorsCount === 0 ? $dependencies : null, usedTraitDependencies: $internalErrorsCount === 0 ? $usedTraitDependencies : null, + valueDependencies: $internalErrorsCount === 0 ? $valueDependencies : null, packageDependencies: $internalErrorsCount === 0 ? $packageDependencies : null, exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $reachedInternalErrorsCountLimit, @@ -284,7 +287,7 @@ public function analyse( $insteadOfFile, $input, ); - $process->start(function (array $json) use ($process, &$internalErrors, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$packageDependencies, &$exportedNodes, &$peakMemoryUsages, &$jobs, $postFileCallback, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, $processIdentifier, $onFileAnalysisHandler, &$allProcessedFiles): void { + $process->start(function (array $json) use ($process, &$internalErrors, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$valueDependencies, &$packageDependencies, &$exportedNodes, &$peakMemoryUsages, &$jobs, $postFileCallback, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, $processIdentifier, $onFileAnalysisHandler, &$allProcessedFiles): void { $fileErrors = []; foreach ($json['errors'] as $jsonError) { $fileErrors[] = Error::decode($jsonError); @@ -342,6 +345,10 @@ public function analyse( $usedTraitDependencies[$file] = $fileUsedTraitDependencies; } + /** @var array{values: array, dependents: array, declarations: list}>} $workerValueDependencies */ + $workerValueDependencies = $json['valueDependencies']; + $valueDependencies = ValueDependencyCollector::merge($valueDependencies, $workerValueDependencies); + /** * @var string $file * @var array $filePackageDependencies diff --git a/src/Parallel/WorkerRunner.php b/src/Parallel/WorkerRunner.php index fa3b5caf2de..3a3f4e466a9 100644 --- a/src/Parallel/WorkerRunner.php +++ b/src/Parallel/WorkerRunner.php @@ -7,6 +7,7 @@ use PHPStan\Analyser\FileAnalyser; use PHPStan\Analyser\InternalError; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Collectors\Registry as CollectorRegistry; use PHPStan\Command\BootstrapFilesRunner; use PHPStan\Command\ErrorsConsoleStyle; @@ -207,6 +208,7 @@ private function runWorker( $collectedData = []; $dependencies = []; $usedTraitDependencies = []; + $valueDependencies = ['values' => [], 'dependents' => []]; $packageDependencies = []; $exportedNodes = []; $processedFiles = []; @@ -223,6 +225,7 @@ private function runWorker( $unmatchedLineIgnores[$file] = $fileAnalyserResult->getUnmatchedLineIgnores(); $dependencies[$file] = $fileAnalyserResult->getDependencies(); $usedTraitDependencies[$file] = $fileAnalyserResult->getUsedTraitDependencies(); + $valueDependencies = ValueDependencyCollector::merge($valueDependencies, $fileAnalyserResult->getValueDependencies()); $packageDependencies[$file] = $fileAnalyserResult->getPackageDependencies(); $exportedNodes[$file] = $fileAnalyserResult->getExportedNodes(); $processedFiles = array_merge($processedFiles, $fileAnalyserResult->getProcessedFiles()); @@ -265,6 +268,7 @@ private function runWorker( 'memoryUsage' => memory_get_peak_usage(true), 'dependencies' => $dependencies, 'usedTraitDependencies' => $usedTraitDependencies, + 'valueDependencies' => $valueDependencies, 'packageDependencies' => $packageDependencies, 'exportedNodes' => $exportedNodes, 'files' => $files, diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index a2b9062093f..4c52e21fe2a 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -1323,6 +1323,24 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc } } + if (!isset($className)) { + $templateType = $nameScope->resolveTemplateTypeName($constExpr->className); + if ($templateType instanceof TemplateType) { + if (strtolower($constExpr->name) === 'class') { + return new GenericClassStringType($templateType); + } + + // A template bound to a final class can only be that class. Any other can be a class + // declaring constants its bound does not - known once the template is resolved. + $boundClassReflections = $templateType->getBound()->getObjectClassReflections(); + if (count($boundClassReflections) !== 1 || !$boundClassReflections[0]->isFinal()) { + return new ClassConstantAccessType($templateType, $constExpr->name); + } + + $className = $boundClassReflections[0]->getName(); + } + } + if (!isset($className)) { $className = $nameScope->resolveStringName($constExpr->className); } @@ -1346,6 +1364,12 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc return new ConstantStringType($classReflection->getName(), true); } + // A class that is not final can have subclasses declaring constants it does not - what + // static:: stands for is known once static is resolved. + if ($isStatic) { + return new ClassConstantAccessType(new StaticType($classReflection), $constantName); + } + if (Strings::contains($constantName, '*')) { // convert * into .*? and escape everything else so the constants can be matched against the pattern $pattern = '{^' . str_replace('\\*', '.*?', preg_quote($constantName)) . '$}D'; @@ -1389,10 +1413,6 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc return new EnumCaseObjectType($classReflection->getName(), $constantName); } - if ($isStatic) { - return new ClassConstantAccessType(new StaticType($classReflection), $constantName); - } - $reflectionConstant = $classReflection->getNativeReflection()->getReflectionConstant($constantName); if ($reflectionConstant === false) { return new ErrorType(); diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index a047011fd93..694ae002e76 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -50,6 +50,7 @@ use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\ArrayType; +use PHPStan\Type\ClassConstantAccessType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Enum\EnumCaseObjectType; @@ -1329,6 +1330,12 @@ private function getPhpDocReturnType(ClassReflection $phpDocBlockClassReflection return $phpDocReturnType; } + if ($phpDocReturnType instanceof ClassConstantAccessType && !$phpDocReturnType->isResolvable()) { + // static::FOO_* or T::* - it can be compared with the native type only once the class is + // known, see TypehintHelper::decideType() + return $phpDocReturnType; + } + if ($nativeReturnType->isSuperTypeOf($phpDocReturnType)->yes()) { return $phpDocReturnType; } diff --git a/src/Rules/Classes/InstantiationRule.php b/src/Rules/Classes/InstantiationRule.php index f0b4f4bf424..f9c19257b04 100644 --- a/src/Rules/Classes/InstantiationRule.php +++ b/src/Rules/Classes/InstantiationRule.php @@ -16,6 +16,7 @@ use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\Php\PhpMethodReflection; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Reflection\Type\CalledOnTypeUnresolvedMethodPrototypeReflection; use PHPStan\Rules\ClassNameCheck; use PHPStan\Rules\ClassNameNodePair; use PHPStan\Rules\ClassNameUsageLocation; @@ -29,6 +30,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\ErrorType; +use PHPStan\Type\ObjectType; use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -293,12 +295,24 @@ private function checkClassName(string $class, bool $isName, Node $node, Scope&N $classDisplayName = SprintfHelper::escapeFormatString($classReflection->getDisplayName()); + $calledConstructorReflection = $constructorReflection; + if ($constructorReflection->getDeclaringClass()->getName() !== $classReflection->getName()) { + // static in the PHPDoc of an inherited constructor is the class being instantiated, + // as it is the class a method is called on + $calledConstructorReflection = (new CalledOnTypeUnresolvedMethodPrototypeReflection( + $constructorReflection, + $constructorReflection->getDeclaringClass(), + false, + new ObjectType($classReflection->getName(), classReflection: $classReflection), + ))->getTransformedMethod(); + } + return array_merge($messages, $this->check->check( ParametersAcceptorSelector::selectFromArgs( $scope, $node->getArgs(), - $constructorReflection->getVariants(), - $constructorReflection->getNamedArgumentsVariants(), + $calledConstructorReflection->getVariants(), + $calledConstructorReflection->getNamedArgumentsVariants(), ), $scope, $constructorReflection->getDeclaringClass()->isBuiltin(), diff --git a/src/Rules/Keywords/RequireFileExistsRule.php b/src/Rules/Keywords/RequireFileExistsRule.php index b1c496608b5..cbff161c0b3 100644 --- a/src/Rules/Keywords/RequireFileExistsRule.php +++ b/src/Rules/Keywords/RequireFileExistsRule.php @@ -8,6 +8,7 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Include_; use PhpParser\Node\Name\FullyQualified; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -58,7 +59,7 @@ public function getNodeType(): string return Include_::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&DependencyEmitter $scope): array { if ($this->isInFileExists($node, $scope)) { return []; @@ -81,7 +82,13 @@ public function processNode(Node $node, Scope $scope): array $pathExpr = '"' . $path . '"'; } - $errors[] = $this->getErrorMessage($node, $pathExpr, $this->includedFilePathResolver->resolve($path, $scope)); + // The error is about a path, and a path is nothing the dependency graph tracks. Declaring the + // paths makes the result cache re-analyse this file when one of them is created. + foreach ($this->includedFilePathResolver->resolve($path, $scope) as $candidatePath) { + $scope->fileDependency($candidatePath); + } + + $errors[] = $this->getErrorMessage($node, $pathExpr); } return $errors; @@ -124,10 +131,7 @@ private function getScopeFile(Scope $scope): string return $scope->getFile(); } - /** - * @param list $candidatePaths - */ - private function getErrorMessage(Include_ $node, string $filePath, array $candidatePaths): IdentifierRuleError + private function getErrorMessage(Include_ $node, string $filePath): IdentifierRuleError { $message = 'Path in %s() %s is not a file or it does not exist.'; @@ -154,21 +158,13 @@ private function getErrorMessage(Include_ $node, string $filePath, array $candid $identifier = sprintf('%s.fileNotFound', $identifierType); - $builder = RuleErrorBuilder::message( + return RuleErrorBuilder::message( sprintf( $message, $type, $filePath, ), - )->identifier($identifier); - - // The error is about a path, and a path is nothing the dependency graph tracks. Declaring the - // paths makes the result cache re-analyse this file when one of them is created. - foreach ($candidatePaths as $candidatePath) { - $builder->fileDependency($candidatePath); - } - - return $builder->build(); + )->identifier($identifier)->build(); } /** diff --git a/src/Rules/Methods/OverridingMethodRule.php b/src/Rules/Methods/OverridingMethodRule.php index b08e81dd8bc..5f9447faf09 100644 --- a/src/Rules/Methods/OverridingMethodRule.php +++ b/src/Rules/Methods/OverridingMethodRule.php @@ -5,6 +5,7 @@ use PhpParser\Node; use PhpParser\Node\Attribute; use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; @@ -52,7 +53,7 @@ public function getNodeType(): string return InClassMethodNode::class; } - public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter&DependencyEmitter $scope): array { $method = $node->getMethodReflection(); $prototypeData = $this->methodPrototypeFinder->findPrototype($node->getClassReflection(), $method->getName()); @@ -365,7 +366,7 @@ private function filterOverrideAttribute(array $attrGroups): array private function addErrors( array $errors, InClassMethodNode $classMethod, - Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter&DependencyEmitter $scope, ): array { if (count($errors) > 0) { diff --git a/src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php b/src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php index 7e2963cf1a0..9280c062350 100644 --- a/src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php +++ b/src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php @@ -12,6 +12,8 @@ use PHPStan\Rules\Generics\GenericObjectTypeCheck; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\TrinaryLogic; +use PHPStan\Type\ClassConstantAccessType; use PHPStan\Type\ClosureType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Type; @@ -143,7 +145,9 @@ public function check( } if (in_array($tagName, ['@param', '@param-out'], true)) { - $isParamSuperType = $nativeParamType->isSuperTypeOf($phpDocParamType); + $isParamSuperType = $this->isDeferredClassConstantAccess($phpDocParamType) + ? TrinaryLogic::createYes() + : $nativeParamType->isSuperTypeOf($phpDocParamType); if ($isParamSuperType->no()) { $errors[] = RuleErrorBuilder::message(sprintf( 'PHPDoc tag %s for parameter $%s with type %s is incompatible with native type %s.', @@ -196,7 +200,9 @@ public function check( $errors[] = $errorBuilder->build(); } else { - $isReturnSuperType = $nativeReturnType->isSuperTypeOf($phpDocReturnType); + $isReturnSuperType = $this->isDeferredClassConstantAccess($phpDocReturnType) + ? TrinaryLogic::createYes() + : $nativeReturnType->isSuperTypeOf($phpDocReturnType); $errors = array_merge($errors, $this->genericObjectTypeCheck->check( $phpDocReturnType, 'PHPDoc tag @return contains generic type %s but %s %s is not generic.', @@ -241,4 +247,13 @@ public function check( return $errors; } + /** + * static::FOO_* or T::* - which constants it stands for is known only for the class the method is + * called on, so it cannot be compared with the native type here. + */ + private function isDeferredClassConstantAccess(Type $type): bool + { + return $type instanceof ClassConstantAccessType && !$type->isResolvable(); + } + } diff --git a/src/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRule.php b/src/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRule.php index 36662057dd2..65c62d48d87 100644 --- a/src/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRule.php +++ b/src/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRule.php @@ -11,6 +11,7 @@ use PHPStan\Rules\Generics\GenericObjectTypeCheck; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\Type\ClassConstantAccessType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\VerbosityLevel; use function array_merge; @@ -69,7 +70,10 @@ public function processNode(Node $node, Scope $scope): array } $nativeType = $node->getNativeType(); - if ($nativeType !== null) { + // static::FOO_* or T::* - which constants it stands for is known only for the class of the + // object, so it cannot be compared with the native type here + $isDeferred = $phpDocType instanceof ClassConstantAccessType && !$phpDocType->isResolvable(); + if ($nativeType !== null && !$isDeferred) { $isSuperType = $nativeType->isSuperTypeOf($phpDocType); if ($isSuperType->no()) { $messages[] = RuleErrorBuilder::message(sprintf( diff --git a/src/Rules/Playground/PromoteParameterRule.php b/src/Rules/Playground/PromoteParameterRule.php index 22a1c6aeedc..640238ae01f 100644 --- a/src/Rules/Playground/PromoteParameterRule.php +++ b/src/Rules/Playground/PromoteParameterRule.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\Container; @@ -88,7 +89,7 @@ private function getOriginalRule(): ?Rule return $this->originalRule = $originalRule; } - public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter&DependencyEmitter $scope): array { if ($this->parameterValue) { return []; diff --git a/src/Rules/Rule.php b/src/Rules/Rule.php index b56cbc92b4c..5421f4d43a5 100644 --- a/src/Rules/Rule.php +++ b/src/Rules/Rule.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\ExtensionInterface; @@ -38,6 +39,6 @@ public function getNodeType(): string; * @param TNodeType $node * @return list */ - public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array; + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter&DependencyEmitter $scope): array; } diff --git a/src/Rules/RuleErrorBuilder.php b/src/Rules/RuleErrorBuilder.php index 51efa2b15f4..ff7c90e0073 100644 --- a/src/Rules/RuleErrorBuilder.php +++ b/src/Rules/RuleErrorBuilder.php @@ -187,6 +187,8 @@ public function file(string $file, ?string $fileDescription = null): self * re-analyses the file the error is reported in when that file appears, changes or is deleted. * The path must be absolute; it does not have to exist. * + * @deprecated Call fileDependency() on the Scope instead (Scope&DependencyEmitter) - it declares + * the dependency whether the rule reports an error or not. * @api * @phpstan-this-out self * @return self diff --git a/src/Testing/CompositeRule.php b/src/Testing/CompositeRule.php index 269ed259cc4..931cb653062 100644 --- a/src/Testing/CompositeRule.php +++ b/src/Testing/CompositeRule.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\Rules\DirectRegistry; @@ -38,7 +39,7 @@ public function getNodeType(): string return Node::class; } - public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter&DependencyEmitter $scope): array { $errors = []; diff --git a/src/Testing/DelayedRule.php b/src/Testing/DelayedRule.php index 17909e3721b..a13dfb06fac 100644 --- a/src/Testing/DelayedRule.php +++ b/src/Testing/DelayedRule.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\Rules\DirectRegistry; @@ -44,7 +45,7 @@ public function getDelayedErrors(): array return $this->errors; } - public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter&DependencyEmitter $scope): array { $nodeType = get_class($node); foreach ($this->registry->getRules($nodeType) as $rule) { diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index 82f16583c30..345fa7af591 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\RuleErrorTransformer; use PHPStan\Analyser\StatementsHandler; use PHPStan\Analyser\TypeSpecifier; +use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Collectors\Collector; use PHPStan\Collectors\Registry as CollectorRegistry; use PHPStan\Dependency\DependencyResolver; @@ -117,6 +118,7 @@ private function getAnalyser(DirectRuleRegistry $ruleRegistry): Analyser self::getContainer()->getByType(RuleErrorTransformer::class), new LocalIgnoresProcessor(), false, + self::getContainer()->getByType(ValueDependencyCollector::class), ); $this->analyser = new Analyser( $fileAnalyser, diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 2b54632598c..6aa515b13d6 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '63cd54c'; + public const EXPECTED_EXTENSION_VERSION = '0d41107'; private static bool $active = false; diff --git a/src/Type/ClassConstantAccessType.php b/src/Type/ClassConstantAccessType.php index 991c271295e..ed55e80967b 100644 --- a/src/Type/ClassConstantAccessType.php +++ b/src/Type/ClassConstantAccessType.php @@ -6,6 +6,7 @@ use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\Turbo\ShadowedByTurboExtension; +use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Traits\LateResolvableTypeTrait; use PHPStan\Type\Traits\NonGeneralizableTypeTrait; @@ -17,13 +18,27 @@ final class ClassConstantAccessType implements CompoundType, LateResolvableType use LateResolvableTypeTrait; use NonGeneralizableTypeTrait; + /** + * @param Type|null $nativeType The native type next to the PHPDoc type this is - see withNativeType(). + */ public function __construct( private Type $type, private string $constantName, + private ?Type $nativeType = null, ) { } + /** + * The PHPDoc type of a parameter or of a return type with its native type. Which constants + * static:: or T:: stands for is known only once the class is, so the two are combined only + * then - see TypehintHelper::decideType(). + */ + public function withNativeType(Type $nativeType): self + { + return new self($this->type, $this->constantName, $nativeType); + } + public function getReferencedClasses(): array { return $this->type->getReferencedClasses(); @@ -36,9 +51,15 @@ public function getReferencedTemplateTypes(TemplateTypeVariance $positionVarianc public function equals(Type $type): bool { - return $type instanceof self - && $this->constantName === $type->constantName - && $this->type->equals($type->type); + if (!$type instanceof self || $this->constantName !== $type->constantName || !$this->type->equals($type->type)) { + return false; + } + + if ($this->nativeType === null || $type->nativeType === null) { + return $this->nativeType === $type->nativeType; + } + + return $this->nativeType->equals($type->nativeType); } public function describe(VerbosityLevel $level): string @@ -46,18 +67,18 @@ public function describe(VerbosityLevel $level): string return $this->resolve()->describe($level); } + /** + * Not while the class is a template type or static - both can still turn out to be a class + * declaring constants the class known now does not. + */ public function isResolvable(): bool { - return !TypeUtils::containsTemplateType($this->type); + return !TypeUtils::containsTemplateType($this->type) && !$this->type instanceof StaticType; } protected function getResult(): Type { - if ($this->type->hasConstant($this->constantName)->yes()) { - return $this->type->getConstant($this->constantName)->getValueType(); - } - - return new ErrorType(); + return ClassConstantPatternResolver::resolve($this->type, $this->constantName, $this->nativeType); } /** @@ -71,7 +92,7 @@ public function traverse(callable $cb): Type return $this; } - return new self($type, $this->constantName); + return new self($type, $this->constantName, $this->nativeType); } public function traverseSimultaneously(Type $right, callable $cb): Type @@ -86,12 +107,12 @@ public function traverseSimultaneously(Type $right, callable $cb): Type return $this; } - return new self($type, $this->constantName); + return new self($type, $this->constantName, $this->nativeType); } public function toPhpDocNode(): TypeNode { - return new ConstTypeNode(new ConstFetchNode('static', $this->constantName)); + return new ConstTypeNode(new ConstFetchNode($this->type instanceof TemplateType ? $this->type->getName() : 'static', $this->constantName)); } } diff --git a/src/Type/ClassConstantPatternResolver.php b/src/Type/ClassConstantPatternResolver.php new file mode 100644 index 00000000000..f19fcbd1c68 --- /dev/null +++ b/src/Type/ClassConstantPatternResolver.php @@ -0,0 +1,126 @@ +getObjectClassReflections(); + if ($classReflections === []) { + return new ErrorType(); + } + + $isClassKnown = !$type instanceof StaticType && !TypeUtils::containsTemplateType($type); + $constantTypes = []; + foreach ($classReflections as $classReflection) { + $classConstantTypes = str_contains($pattern, '*') + ? self::resolveWildcard($classReflection, $pattern, $isClassKnown) + : self::resolveConstant($classReflection, $pattern); + if ($classConstantTypes instanceof MixedType) { + return $classConstantTypes; + } + + foreach ($classConstantTypes as $constantType) { + $constantTypes[] = $constantType; + } + } + + if ($constantTypes === []) { + return new ErrorType(); + } + + return TypeCombinator::union(...$constantTypes); + } + + /** + * @return list|MixedType + */ + private static function resolveWildcard(ClassReflection $classReflection, string $pattern, bool $isClassKnown): array|MixedType + { + if ( + !$classReflection->isFinal() + && (!$isClassKnown || $classReflection->isInterface() || $classReflection->isAbstract()) + ) { + return new MixedType(); + } + + // convert * into .*? and escape everything else so the constants can be matched against the pattern + $regex = '{^' . str_replace('\\*', '.*?', preg_quote($pattern)) . '$}D'; + $constantTypes = []; + foreach ($classReflection->getNativeReflection()->getReflectionConstants() as $reflectionConstant) { + $constantName = $reflectionConstant->getName(); + if (Strings::match($constantName, $regex) === null) { + continue; + } + + if ($classReflection->isEnum() && $classReflection->hasEnumCase($constantName)) { + $constantTypes[] = new EnumCaseObjectType($classReflection->getName(), $constantName); + continue; + } + + $constantTypes[] = $classReflection->getConstant($constantName)->getValueType(); + } + + return $constantTypes; + } + + /** + * A single constant is the one of the class it is looked up on, like `static::FOO` in a return + * type is the value for the class the method is called on. + * + * @return list|MixedType + */ + private static function resolveConstant(ClassReflection $classReflection, string $constantName): array|MixedType + { + if (!$classReflection->hasConstant($constantName)) { + if (!$classReflection->isFinal()) { + return new MixedType(); + } + + return []; + } + + if ($classReflection->isEnum() && $classReflection->hasEnumCase($constantName)) { + return [new EnumCaseObjectType($classReflection->getName(), $constantName)]; + } + + return [$classReflection->getConstant($constantName)->getValueType()]; + } + +} diff --git a/src/Type/DynamicFunctionReturnTypeExtension.php b/src/Type/DynamicFunctionReturnTypeExtension.php index 757c14f0a13..fac50bbd442 100644 --- a/src/Type/DynamicFunctionReturnTypeExtension.php +++ b/src/Type/DynamicFunctionReturnTypeExtension.php @@ -3,6 +3,7 @@ namespace PHPStan\Type; use PhpParser\Node\Expr\FuncCall; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\Scope; use PHPStan\Broker\BrokerFactory; use PHPStan\DependencyInjection\ExtensionInterface; @@ -31,6 +32,6 @@ interface DynamicFunctionReturnTypeExtension public function isFunctionSupported(FunctionReflection $functionReflection): bool; - public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type; + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope&DependencyEmitter $scope): ?Type; } diff --git a/src/Type/DynamicMethodReturnTypeExtension.php b/src/Type/DynamicMethodReturnTypeExtension.php index 557a2ea536f..a3c723907f4 100644 --- a/src/Type/DynamicMethodReturnTypeExtension.php +++ b/src/Type/DynamicMethodReturnTypeExtension.php @@ -3,6 +3,7 @@ namespace PHPStan\Type; use PhpParser\Node\Expr\MethodCall; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\Scope; use PHPStan\Broker\BrokerFactory; use PHPStan\DependencyInjection\ExtensionInterface; @@ -34,6 +35,6 @@ public function getClass(): string; public function isMethodSupported(MethodReflection $methodReflection): bool; - public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type; + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope&DependencyEmitter $scope): ?Type; } diff --git a/src/Type/DynamicStaticMethodReturnTypeExtension.php b/src/Type/DynamicStaticMethodReturnTypeExtension.php index c450ce0b7b0..d798b493539 100644 --- a/src/Type/DynamicStaticMethodReturnTypeExtension.php +++ b/src/Type/DynamicStaticMethodReturnTypeExtension.php @@ -3,6 +3,7 @@ namespace PHPStan\Type; use PhpParser\Node\Expr\StaticCall; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\Scope; use PHPStan\Broker\BrokerFactory; use PHPStan\DependencyInjection\ExtensionInterface; @@ -34,6 +35,6 @@ public function getClass(): string; public function isStaticMethodSupported(MethodReflection $methodReflection): bool; - public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type; + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope&DependencyEmitter $scope): ?Type; } diff --git a/src/Type/ExpressionTypeResolverExtension.php b/src/Type/ExpressionTypeResolverExtension.php index efaa35cc738..20c78da2131 100644 --- a/src/Type/ExpressionTypeResolverExtension.php +++ b/src/Type/ExpressionTypeResolverExtension.php @@ -3,6 +3,7 @@ namespace PHPStan\Type; use PhpParser\Node\Expr; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\Scope; use PHPStan\Broker\BrokerFactory; use PHPStan\DependencyInjection\ExtensionInterface; @@ -26,6 +27,6 @@ interface ExpressionTypeResolverExtension { - public function getType(Expr $expr, Scope $scope): ?Type; + public function getType(Expr $expr, Scope&DependencyEmitter $scope): ?Type; } diff --git a/src/Type/TypehintHelper.php b/src/Type/TypehintHelper.php index 9254c1c6359..6fdab79212b 100644 --- a/src/Type/TypehintHelper.php +++ b/src/Type/TypehintHelper.php @@ -91,6 +91,11 @@ public static function decideType( if ($phpDocType instanceof NeverType && $phpDocType->isExplicit()) { return $phpDocType; } + if ($phpDocType instanceof ClassConstantAccessType && !$phpDocType->isResolvable()) { + // static::FOO_* or T::* - which constants it stands for is known only for the class the + // method is called on, and cannot be compared with the native type before that + return $phpDocType->withNativeType($type); + } if ( $type instanceof MixedType && !$type->isExplicitMixed() diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index adb02c6e81b..36f53b2a7e2 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -872,6 +872,7 @@ private function createAnalyser(): Analyser $container->getByType(RuleErrorTransformer::class), new LocalIgnoresProcessor(), false, + $container->getByType(ValueDependencyCollector::class), ); return new Analyser( diff --git a/tests/PHPStan/Analyser/ValueDependencyCollectorTest.php b/tests/PHPStan/Analyser/ValueDependencyCollectorTest.php new file mode 100644 index 00000000000..55433aea718 --- /dev/null +++ b/tests/PHPStan/Analyser/ValueDependencyCollectorTest.php @@ -0,0 +1,158 @@ +getByType(ValueDependencyCollector::class); + $extension = self::getContainer()->getByType(TestValueExtension::class); + $scopeFactory = self::getContainer()->getByType(ScopeFactory::class); + $analysedFileScope = $scopeFactory->create(ScopeContext::create('/project/src/Analysed.php')); + $otherFileScope = $scopeFactory->create(ScopeContext::create('/project/src/Other.php')); + + $collector->startFile('/project/src/Analysed.php'); + $collector->record(TestValueExtension::class, 'a', $analysedFileScope, true); + // the same value again - from another rule or extension - is the same dependency + $collector->record(TestValueExtension::class, 'a', $analysedFileScope, true); + $collector->record(TestValueExtension::class, 'b', $otherFileScope, false); + + $a = ValueDependencyCollector::getId(TestValueExtension::class, 'a'); + $b = ValueDependencyCollector::getId(TestValueExtension::class, 'b'); + $this->assertSame([ + 'values' => [ + $a => [TestValueExtension::class, 'a', 'value of a'], + $b => [TestValueExtension::class, 'b', 'value of b'], + ], + 'dependents' => [ + '/project/src/Analysed.php' => [ + 'analysis' => [$a, $b], + 'declarations' => [], + ], + '/project/src/Other.php' => [ + 'analysis' => [], + 'declarations' => [$b], + ], + ], + ], $collector->finishFile()); + $this->assertSame(2, $extension->calls); + } + + public function testFile(): void + { + $collector = self::getContainer()->getByType(ValueDependencyCollector::class); + $scopeFactory = self::getContainer()->getByType(ScopeFactory::class); + $fileHelper = self::getContainer()->getByType(FileHelper::class); + // the paths FileAnalyser passes along are normalized - with backslashes on Windows + $analysedFile = $fileHelper->normalizePath(__DIR__ . '/data/value-dependency-analysed.php'); + $dataFile = $fileHelper->normalizePath(__DIR__ . '/data/value-dependency-missing.txt'); + $otherFile = $fileHelper->normalizePath(__DIR__ . '/data/value-dependency-other.php'); + $analysedFileScope = $scopeFactory->create(ScopeContext::create($analysedFile)); + $otherFileScope = $scopeFactory->create(ScopeContext::create($otherFile)); + + $collector->startFile($analysedFile); + // the analysed file is re-analysed when it changes anyway + $collector->recordFile($analysedFile, $analysedFileScope, true); + // the same path, written differently, is the same dependency + $collector->recordFile(__DIR__ . '/data/../data/value-dependency-missing.txt', $analysedFileScope, true); + $collector->recordFile($dataFile, $analysedFileScope, true); + // outside the walk, what the other file declares depends on the analysed file + $collector->recordFile($analysedFile, $otherFileScope, false); + + $dataFileId = ValueDependencyCollector::getId(FileResultCacheValueExtension::class, $dataFile); + $analysedFileId = ValueDependencyCollector::getId(FileResultCacheValueExtension::class, $analysedFile); + $this->assertSame([ + 'values' => [ + $dataFileId => [FileResultCacheValueExtension::class, $dataFile, 'missing'], + $analysedFileId => [FileResultCacheValueExtension::class, $analysedFile, 'missing'], + ], + 'dependents' => [ + $analysedFile => [ + 'analysis' => [$dataFileId, $analysedFileId], + 'declarations' => [], + ], + $otherFile => [ + 'analysis' => [], + 'declarations' => [$analysedFileId], + ], + ], + ], $collector->finishFile()); + } + + public function testNothingOutsideOfAnalysedFile(): void + { + $collector = self::getContainer()->getByType(ValueDependencyCollector::class); + $scope = self::getContainer()->getByType(ScopeFactory::class)->create(ScopeContext::create('/project/src/Analysed.php')); + + $collector->record(TestValueExtension::class, 'a', $scope, true); + $collector->startFile('/project/src/Analysed.php'); + + $this->assertSame([ + 'values' => [], + 'dependents' => [ + '/project/src/Analysed.php' => [ + 'analysis' => [], + 'declarations' => [], + ], + ], + ], $collector->finishFile()); + } + + public function testUnregisteredExtension(): void + { + $collector = self::getContainer()->getByType(ValueDependencyCollector::class); + $scope = self::getContainer()->getByType(ScopeFactory::class)->create(ScopeContext::create('/project/src/Analysed.php')); + + $collector->startFile('/project/src/Analysed.php'); + try { + $this->expectException(ShouldNotHappenException::class); + $this->expectExceptionMessage('stdClass is not registered as a result cache value extension. Register it with the phpstan.resultCacheValueExtension service tag.'); + // @phpstan-ignore argument.type (not an extension on purpose) + $collector->record('stdClass', 'a', $scope, true); + } finally { + $collector->finishFile(); + } + } + + public function testMerge(): void + { + $this->assertSame([ + 'values' => [ + 'x' => ['E', 'x', 'first'], + 'y' => ['E', 'y', 'y'], + ], + 'dependents' => [ + '/a.php' => ['analysis' => ['x', 'y'], 'declarations' => []], + '/b.php' => ['analysis' => [], 'declarations' => ['x']], + ], + ], ValueDependencyCollector::merge([ + 'values' => ['x' => ['E', 'x', 'first']], + 'dependents' => ['/a.php' => ['analysis' => ['x'], 'declarations' => []]], + ], [ + 'values' => ['x' => ['E', 'x', 'second'], 'y' => ['E', 'y', 'y']], + 'dependents' => [ + '/a.php' => ['analysis' => ['x', 'y'], 'declarations' => []], + '/b.php' => ['analysis' => [], 'declarations' => ['x']], + ], + ])); + } + + #[Override] + public static function getAdditionalConfigFiles(): array + { + return array_merge(parent::getAdditionalConfigFiles(), [ + __DIR__ . '/value-dependency-collector.neon', + ]); + } + +} diff --git a/tests/PHPStan/Analyser/data/ValueDependencyCollectorTestExtension.php b/tests/PHPStan/Analyser/data/ValueDependencyCollectorTestExtension.php new file mode 100644 index 00000000000..12fda459a98 --- /dev/null +++ b/tests/PHPStan/Analyser/data/ValueDependencyCollectorTestExtension.php @@ -0,0 +1,29 @@ +calls++; + + return 'value of ' . $key; + } + + public function keyToResultCache(string $key): string + { + return $key; + } + + public function keyFromResultCache(string $storedKey): string + { + return $storedKey; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-11510.php b/tests/PHPStan/Analyser/nsrt/bug-11510.php new file mode 100644 index 00000000000..04fcb3e1608 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11510.php @@ -0,0 +1,37 @@ + $class + * @param T::CONFIG_* $classConfig + */ +function foo(string $class, string $classConfig): void +{ + // T is any implementation of FooInterface - only the native type is certain + assertType('string', $classConfig); +} + +/** + * @template T of FooInterface + * @param class-string $class + * @return T::CONFIG_* + */ +function config(string $class): string +{ + return ''; +} + +function (): void { + assertType("'test'", config(Bar::class)); +}; diff --git a/tests/PHPStan/Analyser/nsrt/bug-4548.php b/tests/PHPStan/Analyser/nsrt/bug-4548.php new file mode 100644 index 00000000000..68aa0cd1825 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-4548.php @@ -0,0 +1,43 @@ +value = $value; + } + + /** @return static::* */ + public function getValue(): int + { + assertType('int', $this->value); + + return $this->value; + } + +} + +final class Suit extends Enum +{ + + public const HEARTS = 1; + public const DIAMONDS = 2; + public const SPADES = 3; + public const CLUBS = 4; + +} + +function (Suit $suit): void { + assertType('1|2|3|4', $suit->getValue()); +}; diff --git a/tests/PHPStan/Analyser/nsrt/class-constant-wildcard-static-template.php b/tests/PHPStan/Analyser/nsrt/class-constant-wildcard-static-template.php new file mode 100644 index 00000000000..71398cdcfc6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/class-constant-wildcard-static-template.php @@ -0,0 +1,182 @@ += 8.1 + +namespace ClassConstantWildcardStaticTemplate; + +use function PHPStan\Testing\assertType; + +interface Extension +{ + + /** + * @param static::* $type + * @return static::* + */ + public function doFoo(string $type): string; + + /** + * @return static::VERSION + */ + public function getVersion(): string; + +} + +final class FileExtension implements Extension +{ + + public const FILE = 'file'; + public const VERSION = 'file-1'; + + public function doFoo(string $type): string + { + assertType("'file'|'file-1'", $type); + + return $type; + } + + public function getVersion(): string + { + return self::VERSION; + } + +} + +class ContainerExtension implements Extension +{ + + public const SERVICE_HAS = 'hasService'; + public const SERVICE_GET = 'getService'; + public const PARAMETER = 'parameter'; + public const VERSION = 'container-1'; + + public function doFoo(string $type): string + { + // static can be a subclass declaring more constants - only the native type is certain + assertType('string', $type); + + return $type; + } + + public function getVersion(): string + { + return static::VERSION; + } + + /** + * @param static::SERVICE_* $type + */ + public function doService(string $type): void + { + assertType('string', $type); + } + + /** + * @return static::SERVICE_* + */ + public function getService(): string + { + return self::SERVICE_HAS; + } + +} + +final class SymfonyContainerExtension extends ContainerExtension +{ + + public const SERVICE_TAGGED = 'taggedService'; + public const VERSION = 'symfony-1'; + +} + +/** + * @template T of Extension + * @param class-string $extensionClass + * @param T::* $type + * @return T::* + */ +function valueOf(string $extensionClass, string $type): string +{ + assertType('class-string', $extensionClass); + + return $type; +} + +/** + * @template T of Extension + * @param class-string $extensionClass + * @return class-string + */ +function classOf(string $extensionClass): string +{ + /** @var T::class $class */ + $class = $extensionClass; + + return $class; +} + +/** + * @template T of FileExtension + * @param class-string $extensionClass + * @param T::* $type + */ +function finalBound(string $extensionClass, string $type): void +{ + assertType("'file'|'file-1'", $type); +} + +function ( + Extension $extension, + FileExtension $fileExtension, + ContainerExtension $containerExtension, + SymfonyContainerExtension $symfonyContainerExtension, +): void { + // the class the method is called on decides which constants static:: stands for + assertType("'file'|'file-1'", $fileExtension->doFoo(FileExtension::FILE)); + assertType("'container-1'|'getService'|'hasService'|'parameter'", $containerExtension->doFoo(ContainerExtension::PARAMETER)); + assertType("'getService'|'hasService'|'parameter'|'symfony-1'|'taggedService'", $symfonyContainerExtension->doFoo(SymfonyContainerExtension::SERVICE_TAGGED)); + assertType("'getService'|'hasService'", $containerExtension->getService()); + assertType("'getService'|'hasService'|'taggedService'", $symfonyContainerExtension->getService()); + + // an interface declares none of the constants its implementations do - only the native type is certain + assertType('string', $extension->doFoo('anything')); + + // a single constant is the one of the class the method is called on, as with static::FOO elsewhere + assertType("'file-1'", $fileExtension->getVersion()); + assertType("'container-1'", $containerExtension->getVersion()); + assertType("'symfony-1'", $symfonyContainerExtension->getVersion()); + + // T::* stands for the constants of the class T is inferred as + assertType("'file'|'file-1'", valueOf(FileExtension::class, FileExtension::FILE)); + assertType("'getService'|'hasService'|'parameter'|'symfony-1'|'taggedService'", valueOf(SymfonyContainerExtension::class, SymfonyContainerExtension::SERVICE_HAS)); + assertType('class-string', classOf(FileExtension::class)); +}; + +trait HasModes +{ + + /** + * @param static::MODE_* $mode + * @return static::MODE_* + */ + public function withMode(string $mode): string + { + // in the context of the only class using the trait, which is final + assertType("'fast'|'slow'", $mode); + + return $mode; + } + +} + +final class Engine +{ + + use HasModes; + + public const MODE_FAST = 'fast'; + public const MODE_SLOW = 'slow'; + +} + +function (Engine $engine): void { + assertType("'fast'|'slow'", $engine->withMode(Engine::MODE_FAST)); +}; diff --git a/tests/PHPStan/Analyser/value-dependency-collector.neon b/tests/PHPStan/Analyser/value-dependency-collector.neon new file mode 100644 index 00000000000..4415967fe6a --- /dev/null +++ b/tests/PHPStan/Analyser/value-dependency-collector.neon @@ -0,0 +1,5 @@ +services: + - + class: PHPStan\Analyser\ValueDependencyCollectorTest\TestValueExtension + tags: + - phpstan.resultCacheValueExtension diff --git a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index d518365eaf6..e5be65d06a4 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -800,4 +800,14 @@ public function testBug12827(bool $checkImportedClassNameCase): void $this->analyse([__DIR__ . '/data/bug-12827.php'], $expectedErrors); } + public function testBug4548(): void + { + $this->analyse([__DIR__ . '/data/bug-4548.php'], [ + [ + 'Parameter #1 $value of class Bug4548\\Suit constructor expects 1|2|3|4, 5 given.', + 26, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Classes/data/bug-4548.php b/tests/PHPStan/Rules/Classes/data/bug-4548.php new file mode 100644 index 00000000000..9c6d1ac61df --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/bug-4548.php @@ -0,0 +1,26 @@ +value = $value; + } +} + +final class Suit extends Enum +{ + public const HEARTS = 1; + public const DIAMONDS = 2; + public const SPADES = 3; + public const CLUBS = 4; +} + +new Suit(Suit::HEARTS); +new Suit(5); diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 501b328ff6c..3ac6c660706 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -3315,4 +3315,14 @@ public function testOversizedArrayUnionWithEmptyArray(): void ]); } + public function testBug11510(): void + { + $this->analyse([__DIR__ . '/data/bug-11510.php'], [ + [ + 'Parameter #2 $classConfig of function Bug11510\\foo expects \'test\', \'hello\' given.', + 20, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11510.php b/tests/PHPStan/Rules/Functions/data/bug-11510.php new file mode 100644 index 00000000000..b48108451ea --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11510.php @@ -0,0 +1,20 @@ + $class + * @param T::CONFIG_* $classConfig + */ +function foo(string $class, string $classConfig): void { +} + +foo(Bar::class, 'test'); +foo(Bar::class, 'hello'); diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index c7bf350930e..7f5210d5f06 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4560,4 +4560,30 @@ public function testBug15251(): void $this->analyse([__DIR__ . '/data/bug-15251.php'], []); } + #[RequiresPhp('>= 8.1.0')] + public function testClassConstantWildcardArguments(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/class-constant-wildcard-arguments.php'], [ + [ + 'Parameter #2 $type of method ClassConstantWildcardArguments\\Emitter::valueDependency() expects \'file\', \'hasService\' given.', + 54, + ], + [ + 'Parameter #2 $type of method ClassConstantWildcardArguments\\Emitter::valueDependency() expects \'file\', \'nope\' given.', + 55, + ], + [ + 'Parameter #1 $type of method ClassConstantWildcardArguments\\FileExtension::doFoo() expects \'file\', \'hasService\' given.', + 58, + ], + [ + 'Parameter #1 $type of method ClassConstantWildcardArguments\\ContainerExtension::doFoo() expects \'hasService\', \'nope\' given.', + 59, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/class-constant-wildcard-arguments.php b/tests/PHPStan/Rules/Methods/data/class-constant-wildcard-arguments.php new file mode 100644 index 00000000000..17b1cc519cc --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/class-constant-wildcard-arguments.php @@ -0,0 +1,61 @@ += 8.1 + +namespace ClassConstantWildcardArguments; + +interface Extension +{ + + /** + * @param static::* $type + */ + public function doFoo(string $type): void; + +} + +final class FileExtension implements Extension +{ + + public const FILE = 'file'; + + public function doFoo(string $type): void + { + } + +} + +class ContainerExtension implements Extension +{ + + public const HAS_SERVICE = 'hasService'; + + public function doFoo(string $type): void + { + } + +} + +final class Emitter +{ + + /** + * @template T of Extension + * @param class-string $extensionClass + * @param T::* $type + */ + public function valueDependency(string $extensionClass, string $type): void + { + } + +} + +function (Emitter $emitter, Extension $extension, FileExtension $fileExtension, ContainerExtension $containerExtension): void { + $emitter->valueDependency(FileExtension::class, FileExtension::FILE); + $emitter->valueDependency(ContainerExtension::class, ContainerExtension::HAS_SERVICE); + $emitter->valueDependency(FileExtension::class, ContainerExtension::HAS_SERVICE); + $emitter->valueDependency(FileExtension::class, 'nope'); + + $fileExtension->doFoo(FileExtension::FILE); + $fileExtension->doFoo(ContainerExtension::HAS_SERVICE); + $containerExtension->doFoo('nope'); + $extension->doFoo('anything'); +}; diff --git a/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php index 808c165f1b2..c284aea9916 100644 --- a/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php @@ -521,4 +521,31 @@ public function testExplainUnresolvable(): void ]); } + #[RequiresPhp('>= 8.1.0')] + public function testClassConstantWildcardUnresolvable(): void + { + // static and a template can stand for a class declaring the constants - only a final class + // is known not to + $this->analyse([__DIR__ . '/data/class-constant-wildcard-unresolvable.php'], [ + [ + 'PHPDoc tag @param for parameter $type contains unresolvable type.', + 40, + ], + [ + 'PHPDoc tag @param for parameter $type contains unresolvable type.', + 65, + ], + ]); + } + + public function testBug4548(): void + { + $this->analyse([__DIR__ . '/data/bug-4548.php'], []); + } + + public function testBug11510(): void + { + $this->analyse([__DIR__ . '/data/bug-11510.php'], []); + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRuleTest.php index 674aa088002..bfe33676515 100644 --- a/tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRuleTest.php @@ -203,4 +203,9 @@ public function testBug11033(): void $this->analyse([__DIR__ . '/data/bug-11033.php'], []); } + public function testBug4548(): void + { + $this->analyse([__DIR__ . '/data/bug-4548.php'], []); + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/data/bug-11510.php b/tests/PHPStan/Rules/PhpDoc/data/bug-11510.php new file mode 100644 index 00000000000..0563de0b3e8 --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/bug-11510.php @@ -0,0 +1,20 @@ + $class + * @param T::CONFIG_* $classConfig + */ +function foo(string $class, string $classConfig): void { +} + +foo(Bar::class, 'test'); +foo(Bar::class, 'hello'); diff --git a/tests/PHPStan/Rules/PhpDoc/data/bug-4548.php b/tests/PHPStan/Rules/PhpDoc/data/bug-4548.php new file mode 100644 index 00000000000..d6e637a1a31 --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/bug-4548.php @@ -0,0 +1,26 @@ +value = $value; + } +} + +final class Suit extends Enum +{ + public const HEARTS = 1; + public const DIAMONDS = 2; + public const SPADES = 3; + public const CLUBS = 4; +} + +new Suit(Suit::HEARTS); +new Suit(5); diff --git a/tests/PHPStan/Rules/PhpDoc/data/class-constant-wildcard-unresolvable.php b/tests/PHPStan/Rules/PhpDoc/data/class-constant-wildcard-unresolvable.php new file mode 100644 index 00000000000..4868cea3a7a --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/class-constant-wildcard-unresolvable.php @@ -0,0 +1,67 @@ += 8.1 + +namespace ClassConstantWildcardUnresolvable; + +interface Extension +{ + + /** + * @param static::* $type + */ + public function doFoo(string $type): void; + + /** + * @param static::VERSION $type + */ + public function doBar(string $type): void; + +} + +class NotFinal +{ + + /** + * @param static::NOPE_* $type + */ + public function doFoo(string $type): void + { + } + +} + +final class IsFinal +{ + + public const FOO = 'foo'; + + /** + * @param static::NOPE_* $type + */ + public function doFoo(string $type): void + { + } + + /** + * @param static::F* $type + */ + public function doBar(string $type): void + { + } + +} + +/** + * @template T of NotFinal + * @param T::NOPE_* $type + */ +function notFinalBound(string $type): void +{ +} + +/** + * @template T of IsFinal + * @param T::NOPE_* $type + */ +function finalBound(string $type): void +{ +} diff --git a/turbo-ext/src/ClassConstantAccessType.cpp b/turbo-ext/src/ClassConstantAccessType.cpp index aae660864a5..7a8a50245a3 100644 --- a/turbo-ext/src/ClassConstantAccessType.cpp +++ b/turbo-ext/src/ClassConstantAccessType.cpp @@ -2,9 +2,9 @@ * PHPStanTurbo\ClassConstantAccessType — native implementation of * PHPStan\Type\ClassConstantAccessType. * - * State is the twin's two promoted `private Type $type` / `private string - * $constantName` in slots 0 and 1; LateResolvableTypeTrait's `private ?Type - * $result` follows them, declared by the shared registrar in TypeTraits.cpp + * State is the twin's three promoted `private Type $type` / `private string + * $constantName` / `private ?Type $nativeType` in slots 0 to 2; + * LateResolvableTypeTrait's `private ?Type $result` follows them, declared by the shared registrar in TypeTraits.cpp * that also supplies the trait's forwards; NonGeneralizableTypeTrait's * generalize() comes from its registrar. * @@ -31,29 +31,43 @@ class ClassConstantAccessType public: explicit ClassConstantAccessType(zend_object *self) : self(self) {} - /* __construct(private Type $type, private string $constantName); both - * borrowed */ - void construct(zval *type, zend_string *constantName) + /* __construct(private Type $type, private string $constantName, private + * ?Type $nativeType = null); all borrowed, $nativeType NULL for the + * default */ + void construct(zval *type, zend_string *constantName, zval *nativeType) { writeSlot(slots::type, type); zval name; ZVAL_STR(&name, constantName); writeSlot(slots::constantName, &name); + zval null; + ZVAL_NULL(&null); + writeSlot(slots::nativeType, nativeType != NULL ? nativeType : &null); } - /* new self($type, $constantName); UNDEF = pending exception */ - static zv::Val create(zval *type, zend_string *constantName) + /* new self($type, $constantName, $nativeType); UNDEF = pending exception */ + static zv::Val create(zval *type, zend_string *constantName, zval *nativeType) { zval object; if (UNEXPECTED(object_init_ex(&object, pt_ce_class_constant_access_type) != SUCCESS)) return zv::Val(); - ClassConstantAccessType(Z_OBJ(object)).construct(type, constantName); + ClassConstantAccessType(Z_OBJ(object)).construct(type, constantName, nativeType); return zv::Val::adopt(object); } + /* new self($this->type, $this->constantName, $nativeType) */ + zv::Val withNativeType(zval *nativeType) const + { + zval *t = type(); + zval *name = t != NULL ? constantName() : NULL; + if (UNEXPECTED(name == NULL)) return zv::Val(); + return create(t, Z_STR_P(name), nativeType); + } + /* the slots (borrowed); NULL with an Error pending when the constructor * never ran */ [[nodiscard]] zval *type() const { return slot(self, slots::type, "type"); } zval *constantName() const { return slot(self, slots::constantName, "constantName"); } + zval *nativeType() const { return slot(self, slots::nativeType, "nativeType"); } static zval *slot(zend_object *object, uint32_t index, const char *name) { return pt_typed_slot(object, index, pt_ce_class_constant_access_type, name); } @@ -83,7 +97,22 @@ class ClassConstantAccessType if (UNEXPECTED(theirType == NULL)) return false; zv::Val equal = callType(PT_LC("equals"), 1, theirType); if (UNEXPECTED(equal.isUndef())) return false; - out = zend_is_true(equal.raw()); + if (!zend_is_true(equal.raw())) { + out = false; + return true; + } + /* the native types: identical when either is null, equals() otherwise */ + zval *native = nativeType(); + if (UNEXPECTED(native == NULL)) return false; + zval *theirNative = slot(Z_OBJ_P(type), slots::nativeType, "nativeType"); + if (UNEXPECTED(theirNative == NULL)) return false; + if (Z_TYPE_P(native) == IS_NULL || Z_TYPE_P(theirNative) == IS_NULL) { + out = Z_TYPE_P(native) == Z_TYPE_P(theirNative); + return true; + } + zv::Val nativeEqual = pt_type_call(Z_OBJ_P(native), PT_LC("equals"), 1, theirNative); + if (UNEXPECTED(nativeEqual.isUndef())) return false; + out = zend_is_true(nativeEqual.raw()); return true; } @@ -99,41 +128,36 @@ class ClassConstantAccessType return pt_type_op(Z_OBJ_P(resolved.raw()), PT_OP_DESCRIBE, 1, level); } - /* !TypeUtils::containsTemplateType($this->type); false = pending exception */ + /* !TypeUtils::containsTemplateType($this->type) && !$this->type instanceof StaticType; + * false = pending exception */ [[nodiscard]] bool isResolvable(bool &out) const { zval *t = type(); if (UNEXPECTED(t == NULL)) return false; bool contains; if (UNEXPECTED(!pt_type_utils_contains_template_type(t, contains))) return false; - out = !contains; + out = !contains && !(pt_ce_static_type != NULL && instanceof_function(Z_OBJCE_P(t), pt_ce_static_type)); return true; } - /* $this->type->getConstant($this->constantName)->getValueType() when - * $this->type->hasConstant($this->constantName)->yes(), new ErrorType() - * otherwise; UNDEF = pending exception */ + /* ClassConstantPatternResolver::resolve($this->type, $this->constantName, + * $this->nativeType); UNDEF = pending exception */ zv::Val getResult() const { zval *t = type(); zval *name = t != NULL ? constantName() : NULL; /* one Error at a time, as the twin's first read raises */ - if (UNEXPECTED(name == NULL)) return zv::Val(); - zend_long has = pt_type_call_trinary(Z_OBJ_P(t), PT_LC("hasconstant"), 1, name); - if (UNEXPECTED(has < 0)) return zv::Val(); - if (has == PT_TRI_YES) { - zv::Val constant = pt_type_call(Z_OBJ_P(t), PT_LC("getconstant"), 1, name); - if (UNEXPECTED(constant.isUndef())) return zv::Val(); - if (UNEXPECTED(!zv::Ref(constant.raw()).isObject())) { - zend_type_error("phpstan_turbo: getConstant() must return an object"); - return zv::Val(); - } - return pt_type_call(Z_OBJ_P(constant.raw()), PT_LC("getvaluetype"), 0, NULL); - } - return pt_type_new_error_type(); + zval *native = name != NULL ? nativeType() : NULL; + if (UNEXPECTED(native == NULL)) return zv::Val(); + zval args[3]; + ZVAL_COPY_VALUE(&args[0], t); + ZVAL_COPY_VALUE(&args[1], name); + ZVAL_COPY_VALUE(&args[2], native); + return pt_type_call_static(PT_CLASS_CLASS_CONSTANT_PATTERN_RESOLVER, PT_LC("resolve"), 3, args); } - /* new self($cb($this->type), $this->constantName) when the callback - * changed the type, $this otherwise; UNDEF = pending exception */ + /* new self($cb($this->type), $this->constantName, $this->nativeType) when + * the callback changed the type, $this otherwise; UNDEF = pending + * exception */ zv::Val traverse(zend_fcall_info *fci, zend_fcall_info_cache *fcc) const { zval *t = type(); @@ -153,13 +177,23 @@ class ClassConstantAccessType return traversed(pt_type_traverse_call(fci, fcc, t, theirType)); } - /* new ConstTypeNode(new ConstFetchNode('static', $this->constantName)) */ + /* new ConstTypeNode(new ConstFetchNode($this->type instanceof TemplateType + * ? $this->type->getName() : 'static', $this->constantName)) */ zv::Val toPhpDocNode() const { - zval *name = constantName(); + zval *t = type(); + zval *name = t != NULL ? constantName() : NULL; if (UNEXPECTED(name == NULL)) return zv::Val(); + zend_class_entry *templateType = pt_class(PT_CLASS_TEMPLATE_TYPE); + if (UNEXPECTED(templateType == NULL)) return zv::Val(); zval args[2]; - ZVAL_STRINGL(&args[0], "static", sizeof("static") - 1); + if (instanceof_function(Z_OBJCE_P(t), templateType)) { + zv::Val templateName = pt_type_call(Z_OBJ_P(t), PT_LC("getname"), 0, NULL); + if (UNEXPECTED(templateName.isUndef())) return zv::Val(); + ZVAL_COPY(&args[0], templateName.raw()); + } else { + ZVAL_STRINGL(&args[0], "static", sizeof("static") - 1); + } ZVAL_COPY_VALUE(&args[1], name); zv::Val constFetch = pt_type_new(PT_CLASS_CONST_FETCH_NODE, 2, args); zval_ptr_dtor(&args[0]); @@ -183,7 +217,8 @@ class ClassConstantAccessType } /* the tail of traverse()/traverseSimultaneously(): `$this->type === $type - * ? $this : new self($type, $this->constantName)` (UNDEF in = UNDEF out) */ + * ? $this : new self($type, $this->constantName, $this->nativeType)` + * (UNDEF in = UNDEF out) */ zv::Val traversed(zv::Val type) const { if (UNEXPECTED(type.isUndef())) return zv::Val(); @@ -191,8 +226,9 @@ class ClassConstantAccessType if (UNEXPECTED(t == NULL)) return zv::Val(); if (pt_type_same_object(t, type.raw())) return thisValue(); zval *name = constantName(); - if (UNEXPECTED(name == NULL)) return zv::Val(); - return create(type.raw(), Z_STR_P(name)); + zval *native = name != NULL ? nativeType() : NULL; + if (UNEXPECTED(native == NULL)) return zv::Val(); + return create(type.raw(), Z_STR_P(name), Z_TYPE_P(native) == IS_NULL ? NULL : native); } }; @@ -202,7 +238,7 @@ using phpstanturbo::ClassConstantAccessType; bool pt_class_constant_access_type_new(zval *out, zval *type, zend_string *constantName) { - return pt_val_into(ClassConstantAccessType::create(type, constantName), out); + return pt_val_into(ClassConstantAccessType::create(type, constantName, NULL), out); } /* {{{ engine ABI glue: parameter parsing + registration */ @@ -217,7 +253,14 @@ PT_MINIT_REGISTRATION(pt_register_class_constant_access_type) * declares $result after them */ ptdecl::ClassConstantAccessType::declareProperties(cls); - cls.method<&ClassConstantAccessType::construct, zp::TypeObj, zp::Str>(sigs::__construct); + cls.method(sigs::__construct, [](INTERNAL_FUNCTION_PARAMETERS) { + zval *type, *nativeType = NULL; + zend_string *constantName; + if (!zp::parse>(execute_data, type, constantName, nativeType)) RETURN_THROWS(); + PT_THIS.construct(type, constantName, nativeType); + }); + + cls.method<&ClassConstantAccessType::withNativeType, zp::TypeObj>(sigs::withNativeType); cls.method<&ClassConstantAccessType::getReferencedClasses>(sigs::getReferencedClasses); cls.op(); diff --git a/turbo-ext/src/MutatingScope.cpp b/turbo-ext/src/MutatingScope.cpp index d4fa8cec495..697c72f5bfd 100644 --- a/turbo-ext/src/MutatingScope.cpp +++ b/turbo-ext/src/MutatingScope.cpp @@ -10582,6 +10582,49 @@ class MutatingScope return !result.isUndef(); } + /* $this->container->getByType(ValueDependencyCollector::class)->record($extensionClass, $key, + * $this, $this->nodeCallback !== null) */ + bool valueDependency(zend_string *extensionClass, zend_string *key) + { + zval extensionClassZv, keyZv; + ZVAL_STR(&extensionClassZv, extensionClass); + ZVAL_STR(&keyZv, key); + return callValueDependencyCollector(PT_LC("record"), &extensionClassZv, &keyZv); + } + + /* $this->container->getByType(ValueDependencyCollector::class)->recordFile($file, $this, + * $this->nodeCallback !== null) */ + bool fileDependency(zend_string *file) + { + zval fileZv; + ZVAL_STR(&fileZv, file); + return callValueDependencyCollector(PT_LC("recordfile"), &fileZv, NULL); + } + + /* the collector's method (lowercase name) with the arguments, the scope and whether it is inside the walk */ + bool callValueDependencyCollector(const char *method, size_t methodLength, zval *first, zval *second) + { + zv::Ref nodeCallback = slot(PT_MS_PROP_NODE_CALLBACK); + if (UNEXPECTED(nodeCallback.isUndef())) { + (void) uninitializedProperty("nodeCallback"); + return false; + } + zval insideWalk; + ZVAL_BOOL(&insideWalk, !nodeCallback.isNull()); + zv::Val collector = containerGetByType(PT_LC("PHPStan\\Analyser\\ValueDependencyCollector")); + if (UNEXPECTED(collector.isUndef())) return false; + zend_object *collectorObject = requireObject(collector, method); + if (UNEXPECTED(collectorObject == NULL)) return false; + if (second == NULL) { + zv::Args args{first, self, &insideWalk}; + zv::Val result = pt_type_call(collectorObject, method, methodLength, 3, args); + return !result.isUndef(); + } + zv::Args args{first, second, self, &insideWalk}; + zv::Val result = pt_type_call(collectorObject, method, methodLength, 4, args); + return !result.isUndef(); + } + static void throwNodeCallbackMissing() { zend_class_entry *ce = pt_class(PT_CLASS_SHOULD_NOT_HAPPEN); @@ -13349,6 +13392,18 @@ PT_MINIT_REGISTRATION(pt_register_mutating_scope) if (UNEXPECTED(!PT_THIS.emitCollectedData(collectorType, data))) RETURN_THROWS(); }); + cls.method(sigs::valueDependency, [](INTERNAL_FUNCTION_PARAMETERS) { + zend_string *extensionClass, *key; + if (!zp::parse(execute_data, extensionClass, key)) RETURN_THROWS(); + if (UNEXPECTED(!PT_THIS.valueDependency(extensionClass, key))) RETURN_THROWS(); + }); + + cls.method(sigs::fileDependency, [](INTERNAL_FUNCTION_PARAMETERS) { + zend_string *file; + if (!zp::parse(execute_data, file)) RETURN_THROWS(); + if (UNEXPECTED(!PT_THIS.fileDependency(file))) RETURN_THROWS(); + }); + /* }}} */ /* out of the twin's file order (see the handle class) */ diff --git a/turbo-ext/src/PhpClassReflectionExtension.cpp b/turbo-ext/src/PhpClassReflectionExtension.cpp index 4660b41a701..1143525d7ee 100644 --- a/turbo-ext/src/PhpClassReflectionExtension.cpp +++ b/turbo-ext/src/PhpClassReflectionExtension.cpp @@ -2494,6 +2494,13 @@ class PhpClassReflectionExtension bool isExplicit = callBool(returnTag.raw(), PT_LC("isexplicit"), 0, NULL, ok); if (UNEXPECTED(!ok)) return zv::Val(); if (isExplicit) return phpDocReturnType; + /* $phpDocReturnType instanceof ClassConstantAccessType && !$phpDocReturnType->isResolvable() - + * static::FOO_* or T::*, compared with the native type once the class is known */ + if (pt_ce_class_constant_access_type != NULL && instanceof_function(Z_OBJCE_P(phpDocReturnType.raw()), pt_ce_class_constant_access_type)) { + bool resolvable = callBool(phpDocReturnType.raw(), PT_LC("isresolvable"), 0, NULL, ok); + if (UNEXPECTED(!ok)) return zv::Val(); + if (!resolvable) return phpDocReturnType; + } bool superType = isSuperTypeOfYes(nativeReturnType, phpDocReturnType.raw(), ok); if (UNEXPECTED(!ok)) return zv::Val(); if (superType) return phpDocReturnType; diff --git a/turbo-ext/src/TypehintHelper.cpp b/turbo-ext/src/TypehintHelper.cpp index 222a3439f5c..bd0b0286049 100644 --- a/turbo-ext/src/TypehintHelper.cpp +++ b/turbo-ext/src/TypehintHelper.cpp @@ -170,6 +170,13 @@ class TypehintHelper if (UNEXPECTED(!pt_never_type_is_explicit(Z_OBJ_P(phpDocType.raw()), isExplicit))) return zv::Val(); if (isExplicit) return phpDocType; } + /* $phpDocType instanceof ClassConstantAccessType && !$phpDocType->isResolvable() - static::FOO_* + * or T::*, known only for the class the method is called on: $phpDocType->withNativeType($type) */ + if (pt_ce_class_constant_access_type != NULL && instanceof_function(Z_OBJCE_P(phpDocType.raw()), pt_ce_class_constant_access_type)) { + zv::Val resolvable = pt_type_call(Z_OBJ_P(phpDocType.raw()), PT_LC("isresolvable"), 0, NULL); + if (UNEXPECTED(resolvable.isUndef())) return zv::Val(); + if (!zend_is_true(resolvable.raw())) return pt_type_call(Z_OBJ_P(phpDocType.raw()), PT_LC("withnativetype"), 1, type.raw()); + } /* $type instanceof MixedType && !$type->isExplicitMixed() && $phpDocType->isVoid()->yes() */ if (instanceof_function(Z_OBJCE_P(type.raw()), pt_ce_mixed_type)) { bool explicitMixed; diff --git a/turbo-ext/src/generated/ClassConstantAccessType.h b/turbo-ext/src/generated/ClassConstantAccessType.h index 53a0d00c6df..ab4465ac63c 100644 --- a/turbo-ext/src/generated/ClassConstantAccessType.h +++ b/turbo-ext/src/generated/ClassConstantAccessType.h @@ -12,7 +12,8 @@ namespace ptdecl::ClassConstantAccessType { namespace slot { inline constexpr uint32_t type = 0; inline constexpr uint32_t constantName = 1; -inline constexpr uint32_t result = 2; +inline constexpr uint32_t nativeType = 2; +inline constexpr uint32_t result = 3; } // namespace slot inline void declareClass(reg::Class &cls) @@ -26,6 +27,7 @@ inline void declareProperties(reg::Class &cls) { cls.property("type", ZEND_ACC_PRIVATE, reg::PropertyKind::Typed, 0, "PHPStan\\Type\\Type"); cls.property("constantName", ZEND_ACC_PRIVATE, reg::PropertyKind::Typed, MAY_BE_STRING); + cls.property("nativeType", ZEND_ACC_PRIVATE, reg::PropertyKind::Typed, MAY_BE_NULL, "PHPStan\\Type\\Type"); } /* the shared registrars of the traits the twin uses, in its own order (a used trait's own traits after it) */ @@ -41,58 +43,66 @@ inline constexpr char strings[] = "type\0" /* 0 */ "PHPStan\\Type\\Type\0" /* 5 */ "constantName\0" /* 23 */ - "__construct\0" /* 36 */ - "\0" /* 48 */ - "getReferencedClasses\0" /* 49 */ - "positionVariance\0" /* 70 */ - "PHPStan\\Type\\Generic\\TemplateTypeVariance\0" /* 87 */ - "getReferencedTemplateTypes\0" /* 129 */ - "equals\0" /* 156 */ - "level\0" /* 163 */ - "PHPStan\\Type\\VerbosityLevel\0" /* 169 */ - "describe\0" /* 197 */ - "isResolvable\0" /* 206 */ - "getResult\0" /* 219 */ - "cb\0" /* 229 */ - "traverse\0" /* 232 */ - "right\0" /* 241 */ - "traverseSimultaneously\0" /* 247 */ - "PHPStan\\PhpDocParser\\Ast\\Type\\TypeNode\0" /* 270 */ - "toPhpDocNode"; /* 309 */ + "nativeType\0" /* 36 */ + "null\0" /* 47 */ + "__construct\0" /* 52 */ + "\0" /* 64 */ + "PHPStan\\Type\\ClassConstantAccessType\0" /* 65 */ + "withNativeType\0" /* 102 */ + "getReferencedClasses\0" /* 117 */ + "positionVariance\0" /* 138 */ + "PHPStan\\Type\\Generic\\TemplateTypeVariance\0" /* 155 */ + "getReferencedTemplateTypes\0" /* 197 */ + "equals\0" /* 224 */ + "level\0" /* 231 */ + "PHPStan\\Type\\VerbosityLevel\0" /* 237 */ + "describe\0" /* 265 */ + "isResolvable\0" /* 274 */ + "getResult\0" /* 287 */ + "cb\0" /* 297 */ + "traverse\0" /* 300 */ + "right\0" /* 309 */ + "traverseSimultaneously\0" /* 315 */ + "PHPStan\\PhpDocParser\\Ast\\Type\\TypeNode\0" /* 338 */ + "toPhpDocNode"; /* 377 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, 0, 5), /* __construct $type */ reg::packed(23, MAY_BE_STRING), /* __construct $constantName */ - reg::packed(48, MAY_BE_ARRAY), /* getReferencedClasses return */ - reg::packed(70, 0, 87), /* getReferencedTemplateTypes $positionVariance */ - reg::packed(48, MAY_BE_ARRAY), /* getReferencedTemplateTypes return */ + reg::packed(36, MAY_BE_NULL, 5, false, false, 47), /* __construct $nativeType */ + reg::packed(36, 0, 5), /* withNativeType $nativeType */ + reg::packed(64, 0, 65), /* withNativeType return */ + reg::packed(64, MAY_BE_ARRAY), /* getReferencedClasses return */ + reg::packed(138, 0, 155), /* getReferencedTemplateTypes $positionVariance */ + reg::packed(64, MAY_BE_ARRAY), /* getReferencedTemplateTypes return */ reg::packed(0, 0, 5), /* equals $type */ - reg::packed(48, MAY_BE_BOOL), /* equals return */ - reg::packed(163, 0, 169), /* describe $level */ - reg::packed(48, MAY_BE_STRING), /* describe return */ - reg::packed(48, MAY_BE_BOOL), /* isResolvable return */ - reg::packed(48, 0, 5), /* getResult return */ - reg::packed(229, MAY_BE_CALLABLE), /* traverse $cb */ - reg::packed(48, 0, 5), /* traverse return */ - reg::packed(241, 0, 5), /* traverseSimultaneously $right */ - reg::packed(229, MAY_BE_CALLABLE), /* traverseSimultaneously $cb */ - reg::packed(48, 0, 5), /* traverseSimultaneously return */ - reg::packed(48, 0, 270), /* toPhpDocNode return */ + reg::packed(64, MAY_BE_BOOL), /* equals return */ + reg::packed(231, 0, 237), /* describe $level */ + reg::packed(64, MAY_BE_STRING), /* describe return */ + reg::packed(64, MAY_BE_BOOL), /* isResolvable return */ + reg::packed(64, 0, 5), /* getResult return */ + reg::packed(297, MAY_BE_CALLABLE), /* traverse $cb */ + reg::packed(64, 0, 5), /* traverse return */ + reg::packed(309, 0, 5), /* traverseSimultaneously $right */ + reg::packed(297, MAY_BE_CALLABLE), /* traverseSimultaneously $cb */ + reg::packed(64, 0, 5), /* traverseSimultaneously return */ + reg::packed(64, 0, 338), /* toPhpDocNode return */ }; using Sig = reg::Sig; } // namespace sigtab /* the signatures of the methods the class declares itself (a used trait's are in the trait's header) */ namespace sig { -inline constexpr sigtab::Sig __construct = { { 36 /* __construct */, 2, 0, 2, reg::NoArg, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig getReferencedClasses = { { 49 /* getReferencedClasses */, 0, 2, 0, 2, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig getReferencedTemplateTypes = { { 129 /* getReferencedTemplateTypes */, 1, 3, 1, 4, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig equals = { { 156 /* equals */, 1, 5, 1, 6, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig describe = { { 197 /* describe */, 1, 7, 1, 8, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig isResolvable = { { 206 /* isResolvable */, 0, 9, 0, 9, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig getResult = { { 219 /* getResult */, 0, 10, 0, 10, ZEND_ACC_PROTECTED } }; -inline constexpr sigtab::Sig traverse = { { 232 /* traverse */, 1, 11, 1, 12, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig traverseSimultaneously = { { 247 /* traverseSimultaneously */, 2, 13, 2, 15, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig toPhpDocNode = { { 309 /* toPhpDocNode */, 0, 16, 0, 16, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig __construct = { { 52 /* __construct */, 2, 0, 3, reg::NoArg, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig withNativeType = { { 102 /* withNativeType */, 1, 3, 1, 4, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig getReferencedClasses = { { 117 /* getReferencedClasses */, 0, 5, 0, 5, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig getReferencedTemplateTypes = { { 197 /* getReferencedTemplateTypes */, 1, 6, 1, 7, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig equals = { { 224 /* equals */, 1, 8, 1, 9, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig describe = { { 265 /* describe */, 1, 10, 1, 11, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig isResolvable = { { 274 /* isResolvable */, 0, 12, 0, 12, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig getResult = { { 287 /* getResult */, 0, 13, 0, 13, ZEND_ACC_PROTECTED } }; +inline constexpr sigtab::Sig traverse = { { 300 /* traverse */, 1, 14, 1, 15, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig traverseSimultaneously = { { 315 /* traverseSimultaneously */, 2, 16, 2, 18, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig toPhpDocNode = { { 377 /* toPhpDocNode */, 0, 19, 0, 19, ZEND_ACC_PUBLIC } }; } // namespace sig } // namespace ptdecl::ClassConstantAccessType diff --git a/turbo-ext/src/generated/MutatingScope.h b/turbo-ext/src/generated/MutatingScope.h index 18b834da1ce..cd7266cf0a6 100644 --- a/turbo-ext/src/generated/MutatingScope.h +++ b/turbo-ext/src/generated/MutatingScope.h @@ -51,7 +51,7 @@ inline constexpr uint32_t templateArgumentConstraints = 36; inline void declareClass(reg::Class &cls) { - cls.implements({ "PHPStan\\Analyser\\Scope", "PHPStan\\Analyser\\NodeCallbackInvoker", "PHPStan\\Analyser\\CollectedDataEmitter" }); + cls.implements({ "PHPStan\\Analyser\\Scope", "PHPStan\\Analyser\\NodeCallbackInvoker", "PHPStan\\Analyser\\CollectedDataEmitter", "PHPStan\\Analyser\\DependencyEmitter" }); } /* the properties the class declares itself, in declaration order (a used trait's come from its registrar) */ @@ -531,7 +531,11 @@ inline constexpr char strings[] = "invokeNodeCallback\0" /* 9203 */ "collectorType\0" /* 9222 */ "data\0" /* 9236 */ - "emitCollectedData"; /* 9241 */ + "emitCollectedData\0" /* 9241 */ + "extensionClass\0" /* 9259 */ + "valueDependency\0" /* 9274 */ + "file\0" /* 9290 */ + "fileDependency"; /* 9295 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, 0, 10), /* __construct $container */ reg::packed(48, 0, 61), /* __construct $scopeFactory */ @@ -1064,6 +1068,11 @@ inline constexpr reg::PackedArg args[] = { reg::packed(9222, MAY_BE_STRING), /* emitCollectedData $collectorType */ reg::packed(9236, MAY_BE_ANY), /* emitCollectedData $data */ reg::packed(1476, MAY_BE_VOID), /* emitCollectedData return */ + reg::packed(9259, MAY_BE_STRING), /* valueDependency $extensionClass */ + reg::packed(3280, MAY_BE_STRING), /* valueDependency $key */ + reg::packed(1476, MAY_BE_VOID), /* valueDependency return */ + reg::packed(9290, MAY_BE_STRING), /* fileDependency $file */ + reg::packed(1476, MAY_BE_VOID), /* fileDependency return */ }; using Sig = reg::Sig; } // namespace sigtab @@ -1265,6 +1274,8 @@ inline constexpr sigtab::Sig getPhpVersion = { { 9149 /* getPhpVersion */, 0, 52 inline constexpr sigtab::Sig isOverallPhpVersionRange = { { 9163 /* isOverallPhpVersionRange */, 1, 524, 1, 525, ZEND_ACC_PRIVATE } }; inline constexpr sigtab::Sig invokeNodeCallback = { { 9203 /* invokeNodeCallback */, 1, 526, 1, 527, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig emitCollectedData = { { 9241 /* emitCollectedData */, 2, 528, 2, 530, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig valueDependency = { { 9274 /* valueDependency */, 2, 531, 2, 533, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig fileDependency = { { 9295 /* fileDependency */, 1, 534, 1, 535, ZEND_ACC_PUBLIC } }; } // namespace sig } // namespace ptdecl::MutatingScope diff --git a/turbo-ext/src/support.cpp b/turbo-ext/src/support.cpp index 29bc210b316..9ec7cf8ed83 100644 --- a/turbo-ext/src/support.cpp +++ b/turbo-ext/src/support.cpp @@ -411,6 +411,7 @@ static const pt_class_template pt_class_templates[PT_CLASS_COUNT] = { /* PT_CLASS_RELATIVE_NAME */ {"relativeName", "PhpParser\\Node\\Name\\Relative"}, /* PT_CLASS_ITERABLE_HELPER */ {"iterableHelper", "PHPStan\\Internal\\IterableHelper"}, /* PT_CLASS_CLOSURE_CALL_CONTEXT_MATCHER */ {"closureCallContextMatcher", "PHPStan\\Analyser\\ClosureCallContextMatcher"}, + /* PT_CLASS_CLASS_CONSTANT_PATTERN_RESOLVER */ {"classConstantPatternResolver", "PHPStan\\Type\\ClassConstantPatternResolver"}, }; zend_class_entry *pt_class(int idx) diff --git a/turbo-ext/src/support.h b/turbo-ext/src/support.h index a9928e8412f..061c91a416a 100644 --- a/turbo-ext/src/support.h +++ b/turbo-ext/src/support.h @@ -469,6 +469,7 @@ enum { PT_CLASS_RELATIVE_NAME, PT_CLASS_ITERABLE_HELPER, PT_CLASS_CLOSURE_CALL_CONTEXT_MATCHER, + PT_CLASS_CLASS_CONSTANT_PATTERN_RESOLVER, PT_CLASS_COUNT }; diff --git a/turbo-ext/tests/type-family.php b/turbo-ext/tests/type-family.php index 2f205bef50d..b314ade2f46 100644 --- a/turbo-ext/tests/type-family.php +++ b/turbo-ext/tests/type-family.php @@ -4993,6 +4993,9 @@ public function getKeyType(): \PHPStan\Type\Type 'constantStaticKindList' => new \PHPStan\Type\ClassConstantAccessType($lateShapeNodeStatic, 'KIND_LIST'), 'constantObjectKindArray' => new \PHPStan\Type\ClassConstantAccessType($lateShapeNodeObject, 'KIND_ARRAY'), 'constantMissing' => new \PHPStan\Type\ClassConstantAccessType($lateShapeNodeObject, 'KIND_NONEXISTENT'), + 'constantStaticKindListNative' => new \PHPStan\Type\ClassConstantAccessType($lateShapeNodeStatic, 'KIND_LIST', new \PHPStan\Type\StringType()), + 'constantObjectKindArrayNative' => (new \PHPStan\Type\ClassConstantAccessType($lateShapeNodeObject, 'KIND_ARRAY'))->withNativeType(new \PHPStan\Type\IntegerType()), + 'constantObjectWildcardNative' => new \PHPStan\Type\ClassConstantAccessType($lateShapeNodeObject, 'KIND_*', new \PHPStan\Type\StringType()), 'constantTShapeNode' => new \PHPStan\Type\ClassConstantAccessType($lateTShapeNode, 'KIND_LIST'), 'constantOnInt' => new \PHPStan\Type\ClassConstantAccessType(new \PHPStan\Type\IntegerType(), 'KIND_LIST'), 'newException' => new \PHPStan\Type\NewObjectType(new \PHPStan\Type\ObjectType(\Exception::class)),