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'); + } + +}