From a88dcc3518387ff7e3936c7ee3584b6053651676 Mon Sep 17 00:00:00 2001 From: John Boehr Date: Thu, 27 Aug 2026 07:04:37 -0700 Subject: [PATCH] Support per-file result cache dependencies Allow extensions to associate semantic dependency keys with individual analysed files while calculating hashes in the main process during cache save and restore. This keeps invalidation narrow without making worker results depend on mutable external state. Fail closed for malformed records, stop reading dependency keys once a file is scheduled, and keep persisted hashes out of extension-visible collected data. Document the provider and emission lifecycle contracts and cover the public API and incremental cache behaviour end to end. --- .github/workflows/e2e-tests.yml | 128 ++++++++++ e2e/result-cache-dependency/.gitignore | 2 + .../assert-hash-calls.php | 82 +++++++ .../assert-result-cache-dependencies.php | 30 +++ e2e/result-cache-dependency/bootstrap.php | 23 ++ e2e/result-cache-dependency/composer.json | 5 + e2e/result-cache-dependency/composer.lock | 18 ++ e2e/result-cache-dependency/config-types.json | 9 + .../duplicate-provider.neon | 11 + .../ConfigResultCacheDependencyRule.php | 101 ++++++++ .../extension/ConfigTypeRegistry.php | 75 ++++++ .../ConfigValueDynamicReturnTypeExtension.php | 58 +++++ ...uplicateResultCacheDependencyExtension.php | 20 ++ .../ResultCacheDependencyDataRule.php | 56 +++++ .../extension/TenantConfigTypeRegistry.php | 62 +++++ .../mutate-result-cache.php | 50 ++++ e2e/result-cache-dependency/phpstan.neon | 33 +++ e2e/result-cache-dependency/src/Consumer.php | 22 ++ e2e/result-cache-dependency/src/Dependent.php | 7 + .../src/SecondConsumer.php | 8 + .../src/TenantConsumer.php | 8 + e2e/result-cache-dependency/src/Unrelated.php | 7 + .../tenant-config-types.json | 3 + src/Analyser/AnalyserResult.php | 11 + src/Analyser/CollectedDataEmitter.php | 19 +- .../ResultCacheDependencyExtension.php | 77 +++++++ .../ResultCache/ResultCacheManager.php | 218 +++++++++++++++++- .../ResultCacheDependencyCollector.php | 48 ++++ .../ResultCache/ResultCacheManagerTest.php | 162 +++++++++++++ .../ResultCacheDependencyCollectorTest.php | 41 ++++ .../data/class-const-fetch-out-of-phpstan.php | 10 + .../data/class-implements-out-of-phpstan.php | 3 + .../Api/data/static-call-out-of-phpstan.php | 10 + 33 files changed, 1407 insertions(+), 10 deletions(-) create mode 100644 e2e/result-cache-dependency/.gitignore create mode 100644 e2e/result-cache-dependency/assert-hash-calls.php create mode 100644 e2e/result-cache-dependency/assert-result-cache-dependencies.php create mode 100644 e2e/result-cache-dependency/bootstrap.php create mode 100644 e2e/result-cache-dependency/composer.json create mode 100644 e2e/result-cache-dependency/composer.lock create mode 100644 e2e/result-cache-dependency/config-types.json create mode 100644 e2e/result-cache-dependency/duplicate-provider.neon create mode 100644 e2e/result-cache-dependency/extension/ConfigResultCacheDependencyRule.php create mode 100644 e2e/result-cache-dependency/extension/ConfigTypeRegistry.php create mode 100644 e2e/result-cache-dependency/extension/ConfigValueDynamicReturnTypeExtension.php create mode 100644 e2e/result-cache-dependency/extension/DuplicateResultCacheDependencyExtension.php create mode 100644 e2e/result-cache-dependency/extension/ResultCacheDependencyDataRule.php create mode 100644 e2e/result-cache-dependency/extension/TenantConfigTypeRegistry.php create mode 100644 e2e/result-cache-dependency/mutate-result-cache.php create mode 100644 e2e/result-cache-dependency/phpstan.neon create mode 100644 e2e/result-cache-dependency/src/Consumer.php create mode 100644 e2e/result-cache-dependency/src/Dependent.php create mode 100644 e2e/result-cache-dependency/src/SecondConsumer.php create mode 100644 e2e/result-cache-dependency/src/TenantConsumer.php create mode 100644 e2e/result-cache-dependency/src/Unrelated.php create mode 100644 e2e/result-cache-dependency/tenant-config-types.json create mode 100644 src/Analyser/ResultCache/ResultCacheDependencyExtension.php create mode 100644 src/Collectors/ResultCacheDependencyCollector.php create mode 100644 tests/PHPStan/Analyser/ResultCache/ResultCacheManagerTest.php create mode 100644 tests/PHPStan/Collectors/ResultCacheDependencyCollectorTest.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index fac692020a3..31fb64dc153 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -484,6 +484,134 @@ jobs: echo "$OUTPUT" ../bashunit -a matches "Note: Using configuration file .+phpstan.neon." "$OUTPUT" ../bashunit -a contains 'Result cache not used because the metadata do not match: metaExtensions' "$OUTPUT" + - script: | + cd e2e/result-cache-dependency + composer install + ../../bin/phpstan clear-result-cache + mkdir -p tmp + : > tmp/hash-calls.log + : > tmp/rule-pids.log + : > tmp/collected-data.log + set +e + ../../bin/phpstan analyse -vv --error-format raw > tmp/analysis-output.log 2>&1 & + PHPSTAN_MAIN_PID=$! + wait "$PHPSTAN_MAIN_PID" + STATUS=$? + set -e + ../bashunit -a equals "0" "$STATUS" + OUTPUT=$(cat tmp/analysis-output.log) + echo "$OUTPUT" + ../bashunit -a contains 'Result cache is saved.' "$OUTPUT" + ../bashunit -a equals 'hashed=0 unhashed=6' "$(cat tmp/collected-data.log)" + php assert-hash-calls.php cold "$PHPSTAN_MAIN_PID" + php assert-result-cache-dependencies.php + : > tmp/hash-calls.log + : > tmp/rule-pids.log + : > tmp/collected-data.log + set +e + ../../bin/phpstan analyse -vv --error-format raw > tmp/analysis-output.log 2>&1 & + PHPSTAN_MAIN_PID=$! + wait "$PHPSTAN_MAIN_PID" + STATUS=$? + set -e + ../bashunit -a equals "0" "$STATUS" + OUTPUT=$(cat tmp/analysis-output.log) + echo "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT" + ../bashunit -a equals 'hashed=0 unhashed=6' "$(cat tmp/collected-data.log)" + php assert-hash-calls.php warm "$PHPSTAN_MAIN_PID" + php assert-result-cache-dependencies.php + # Duplicate provider keys are rejected before analysis. + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -c duplicate-provider.neon -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Duplicate ResultCacheDependencyExtension with key' "$OUTPUT" + ../bashunit -a contains 'ConfigTypeRegistry" found.' "$OUTPUT" + ../bashunit -a not_contains '100%' "$OUTPUT" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -c duplicate-provider.neon --debug --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Duplicate ResultCacheDependencyExtension with key' "$OUTPUT" + ../bashunit -a not_contains 'src/Consumer.php' "$OUTPUT" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -c duplicate-provider.neon src/Consumer.php -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Duplicate ResultCacheDependencyExtension with key' "$OUTPUT" + ../bashunit -a not_contains '100%' "$OUTPUT" + # The same dependency key in different provider namespaces must not collide. + sed -i 's/"checkout.label": "string"/"checkout.label": "int"/' tenant-config-types.json + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT" + ../bashunit -a contains 'TenantConsumer.php:7:Parameter #1 $string of function strlen expects string, int given.' "$OUTPUT" + # Changing a key shared by two consumers invalidates both files. + sed -i 's/"checkout.label": "string"/"checkout.label": "int"/' config-types.json + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 2 files will be reanalysed.' "$OUTPUT" + ../bashunit -a contains 'Consumer.php:11:Parameter #1 $string of function strlen expects string, int given.' "$OUTPUT" + ../bashunit -a contains 'SecondConsumer.php:7:Parameter #1 $string of function strlen expects string, int given.' "$OUTPUT" + # Changing an unused key invalidates no files. + sed -i 's/"unused": "string"/"unused": "int"/' config-types.json + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT" + ../bashunit -a contains 'Consumer.php:11:Parameter #1 $string of function strlen expects string, int given.' "$OUTPUT" + # Changing a key used by one consumer invalidates only that file. + sed -i 's/"profile.name": "string"/"profile.name": "int"/' config-types.json + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT" + ../bashunit -a contains 'Consumer.php:16:Parameter #1 $string of function strlen expects string, int given.' "$OUTPUT" + # A source-invalidated file does not need its obsolete dependency hashes. + sed -i 's/"profile.name": "int"/"profile.name": "throw"/' config-types.json + sed -i "s/configValue('profile.name')/configValue('replacement')/" src/Consumer.php + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT" + ../bashunit -a not_contains 'boom from dependency getHash' "$OUTPUT" + ../bashunit -a not_contains 'Consumer.php:16:Parameter #1 $string of function strlen expects string, int given.' "$OUTPUT" + sed -i 's/"profile.name": "throw"/"profile.name": "string"/' config-types.json + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT" + sed -i 's/"replacement": "string"/"replacement": "int"/' config-types.json + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT" + ../bashunit -a contains 'Consumer.php:16:Parameter #1 $string of function strlen expects string, int given.' "$OUTPUT" + # A selector change invalidates the file before its old selected key is hashed. + sed -i 's/"database.default": "legacy"/"database.default": "modern"/' config-types.json + sed -i 's/"database.connection.legacy": "string"/"database.connection.legacy": "throw"/' config-types.json + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT" + ../bashunit -a not_contains 'boom from dependency getHash' "$OUTPUT" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT" + ../bashunit -a not_contains 'boom from dependency getHash' "$OUTPUT" + sed -i 's/"database.connection.modern": "string"/"database.connection.modern": "int"/' config-types.json + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT" + ../bashunit -a contains 'Consumer.php:21:Parameter #1 $string of function strlen expects string, int given.' "$OUTPUT" + # Malformed or unknown persisted records invalidate their emitting files. + php mutate-result-cache.php unknown-provider + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 2 files will be reanalysed.' "$OUTPUT" + php mutate-result-cache.php malformed-record + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT" + php mutate-result-cache.php malformed-payload + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT" + # Provider exceptions propagate instead of being treated as cache misses. + sed -i 's/"replacement": "int"/"replacement": "throw"/' config-types.json + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'boom from dependency getHash' "$OUTPUT" + ../bashunit -a not_contains 'Swallowed by global exception handler' "$OUTPUT" - script: | cd e2e/result-cache-meta-extension-throw composer install diff --git a/e2e/result-cache-dependency/.gitignore b/e2e/result-cache-dependency/.gitignore new file mode 100644 index 00000000000..26009b00062 --- /dev/null +++ b/e2e/result-cache-dependency/.gitignore @@ -0,0 +1,2 @@ +/vendor +/tmp diff --git a/e2e/result-cache-dependency/assert-hash-calls.php b/e2e/result-cache-dependency/assert-hash-calls.php new file mode 100644 index 00000000000..48508eb7155 --- /dev/null +++ b/e2e/result-cache-dependency/assert-hash-calls.php @@ -0,0 +1,82 @@ + $collectedDataForFile) { + $records = $collectedDataForFile[ResultCacheDependencyCollector::class] ?? []; + $seen = []; + foreach ($records as $record) { + $identity = $record['extensionKey'] . "\0" . $record['dependencyKey']; + if (isset($seen[$identity])) { + throw new RuntimeException(sprintf('Duplicate result-cache dependency persisted for %s.', $file)); + } + $seen[$identity] = true; + if ($record['hash'] === 'extension-supplied') { + throw new RuntimeException(sprintf('Extension-supplied hash persisted for %s.', $file)); + } + $recordCount++; + } +} + +if ($recordCount !== 6) { + throw new RuntimeException(sprintf('Expected 6 persisted dependency records, got %d.', $recordCount)); +} + +echo "result cache contains six unique main-process dependency hashes.\n"; diff --git a/e2e/result-cache-dependency/bootstrap.php b/e2e/result-cache-dependency/bootstrap.php new file mode 100644 index 00000000000..5a8b51602a1 --- /dev/null +++ b/e2e/result-cache-dependency/bootstrap.php @@ -0,0 +1,23 @@ +getMessage() . "\n"); + exit(0); +}); diff --git a/e2e/result-cache-dependency/composer.json b/e2e/result-cache-dependency/composer.json new file mode 100644 index 00000000000..10c99380d08 --- /dev/null +++ b/e2e/result-cache-dependency/composer.json @@ -0,0 +1,5 @@ +{ + "autoload-dev": { + "classmap": ["extension/", "src/"] + } +} diff --git a/e2e/result-cache-dependency/composer.lock b/e2e/result-cache-dependency/composer.lock new file mode 100644 index 00000000000..ba8d41762cc --- /dev/null +++ b/e2e/result-cache-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-dependency/config-types.json b/e2e/result-cache-dependency/config-types.json new file mode 100644 index 00000000000..8f8eecda30c --- /dev/null +++ b/e2e/result-cache-dependency/config-types.json @@ -0,0 +1,9 @@ +{ + "checkout.label": "string", + "profile.name": "string", + "unused": "string", + "replacement": "string", + "database.default": "legacy", + "database.connection.legacy": "string", + "database.connection.modern": "string" +} diff --git a/e2e/result-cache-dependency/duplicate-provider.neon b/e2e/result-cache-dependency/duplicate-provider.neon new file mode 100644 index 00000000000..fbe161906de --- /dev/null +++ b/e2e/result-cache-dependency/duplicate-provider.neon @@ -0,0 +1,11 @@ +includes: + - phpstan.neon + +parameters: + tmpDir: tmp/duplicate + +services: + - + class: ResultCacheE2E\Dependency\DuplicateResultCacheDependencyExtension + tags: + - phpstan.resultCacheDependencyExtension diff --git a/e2e/result-cache-dependency/extension/ConfigResultCacheDependencyRule.php b/e2e/result-cache-dependency/extension/ConfigResultCacheDependencyRule.php new file mode 100644 index 00000000000..1c45b0edb68 --- /dev/null +++ b/e2e/result-cache-dependency/extension/ConfigResultCacheDependencyRule.php @@ -0,0 +1,101 @@ + */ +final class ConfigResultCacheDependencyRule implements Rule +{ + public function __construct( + private ConfigTypeRegistry $configTypeRegistry, + private TenantConfigTypeRegistry $tenantConfigTypeRegistry, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + /** @param Scope&CollectedDataEmitter $scope */ + public function processNode(Node $node, Scope $scope): array + { + if ( + !$node->name instanceof Name + || !isset($node->getArgs()[0]) + || !$node->getArgs()[0]->value instanceof String_ + ) { + return []; + } + + $functionName = $node->name->toString(); + if ( + $functionName !== 'configValue' + && $functionName !== 'configuredConnectionValue' + && $functionName !== 'tenantConfigValue' + ) { + return []; + } + + $pid = getmypid(); + if ($pid === false) { + throw new RuntimeException('Could not determine the configuration dependency rule process.'); + } + if (file_put_contents( + __DIR__ . '/../tmp/rule-pids.log', + sprintf("%d\n", $pid), + FILE_APPEND | LOCK_EX, + ) === false) { + throw new RuntimeException('Could not record the configuration dependency rule process.'); + } + + $key = $node->getArgs()[0]->value->value; + $extension = $functionName === 'tenantConfigValue' + ? $this->tenantConfigTypeRegistry + : $this->configTypeRegistry; + $this->emitDependency($scope, $extension, $key); + if ($functionName === 'configuredConnectionValue') { + $this->emitDependency( + $scope, + $this->configTypeRegistry, + $this->configTypeRegistry->getSelectedConnectionKey($key), + ); + } + + return []; + } + + private function emitDependency( + CollectedDataEmitter $scope, + ResultCacheDependencyExtension $extension, + string $key, + ): void + { + $data = ResultCacheDependencyCollector::createData($extension, $key); + if ($key === 'profile.name') { + $data += ['hash' => 'extension-supplied']; + } + $scope->emitCollectedData( + ResultCacheDependencyCollector::class, + $data, + ); + } +} diff --git a/e2e/result-cache-dependency/extension/ConfigTypeRegistry.php b/e2e/result-cache-dependency/extension/ConfigTypeRegistry.php new file mode 100644 index 00000000000..287aaa33dcf --- /dev/null +++ b/e2e/result-cache-dependency/extension/ConfigTypeRegistry.php @@ -0,0 +1,75 @@ +getKey(), $dependencyKey), + FILE_APPEND | LOCK_EX, + ) === false) { + throw new RuntimeException('Could not record dependency hash call.'); + } + + $value = $this->get($dependencyKey); + if ($value === 'throw') { + throw new Error('boom from dependency getHash'); + } + + return hash('sha256', $value); + } + + public function get(string $dependencyKey): string + { + $contents = file_get_contents(__DIR__ . '/../config-types.json'); + if ($contents === false) { + throw new RuntimeException('Could not read configuration types.'); + } + $configTypes = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + if (!is_array($configTypes)) { + throw new RuntimeException('Configuration types must be an object.'); + } + $value = $configTypes[$dependencyKey] ?? 'missing'; + if (!is_string($value)) { + throw new RuntimeException('Configuration types must be strings.'); + } + + return $value; + } + + public function getSelectedConnectionKey(string $selectorKey): string + { + return self::CONNECTION_PREFIX . $this->get($selectorKey); + } +} diff --git a/e2e/result-cache-dependency/extension/ConfigValueDynamicReturnTypeExtension.php b/e2e/result-cache-dependency/extension/ConfigValueDynamicReturnTypeExtension.php new file mode 100644 index 00000000000..3f5cb5c6eda --- /dev/null +++ b/e2e/result-cache-dependency/extension/ConfigValueDynamicReturnTypeExtension.php @@ -0,0 +1,58 @@ +getName() === 'configValue' + || $functionReflection->getName() === 'configuredConnectionValue' + || $functionReflection->getName() === 'tenantConfigValue'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): Type + { + $keyArgument = $functionCall->getArgs()[0] ?? null; + if ($keyArgument === null || !$keyArgument->value instanceof String_) { + return new MixedType(); + } + + $key = $keyArgument->value->value; + $configTypeRegistry = $functionReflection->getName() === 'tenantConfigValue' + ? $this->tenantConfigTypeRegistry + : $this->configTypeRegistry; + if ($functionReflection->getName() === 'configuredConnectionValue') { + $key = $this->configTypeRegistry->getSelectedConnectionKey($key); + } + + return match ($configTypeRegistry->get($key)) { + 'string' => new StringType(), + 'int' => new IntegerType(), + default => new MixedType(), + }; + } +} diff --git a/e2e/result-cache-dependency/extension/DuplicateResultCacheDependencyExtension.php b/e2e/result-cache-dependency/extension/DuplicateResultCacheDependencyExtension.php new file mode 100644 index 00000000000..456a5b26f88 --- /dev/null +++ b/e2e/result-cache-dependency/extension/DuplicateResultCacheDependencyExtension.php @@ -0,0 +1,20 @@ + */ +final class ResultCacheDependencyDataRule implements Rule +{ + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $hashed = 0; + $unhashed = 0; + foreach ($node->get(ResultCacheDependencyCollector::class) as $records) { + foreach ($records as $record) { + if ($this->hasHash($record)) { + $hashed++; + continue; + } + + $unhashed++; + } + } + if (file_put_contents( + __DIR__ . '/../tmp/collected-data.log', + sprintf("hashed=%d unhashed=%d\n", $hashed, $unhashed), + LOCK_EX, + ) === false) { + throw new RuntimeException('Could not record the result-cache dependency data shape.'); + } + + return []; + } + + private function hasHash(mixed $record): bool + { + return is_array($record) && array_key_exists('hash', $record); + } +} diff --git a/e2e/result-cache-dependency/extension/TenantConfigTypeRegistry.php b/e2e/result-cache-dependency/extension/TenantConfigTypeRegistry.php new file mode 100644 index 00000000000..086eef44062 --- /dev/null +++ b/e2e/result-cache-dependency/extension/TenantConfigTypeRegistry.php @@ -0,0 +1,62 @@ +getKey(), $dependencyKey), + FILE_APPEND | LOCK_EX, + ) === false) { + throw new RuntimeException('Could not record dependency hash call.'); + } + + return hash('sha256', $this->get($dependencyKey)); + } + + public function get(string $dependencyKey): string + { + $contents = file_get_contents(__DIR__ . '/../tenant-config-types.json'); + if ($contents === false) { + throw new RuntimeException('Could not read tenant configuration types.'); + } + $configTypes = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + if (!is_array($configTypes)) { + throw new RuntimeException('Tenant configuration types must be an object.'); + } + $value = $configTypes[$dependencyKey] ?? 'missing'; + if (!is_string($value)) { + throw new RuntimeException('Tenant configuration types must be strings.'); + } + + return $value; + } +} diff --git a/e2e/result-cache-dependency/mutate-result-cache.php b/e2e/result-cache-dependency/mutate-result-cache.php new file mode 100644 index 00000000000..8ffd57d836f --- /dev/null +++ b/e2e/result-cache-dependency/mutate-result-cache.php @@ -0,0 +1,50 @@ + $collectedDataPerFile) { + if (!array_key_exists($collectorType, $collectedDataPerFile)) { + continue; + } + + $search = substr(var_export([$file => $collectedDataPerFile], true), 8, -2); + $collectedDataPerFile[$collectorType] = 'malformed'; + $replacement = substr(var_export([$file => $collectedDataPerFile], true), 8, -2); + break; + } +} else { + [$search, $replacement] = match ($mutation) { + 'unknown-provider' => [ + "'extensionKey' => " . var_export('ResultCacheE2E\\Dependency\\ConfigTypeRegistry', true), + "'extensionKey' => 'missing-extension'", + ], + 'malformed-record' => [ + "'dependencyKey' => 'replacement'", + "'dependencyKey' => array ()", + ], + default => throw new RuntimeException('Unknown mutation.'), + }; +} + +if (!isset($search, $replacement)) { + throw new RuntimeException('Result cache did not contain dependency collected data.'); +} + +$contents = str_replace($search, $replacement, $contents, $count); +if ($count === 0) { + throw new RuntimeException('Result-cache mutation did not match anything.'); +} +if (file_put_contents($cacheFile, $contents) === false) { + throw new RuntimeException('Could not write result cache.'); +} diff --git a/e2e/result-cache-dependency/phpstan.neon b/e2e/result-cache-dependency/phpstan.neon new file mode 100644 index 00000000000..cf07e9b3245 --- /dev/null +++ b/e2e/result-cache-dependency/phpstan.neon @@ -0,0 +1,33 @@ +parameters: + level: max + tmpDir: tmp + paths: + - src + bootstrapFiles: + - bootstrap.php + parallel: + jobSize: 1 + maximumNumberOfProcesses: 2 + minimumNumberOfJobsPerProcess: 1 + +services: + - + class: ResultCacheE2E\Dependency\ConfigTypeRegistry + tags: + - phpstan.resultCacheDependencyExtension + - + class: ResultCacheE2E\Dependency\TenantConfigTypeRegistry + tags: + - phpstan.resultCacheDependencyExtension + - + class: ResultCacheE2E\Dependency\ConfigResultCacheDependencyRule + tags: + - phpstan.rules.rule + - + class: ResultCacheE2E\Dependency\ConfigValueDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + - + class: ResultCacheE2E\Dependency\ResultCacheDependencyDataRule + tags: + - phpstan.rules.rule diff --git a/e2e/result-cache-dependency/src/Consumer.php b/e2e/result-cache-dependency/src/Consumer.php new file mode 100644 index 00000000000..6c884926167 --- /dev/null +++ b/e2e/result-cache-dependency/src/Consumer.php @@ -0,0 +1,22 @@ +collectedData; } + /** + * @param CollectorData $collectedData + */ + public function withCollectedData(array $collectedData): self + { + $self = clone $this; + $self->collectedData = $collectedData; + + return $self; + } + /** * @return array>|null */ diff --git a/src/Analyser/CollectedDataEmitter.php b/src/Analyser/CollectedDataEmitter.php index 1f5be5f4def..fef27e1b94e 100644 --- a/src/Analyser/CollectedDataEmitter.php +++ b/src/Analyser/CollectedDataEmitter.php @@ -6,13 +6,7 @@ use PHPStan\Collectors\Collector; /** - * The interface CollectedDataEmitter can be typehinted in 2nd parameter of Rule::processNode(): - * - * ```php - * public function processNode(Node $node, Scope&CollectedDataEmitter $scope): array - * ``` - * - * It allows rules to emit collected data directly, without having to write + * CollectedDataEmitter allows rules to emit collected data directly, without having to write * a separate complex Collector class. The emitted data is aggregated the same way * as data from Collectors and can be consumed by rules registered * for CollectedDataNode. @@ -23,8 +17,17 @@ * The referenced MyCollector class should NOT be registered * as a collector, unless you also want it to collect data on its own. * + * The scope passed to Rule::processNode() implements CollectedDataEmitter. Keep the native parameter type + * as Scope so the rule is compatible with the distributed PHPStan PHAR. Declare + * `@param Scope&CollectedDataEmitter $scope` in the method PHPDoc: + * * ```php - * $scope->emitCollectedData(MyCollector::class, ['some', 'data']); + * public function processNode(Node $node, Scope $scope): array + * { + * $scope->emitCollectedData(MyCollector::class, ['some', 'data']); + * + * return []; + * } * ``` * * @api diff --git a/src/Analyser/ResultCache/ResultCacheDependencyExtension.php b/src/Analyser/ResultCache/ResultCacheDependencyExtension.php new file mode 100644 index 00000000000..94d3ff61f17 --- /dev/null +++ b/src/Analyser/ResultCache/ResultCacheDependencyExtension.php @@ -0,0 +1,77 @@ +emitCollectedData( + * ResultCacheDependencyCollector::class, + * ResultCacheDependencyCollector::createData($this->extension, $dependencyKey), + * ); + * + * return []; + * } + * ``` + * + * Emit dependencies only from Rule::processNode(). Other extension callbacks, including dynamic return + * type extensions, can receive scopes without an active collected-data callback. + * Repeated emissions of the same provider and dependency key for one file are deduplicated. + * + * If a cached hash changes, PHPStan reanalyses only files that emitted the dependency, not their ordinary + * PHPStan dependants. Each affected file must emit its own dependency. Use ResultCacheMetaExtension + * instead for state with global or indirect effects. + * + * @api + */ +#[ExtensionInterface(tag: self::EXTENSION_TAG)] +interface ResultCacheDependencyExtension +{ + + public const EXTENSION_TAG = 'phpstan.resultCacheDependencyExtension'; + + /** + * Returns a globally unique, stable key identifying this dependency provider. + * + * The implementation class name (self::class) is recommended. Multiple instances of the same class + * need distinct keys. The key must be stable across all analysis processes. Changing it makes previously + * cached records unknown and reanalyses their files. + */ + public function getKey(): string; + + /** + * Returns a deterministic hash of the dependency identified by the opaque key. + * + * The key can come from an older result cache, so obsolete keys must be handled deterministically. + * + * The hash must describe the same state used during analysis, and that state must remain stable for + * the duration of the run. + * + * Restoring happens before configured bootstrapFiles are executed in the main process, while saving + * happens afterwards. The hash must not depend on state initialized by bootstrapFiles. + * + * Calls can be repeated and can happen in any order or process. They must return the same hash while + * the backing state is unchanged. + */ + public function getHash(string $dependencyKey): string; + +} diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 53407bed071..90e4f9e2005 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -7,6 +7,7 @@ use PHPStan\Analyser\Error; use PHPStan\Analyser\FileAnalyserResult; use PHPStan\Collectors\CollectedData; +use PHPStan\Collectors\ResultCacheDependencyCollector; use PHPStan\Command\Output; use PHPStan\Dependency\ExportedNode\ExportedTraitNode; use PHPStan\Dependency\ExportedNodeFetcher; @@ -51,6 +52,7 @@ use function is_array; use function is_dir; use function is_file; +use function is_string; use function ksort; use function microtime; use function sort; @@ -80,6 +82,9 @@ final class ResultCacheManager /** @var array */ private array $alreadyProcessed = []; + /** @var array|null */ + private ?array $resultCacheDependencyExtensionsByKey = null; + /** * @param string[] $analysedPaths * @param string[] $analysedPathsFromConfig @@ -91,10 +96,13 @@ final class ResultCacheManager * @param list> $parametersNotInvalidatingCache * @param array $fileReplacements * @param ExtensionsCollection $resultCacheMetaExtensions + * @param ExtensionsCollection $resultCacheDependencyExtensions */ public function __construct( #[AutowiredExtensions(of: ResultCacheMetaExtension::class)] private ExtensionsCollection $resultCacheMetaExtensions, + #[AutowiredExtensions(of: ResultCacheDependencyExtension::class)] + private ExtensionsCollection $resultCacheDependencyExtensions, private ExportedNodeFetcher $exportedNodeFetcher, #[AutowiredParameter(ref: '@fileFinderScan')] private FileFinder $scanFileFinder, @@ -162,6 +170,10 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? } $currentFileHashes[$analysedFile] = $this->getFileHash($analysedFile); } + + // Validate extension keys even when result-cache use is disabled. + $this->getResultCacheDependencyExtensionsByKey(); + if ($debug) { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache not used because of debug mode.'); @@ -701,6 +713,18 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $filesToAnalyse[] = $packageSeededFile; } + $resultCacheDependencySeededFiles = $this->restoreResultCacheDependencies( + $filteredCollectedData, + array_fill_keys($filesToAnalyse, true), + ); + + foreach ($resultCacheDependencySeededFiles as $resultCacheDependencySeededFile) { + if (!is_file($resultCacheDependencySeededFile)) { + continue; + } + $filesToAnalyse[] = $resultCacheDependencySeededFile; + } + $filesToAnalyse = array_unique($filesToAnalyse); $filesToAnalyseCount = count($filesToAnalyse); @@ -848,7 +872,9 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $freshLocallyIgnoredErrorsByFile[$error->getFilePath()][] = $error; } - $freshCollectedDataByFile = $analyserResult->getCollectedData(); + // Hashes are cache metadata. Records received from analysis workers cannot supply them. + $freshCollectedDataByFile = $this->removeResultCacheDependencyHashes($analyserResult->getCollectedData()); + $freshCollectedDataByFile = $this->deduplicateResultCacheDependencies($freshCollectedDataByFile); $meta = $resultCache->getMeta(); $projectConfigArray = $meta['projectConfig']; @@ -910,6 +936,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache } } + $collectedDataByFile = $this->addResultCacheDependencyHashes($collectedDataByFile); $this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $packageDependencies, $exportedNodes, $projectExtensionFiles, $resultCache->getCurrentFileHashes(), $meta); if ($output->isVeryVerbose()) { @@ -933,12 +960,16 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache } } - return new ResultCacheProcessResult($analyserResult, $saved); + return new ResultCacheProcessResult( + $analyserResult->withCollectedData($freshCollectedDataByFile), + $saved, + ); } $errorsByFile = $this->mergeErrors($resultCache, $freshErrorsByFile); $locallyIgnoredErrorsByFile = $this->mergeLocallyIgnoredErrors($resultCache, $freshLocallyIgnoredErrorsByFile); $collectedDataByFile = $this->mergeCollectedData($resultCache, $freshCollectedDataByFile); + $collectedDataByFile = $this->deduplicateResultCacheDependencies($collectedDataByFile); $dependencies = $this->mergeDependencies($resultCache->getDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getDependencies()); $usedTraitDependencies = $this->mergeDependencies($resultCache->getUsedTraitDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getUsedTraitDependencies()); $packageDependencies = $this->mergePackageDependencies($resultCache->getPackageDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getPackageDependencies()); @@ -984,6 +1015,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $flatLocallyIgnoredErrors[] = $fileError; } } + $collectedDataByFile = $this->removeResultCacheDependencyHashes($collectedDataByFile); return new ResultCacheProcessResult(new AnalyserResult( unorderedErrors: $flatErrors, @@ -1070,6 +1102,188 @@ private function mergeCollectedData(ResultCache $resultCache, array $freshCollec return $collectedDataByFile; } + /** + * @param array> $collectedData + * @param array $alreadyScheduledFiles + * @return list + */ + private function restoreResultCacheDependencies(array $collectedData, array $alreadyScheduledFiles): array + { + $extensions = $this->getResultCacheDependencyExtensionsByKey(); + + $currentHashes = []; + $filesToAnalyse = []; + foreach ($collectedData as $file => $collectedDataPerFile) { + if (isset($alreadyScheduledFiles[$file])) { + continue; + } + if (!array_key_exists(ResultCacheDependencyCollector::class, $collectedDataPerFile)) { + continue; + } + $records = $collectedDataPerFile[ResultCacheDependencyCollector::class]; + if (!is_array($records)) { + $filesToAnalyse[] = $file; + continue; + } + + foreach ($records as $record) { + if ( + !is_array($record) + || !isset($record['extensionKey'], $record['dependencyKey'], $record['hash']) + || !is_string($record['extensionKey']) + || !is_string($record['dependencyKey']) + || !is_string($record['hash']) + || !isset($extensions[$record['extensionKey']]) + ) { + $filesToAnalyse[] = $file; + break; + } + + $extensionKey = $record['extensionKey']; + $dependencyKey = $record['dependencyKey']; + $currentHashes[$extensionKey][$dependencyKey] ??= $extensions[$extensionKey]->getHash($dependencyKey); + $currentHash = $currentHashes[$extensionKey][$dependencyKey]; + if ($record['hash'] === $currentHash) { + continue; + } + + $filesToAnalyse[] = $file; + break; + } + } + + return $filesToAnalyse; + } + + /** + * @param CollectorData $collectedData + * @return CollectorData + */ + private function addResultCacheDependencyHashes(array $collectedData): array + { + $extensions = $this->getResultCacheDependencyExtensionsByKey(); + $currentHashes = []; + foreach ($collectedData as $file => $collectedDataPerFile) { + if (!isset($collectedDataPerFile[ResultCacheDependencyCollector::class])) { + continue; + } + + $records = []; + foreach ($collectedDataPerFile[ResultCacheDependencyCollector::class] as $record) { + if ( + !is_array($record) + || !isset($record['extensionKey'], $record['dependencyKey']) + || !is_string($record['extensionKey']) + || !is_string($record['dependencyKey']) + || !isset($extensions[$record['extensionKey']]) + || (isset($record['hash']) && is_string($record['hash'])) + ) { + $records[] = $record; + continue; + } + + $extensionKey = $record['extensionKey']; + $dependencyKey = $record['dependencyKey']; + $currentHashes[$extensionKey][$dependencyKey] ??= $extensions[$extensionKey]->getHash($dependencyKey); + $record['hash'] = $currentHashes[$extensionKey][$dependencyKey]; + $records[] = $record; + } + $collectedData[$file][ResultCacheDependencyCollector::class] = $records; + } + + return $collectedData; + } + + /** + * @param CollectorData $collectedData + * @return CollectorData + */ + private function removeResultCacheDependencyHashes(array $collectedData): array + { + foreach ($collectedData as $file => $collectedDataPerFile) { + if (!isset($collectedDataPerFile[ResultCacheDependencyCollector::class])) { + continue; + } + + $records = []; + foreach ($collectedDataPerFile[ResultCacheDependencyCollector::class] as $record) { + if (is_array($record)) { + unset($record['hash']); + } + + $records[] = $record; + } + $collectedData[$file][ResultCacheDependencyCollector::class] = $records; + } + + return $collectedData; + } + + /** + * @param CollectorData $collectedData + * @return CollectorData + */ + private function deduplicateResultCacheDependencies(array $collectedData): array + { + foreach ($collectedData as $file => $collectedDataPerFile) { + if (!isset($collectedDataPerFile[ResultCacheDependencyCollector::class])) { + continue; + } + + $seen = []; + $records = []; + foreach ($collectedDataPerFile[ResultCacheDependencyCollector::class] as $record) { + if ( + !is_array($record) + || !isset($record['extensionKey'], $record['dependencyKey']) + || !is_string($record['extensionKey']) + || !is_string($record['dependencyKey']) + ) { + $records[] = $record; + continue; + } + + $extensionKey = $record['extensionKey']; + $dependencyKey = $record['dependencyKey']; + if (isset($seen[$extensionKey][$dependencyKey])) { + continue; + } + + $seen[$extensionKey][$dependencyKey] = true; + $records[] = $record; + } + $collectedData[$file][ResultCacheDependencyCollector::class] = $records; + } + + return $collectedData; + } + + /** + * @return array + * @throws ShouldNotHappenException + */ + private function getResultCacheDependencyExtensionsByKey(): array + { + if ($this->resultCacheDependencyExtensionsByKey !== null) { + return $this->resultCacheDependencyExtensionsByKey; + } + + $extensions = []; + foreach ($this->resultCacheDependencyExtensions->getAll() as $extension) { + $key = $extension->getKey(); + if (array_key_exists($key, $extensions)) { + throw new ShouldNotHappenException(sprintf( + 'Duplicate ResultCacheDependencyExtension with key "%s" found.', + $key, + )); + } + + $extensions[$key] = $extension; + } + + return $this->resultCacheDependencyExtensionsByKey = $extensions; + } + /** * @param array> $resultCacheDependencies * @param string[] $filesToAnalyse diff --git a/src/Collectors/ResultCacheDependencyCollector.php b/src/Collectors/ResultCacheDependencyCollector.php new file mode 100644 index 00000000000..2baacffbc40 --- /dev/null +++ b/src/Collectors/ResultCacheDependencyCollector.php @@ -0,0 +1,48 @@ + + */ +final class ResultCacheDependencyCollector implements Collector +{ + + /** + * @api + * @return ResultCacheDependencyData + */ + public static function createData(ResultCacheDependencyExtension $extension, string $dependencyKey): array + { + return [ + 'extensionKey' => $extension->getKey(), + 'dependencyKey' => $dependencyKey, + ]; + } + + #[Override] + public function getNodeType(): string + { + throw new ShouldNotHappenException(); + } + + #[Override] + public function processNode(Node $node, Scope $scope): ?array + { + throw new ShouldNotHappenException(); + } + +} diff --git a/tests/PHPStan/Analyser/ResultCache/ResultCacheManagerTest.php b/tests/PHPStan/Analyser/ResultCache/ResultCacheManagerTest.php new file mode 100644 index 00000000000..6a51990e511 --- /dev/null +++ b/tests/PHPStan/Analyser/ResultCache/ResultCacheManagerTest.php @@ -0,0 +1,162 @@ + */ + public static function providePartialCacheSaveModes(): iterable + { + yield 'saving disabled' => [false, true]; + yield 'save rejected because dependencies are unavailable' => [true, false]; + } + + #[DataProvider('providePartialCacheSaveModes')] + public function testProcessDoesNotExposeDependencyHashesWhenPartialCacheIsNotSaved(bool $save, bool $dependenciesAvailable): void + { + $file = '/analysed.php'; + $result = $this->createManager()->process( + $this->createAnalyserResult([], $dependenciesAvailable ? [] : null), + $this->createResultCache(false, [], [ + $file => [ResultCacheDependencyCollector::class => [[ + 'extensionKey' => 'provider', + 'dependencyKey' => 'dependency', + 'hash' => 'internal-cache-hash', + ]]], + ]), + $this->createStub(Output::class), + false, + $save, + ); + + $this->assertFalse($result->isSaved()); + $this->assertSame([ + $file => [ResultCacheDependencyCollector::class => [[ + 'extensionKey' => 'provider', + 'dependencyKey' => 'dependency', + ]]], + ], $result->getAnalyserResult()->getCollectedData()); + } + + public function testProcessUsesFreshDependencyRecordInsteadOfMalformedCachedRecord(): void + { + $file = '/analysed.php'; + $freshCollectedData = [ + $file => [ResultCacheDependencyCollector::class => [[ + 'extensionKey' => 'provider', + 'dependencyKey' => 'fresh-dependency', + ]]], + ]; + $result = $this->createManager()->process( + $this->createAnalyserResult($freshCollectedData), + $this->createResultCache(false, [$file], [ + $file => [ResultCacheDependencyCollector::class => [[ + 'extensionKey' => 'provider', + 'dependencyKey' => [], + 'hash' => 'internal-cache-hash', + ]]], + ]), + $this->createStub(Output::class), + false, + false, + ); + + $this->assertSame($freshCollectedData, $result->getAnalyserResult()->getCollectedData()); + } + + public function testProcessNormalizesFreshDependencyRecords(): void + { + $file = '/analysed.php'; + $result = $this->createManager()->process( + $this->createAnalyserResult([ + $file => [ResultCacheDependencyCollector::class => [ + [ + 'extensionKey' => 'provider', + 'dependencyKey' => 'dependency', + 'hash' => 'extension-supplied', + ], + [ + 'extensionKey' => 'provider', + 'dependencyKey' => 'dependency', + ], + ]], + ]), + $this->createResultCache(true, [$file], []), + $this->createStub(Output::class), + false, + false, + ); + + $this->assertSame([ + $file => [ResultCacheDependencyCollector::class => [[ + 'extensionKey' => 'provider', + 'dependencyKey' => 'dependency', + ]]], + ], $result->getAnalyserResult()->getCollectedData()); + } + + private function createManager(): ResultCacheManager + { + return self::getContainer()->getByType(ResultCacheManagerFactory::class)->create([]); + } + + /** + * @param string[] $filesToAnalyse + * @param CollectorData $collectedData + */ + private function createResultCache(bool $fullAnalysis, array $filesToAnalyse, array $collectedData): ResultCache + { + return new ResultCache( + filesToAnalyse: $filesToAnalyse, + fullAnalysis: $fullAnalysis, + lastFullAnalysisTime: 0, + meta: ['projectConfig' => null], + errors: [], + locallyIgnoredErrors: [], + linesToIgnore: [], + unmatchedLineIgnores: [], + collectedData: $collectedData, + dependencies: [], + usedTraitDependencies: [], + packageDependencies: [], + exportedNodes: [], + projectExtensionFiles: [], + currentFileHashes: [], + ); + } + + /** + * @param CollectorData $collectedData + * @param array>|null $dependencies + */ + private function createAnalyserResult(array $collectedData, ?array $dependencies = []): AnalyserResult + { + return new AnalyserResult( + unorderedErrors: [], + filteredPhpErrors: [], + allPhpErrors: [], + locallyIgnoredErrors: [], + linesToIgnore: [], + unmatchedLineIgnores: [], + internalErrors: [], + collectedData: $collectedData, + dependencies: $dependencies, + usedTraitDependencies: [], + packageDependencies: [], + exportedNodes: [], + reachedInternalErrorsCountLimit: false, + peakMemoryUsageBytes: 0, + processedFiles: [], + ); + } + +} diff --git a/tests/PHPStan/Collectors/ResultCacheDependencyCollectorTest.php b/tests/PHPStan/Collectors/ResultCacheDependencyCollectorTest.php new file mode 100644 index 00000000000..fecf165435e --- /dev/null +++ b/tests/PHPStan/Collectors/ResultCacheDependencyCollectorTest.php @@ -0,0 +1,41 @@ +assertSame([ + 'extensionKey' => 'provider', + 'dependencyKey' => 'dependency', + ], ResultCacheDependencyCollector::createData($extension, 'dependency')); + } + + public function testGetNodeTypeCannotBeCalled(): void + { + $this->expectException(ShouldNotHappenException::class); + + (new ResultCacheDependencyCollector())->getNodeType(); + } + +} diff --git a/tests/PHPStan/Rules/Api/data/class-const-fetch-out-of-phpstan.php b/tests/PHPStan/Rules/Api/data/class-const-fetch-out-of-phpstan.php index 03cdd9f94a0..78369c31e00 100644 --- a/tests/PHPStan/Rules/Api/data/class-const-fetch-out-of-phpstan.php +++ b/tests/PHPStan/Rules/Api/data/class-const-fetch-out-of-phpstan.php @@ -21,3 +21,13 @@ public function doFoo() } } + +class ResultCacheDependencyUser +{ + + public function doFoo() + { + echo \PHPStan\Collectors\ResultCacheDependencyCollector::class; + } + +} diff --git a/tests/PHPStan/Rules/Api/data/class-implements-out-of-phpstan.php b/tests/PHPStan/Rules/Api/data/class-implements-out-of-phpstan.php index 69735317357..662b1270470 100644 --- a/tests/PHPStan/Rules/Api/data/class-implements-out-of-phpstan.php +++ b/tests/PHPStan/Rules/Api/data/class-implements-out-of-phpstan.php @@ -376,3 +376,6 @@ abstract class MyFunctionReflection implements FunctionReflection abstract class MyMethodReflection implements ExtendedMethodReflection {} + +abstract class MyResultCacheDependencyExtension implements \PHPStan\Analyser\ResultCache\ResultCacheDependencyExtension +{} diff --git a/tests/PHPStan/Rules/Api/data/static-call-out-of-phpstan.php b/tests/PHPStan/Rules/Api/data/static-call-out-of-phpstan.php index f0f5bdd9ab2..4eb07ddcc92 100644 --- a/tests/PHPStan/Rules/Api/data/static-call-out-of-phpstan.php +++ b/tests/PHPStan/Rules/Api/data/static-call-out-of-phpstan.php @@ -44,3 +44,13 @@ public function __construct() } } + +class ResultCacheDependencyUser +{ + + public function createData(\PHPStan\Analyser\ResultCache\ResultCacheDependencyExtension $extension): void + { + \PHPStan\Collectors\ResultCacheDependencyCollector::createData($extension, 'dependency'); + } + +}