diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 007a8d510a5..5ee2556e697 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -215,6 +215,63 @@ jobs: OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan -vvv") echo "$OUTPUT" ../bashunit assert contains 'Result cache restored.' "$OUTPUT" + - script: | + cd e2e/result-cache-collected-data-rules + OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit assert contains 'Caller.php:10:Call to function ResultCacheCollectedDataRules\pure() on a separate line has no effect.' "$OUTPUT" + # Nothing changed, so every rule on CollectedDataNode reuses its errors from the result cache. + OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit assert contains 'Caller.php:10:Call to function ResultCacheCollectedDataRules\pure() on a separate line has no effect.' "$OUTPUT" + echo "$OUTPUT" | grep -E 'Result cache reused the errors of ([0-9]+) of \1 rules on collected data\.' + ../bashunit assert contains 'Result cache was not rewritten because it is unchanged.' "$OUTPUT" + # src/Other.php is analysed again, but what it collects stays the same. + echo '// changed' >> src/Other.php + OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit assert contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT" + ../bashunit assert contains 'Caller.php:10:Call to function ResultCacheCollectedDataRules\pure() on a separate line has no effect.' "$OUTPUT" + echo "$OUTPUT" | grep -E 'Result cache reused the errors of ([0-9]+) of \1 rules on collected data\.' + # Removing the call changes what src/Caller.php collects, so the rule runs again and the error goes away. + sed -i 's/^\t\tpure();$//' src/Caller.php + OUTPUT=$(../bashunit assert exit_code "0" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit assert not_contains 'on a separate line has no effect' "$OUTPUT" + - script: | + cd e2e/result-cache-collected-data-rules-tracking + composer install + OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit assert contains 'Controller.php:15:View about not found.' "$OUTPUT" + OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + echo "$OUTPUT" | grep -E 'Result cache reused the errors of ([0-9]+) of \1 rules on collected data\.' + # The rule tracks the views directory, so a new view runs it again. + printf 'about\n' > views/about.txt + OUTPUT=$(../bashunit assert exit_code "0" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit assert not_contains 'View about not found.' "$OUTPUT" + rm views/about.txt + OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit assert contains 'Controller.php:15:View about not found.' "$OUTPUT" + # The rule tracks the file of ignored views too. + printf 'about\n' > ignored-views.txt + OUTPUT=$(../bashunit assert exit_code "0" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit assert not_contains 'View about not found.' "$OUTPUT" + # And the class of extra views, which is not analysed. + sed -i "s/'contact'/'nothing'/" lib/ExtraViews.php + OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit assert contains 'Controller.php:20:View contact not found.' "$OUTPUT" + # And a method that a class reflection extension describes from a file that is not + # analysed, tracked through DeclarationDependencyTracker. No analysed file changes. + printf '' > more-views.txt + OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit assert contains 'Controller.php:25:View faq not found.' "$OUTPUT" - script: | cd e2e/bug-14514 composer install diff --git a/.gitignore b/.gitignore index 37f02026c37..9848a14eacf 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ e2e/bashunit /e2e/result-cache-moved-tmpdir/tmp-a /e2e/result-cache-moved-tmpdir/tmp-b /e2e/result-cache-stale-objects/tmp +/e2e/result-cache-collected-data-rules/tmp +/e2e/result-cache-collected-data-rules-tracking/tmp /e2e/result-cache-truncated/tmp /e2e/result-cache-worker-count/tmp e2e/restart-manager/probe.log diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index a14610a1aa4..7098877fb44 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -33,3 +33,4 @@ parameters: backedEnumFromParameterType: true unusedVariable: true unusedParameters: true + reuseCollectedDataRuleErrors: true diff --git a/conf/config.neon b/conf/config.neon index f623528506a..0eb6c0c764b 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -65,6 +65,7 @@ parameters: backedEnumFromParameterType: false unusedVariable: false unusedParameters: false + reuseCollectedDataRuleErrors: false fileExtensions: - php checkAdvancedIsset: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 154054ccb1f..62dab9d9c3b 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -62,6 +62,7 @@ parametersSchema: backedEnumFromParameterType: bool() unusedVariable: bool() unusedParameters: bool() + reuseCollectedDataRuleErrors: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/e2e/result-cache-collected-data-rules-tracking/.gitignore b/e2e/result-cache-collected-data-rules-tracking/.gitignore new file mode 100644 index 00000000000..61ead86667c --- /dev/null +++ b/e2e/result-cache-collected-data-rules-tracking/.gitignore @@ -0,0 +1 @@ +/vendor diff --git a/e2e/result-cache-collected-data-rules-tracking/composer.json b/e2e/result-cache-collected-data-rules-tracking/composer.json new file mode 100644 index 00000000000..e23fc929433 --- /dev/null +++ b/e2e/result-cache-collected-data-rules-tracking/composer.json @@ -0,0 +1,5 @@ +{ + "autoload-dev": { + "classmap": ["extension/"] + } +} diff --git a/e2e/result-cache-collected-data-rules-tracking/composer.lock b/e2e/result-cache-collected-data-rules-tracking/composer.lock new file mode 100644 index 00000000000..ba8d41762cc --- /dev/null +++ b/e2e/result-cache-collected-data-rules-tracking/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-collected-data-rules-tracking/extension/MoreViewsExtension.php b/e2e/result-cache-collected-data-rules-tracking/extension/MoreViewsExtension.php new file mode 100644 index 00000000000..50ed386fa88 --- /dev/null +++ b/e2e/result-cache-collected-data-rules-tracking/extension/MoreViewsExtension.php @@ -0,0 +1,43 @@ +getName() !== MoreViews::class) { + return false; + } + + $this->declarationDependencyTracker->trackFileDependency($classReflection, $this->moreViewsFile); + + return in_array($methodName, file($this->moreViewsFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES), true); + } + + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return new ViewMethodReflection($classReflection, $methodName, new StringType()); + } + +} diff --git a/e2e/result-cache-collected-data-rules-tracking/extension/ViewCollector.php b/e2e/result-cache-collected-data-rules-tracking/extension/ViewCollector.php new file mode 100644 index 00000000000..ec3bb3bdad4 --- /dev/null +++ b/e2e/result-cache-collected-data-rules-tracking/extension/ViewCollector.php @@ -0,0 +1,37 @@ + + */ +final class ViewCollector implements Collector +{ + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope) + { + if (!$node->name instanceof Name || $node->name->toLowerString() !== 'view') { + return null; + } + + $args = $node->getArgs(); + if (!isset($args[0]) || !$args[0]->value instanceof String_) { + return null; + } + + return [$args[0]->value->value, $node->getStartLine()]; + } + +} diff --git a/e2e/result-cache-collected-data-rules-tracking/extension/ViewMethodReflection.php b/e2e/result-cache-collected-data-rules-tracking/extension/ViewMethodReflection.php new file mode 100644 index 00000000000..435df1cfb57 --- /dev/null +++ b/e2e/result-cache-collected-data-rules-tracking/extension/ViewMethodReflection.php @@ -0,0 +1,90 @@ +declaringClass; + } + + public function isStatic(): bool + { + return false; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function getDocComment(): ?string + { + return null; + } + + public function getName(): string + { + return $this->name; + } + + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + public function getVariants(): array + { + return [new FunctionVariant(TemplateTypeMap::createEmpty(), null, [], false, $this->returnType)]; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getThrowType(): ?Type + { + return null; + } + + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + +} diff --git a/e2e/result-cache-collected-data-rules-tracking/extension/ViewRule.php b/e2e/result-cache-collected-data-rules-tracking/extension/ViewRule.php new file mode 100644 index 00000000000..47edd41db22 --- /dev/null +++ b/e2e/result-cache-collected-data-rules-tracking/extension/ViewRule.php @@ -0,0 +1,71 @@ + + */ +final class ViewRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private string $viewsDirectory, + private string $ignoredViewsFile, + ) + { + } + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + /** + * @param Scope&DependencyTracker $scope + */ + public function processNode(Node $node, Scope $scope): array + { + $scope->trackDirectoryDependency($this->viewsDirectory, '*.txt'); + $scope->trackFileDependency($this->ignoredViewsFile); + $scope->trackClassDependency(ExtraViews::class); + + $ignoredViews = file($this->ignoredViewsFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $extraViews = $this->reflectionProvider->getClass(ExtraViews::class)->getNativeReflection()->getConstants(); + $moreViews = $this->reflectionProvider->getClass(MoreViews::class); + $errors = []; + foreach ($node->get(ViewCollector::class) as $file => $views) { + foreach ($views as [$view, $line]) { + if (is_file($this->viewsDirectory . '/' . $view . '.txt') || in_array($view, $ignoredViews, true) || in_array($view, $extraViews, true) || $moreViews->hasMethod($view)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf('View %s not found.', $view)) + ->file($file) + ->line($line) + ->identifier('view.notFound') + ->build(); + } + } + + return $errors; + } + +} diff --git a/e2e/result-cache-collected-data-rules-tracking/ignored-views.txt b/e2e/result-cache-collected-data-rules-tracking/ignored-views.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/e2e/result-cache-collected-data-rules-tracking/lib/ExtraViews.php b/e2e/result-cache-collected-data-rules-tracking/lib/ExtraViews.php new file mode 100644 index 00000000000..479c602de02 --- /dev/null +++ b/e2e/result-cache-collected-data-rules-tracking/lib/ExtraViews.php @@ -0,0 +1,10 @@ + $ignoreErrorExtensions */ @@ -34,11 +43,19 @@ public function __construct( private LocalIgnoresProcessor $localIgnoresProcessor, #[AutowiredParameter] private bool $reportUnmatchedIgnoredErrors, + private ValueDependencyCollector $valueDependencyCollector, + #[AutowiredParameter(ref: '%featureToggles.reuseCollectedDataRuleErrors%')] + private bool $reuseCollectedDataRuleErrors, ) { } - public function finalize(AnalyserResult $analyserResult, bool $onlyFiles, bool $debug): FinalizerResult + /** + * @param array $previousCollectedDataRuleResults the rule results of the + * previous run, from the result cache - a rule whose input did not change reuses its errors. + * Only with the reuseCollectedDataRuleErrors feature toggle, otherwise every rule runs. + */ + public function finalize(AnalyserResult $analyserResult, bool $onlyFiles, bool $debug, array $previousCollectedDataRuleResults = []): FinalizerResult { if (count($analyserResult->getCollectedData()) === 0) { return $this->addUnmatchedIgnoredErrors($this->mergeFilteredPhpErrors($analyserResult), [], []); @@ -50,15 +67,57 @@ public function finalize(AnalyserResult $analyserResult, bool $onlyFiles, bool $ } $nodeType = CollectedDataNode::class; - $node = new CollectedDataNode($analyserResult->getCollectedData(), $onlyFiles); + $collectedData = $analyserResult->getCollectedData(); + $node = new CollectedDataNode($collectedData, $onlyFiles); + + // Hashed once per collector type and run, in the same order however the files were merged. + $collectorHashes = []; + $hashCollector = static function (string $collectorType) use ($collectedData, &$collectorHashes): string { + if (!array_key_exists($collectorType, $collectorHashes)) { + $data = []; + foreach ($collectedData as $filePath => $collectedDataPerCollector) { + if (!isset($collectedDataPerCollector[$collectorType])) { + continue; + } - $file = 'N/A'; + $data[$filePath] = $collectedDataPerCollector[$collectorType]; + } + ksort($data); + $collectorHashes[$collectorType] = md5(serialize($data)); + } + + return $collectorHashes[$collectorType]; + }; + + $file = self::NO_FILE; $scope = $this->scopeFactory->create(ScopeContext::create($file)); $tempCollectorErrors = []; $internalErrors = $analyserResult->getInternalErrors(); + $collectedDataRuleResults = []; + $reusedRulesCount = 0; + $ruleCountsByClass = []; + $currentValues = []; foreach ($this->ruleRegistry->getRules($nodeType) as $rule) { + $ruleClass = get_class($rule); + $ruleCountsByClass[$ruleClass] = ($ruleCountsByClass[$ruleClass] ?? 0) + 1; + $ruleKey = sprintf('%s#%d', $ruleClass, $ruleCountsByClass[$ruleClass]); + + $previousResult = $this->reuseCollectedDataRuleErrors ? $previousCollectedDataRuleResults[$ruleKey] ?? null : null; + if ($previousResult !== null && $this->canReuse($previousResult, $onlyFiles, $hashCollector, $currentValues)) { + $collectedDataRuleResults[$ruleKey] = $previousResult; + $reusedRulesCount++; + $this->addRuleErrors($previousResult->getErrors(), $node, $scope, $tempCollectorErrors); + continue; + } + + $reads = new CollectedDataNodeReads(); + $ruleNode = new CollectedDataNode($collectedData, $onlyFiles, $reads); + if ($this->reuseCollectedDataRuleErrors) { + $this->valueDependencyCollector->startCollectedDataRule(); + } try { - $ruleErrors = $rule->processNode($node, $scope); + $ruleErrors = $rule->processNode($ruleNode, $scope); + $ruleValueDependencies = $this->valueDependencyCollector->getCollectedDataRuleValues(); } catch (AnalysedCodeException $e) { $tempCollectorErrors[] = (new Error($e->getMessage(), $file, $node->getStartLine(), $e, tip: $e->getTip())) ->withIdentifier('phpstan.internal') @@ -96,21 +155,20 @@ public function finalize(AnalyserResult $analyserResult, bool $onlyFiles, bool $ shouldReportBug: true, ); continue; + } finally { + $this->valueDependencyCollector->finishCollectedDataRule(); } + $errors = []; foreach ($ruleErrors as $ruleError) { - $error = $this->ruleErrorTransformer->transform($ruleError, $scope, [], $node); - - if ($error->canBeIgnored()) { - foreach ($this->ignoreErrorExtensions->getAll() as $ignoreErrorExtension) { - if ($ignoreErrorExtension->shouldIgnore($error, $node, $scope)) { - continue 2; - } - } - } + $errors[] = $this->ruleErrorTransformer->transform($ruleError, $scope, [], $ruleNode); + } - $tempCollectorErrors[] = $error; + if ($this->reuseCollectedDataRuleErrors) { + $collectedDataRuleResults[$ruleKey] = $this->createRuleResult($reads, $onlyFiles, $hashCollector, $errors, $ruleValueDependencies); } + + $this->addRuleErrors($errors, $node, $scope, $tempCollectorErrors); } $errors = $analyserResult->getUnorderedErrors(); @@ -158,7 +216,90 @@ public function finalize(AnalyserResult $analyserResult, bool $onlyFiles, bool $ peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), processedFiles: $analyserResult->getProcessedFiles(), workerCount: $analyserResult->getWorkerCount(), - ), $collectorErrors, $locallyIgnoredCollectorErrors); + ), $collectorErrors, $locallyIgnoredCollectorErrors, $collectedDataRuleResults, $reusedRulesCount); + } + + /** + * @param Closure(string): string $hashCollector + * @param list $errors + * @param array $valueDependencies + */ + private function createRuleResult(CollectedDataNodeReads $reads, bool $onlyFiles, Closure $hashCollector, array $errors, array $valueDependencies): CollectedDataRuleResult + { + $readCollectorHashes = []; + foreach ($reads->getCollectorTypes() as $collectorType) { + $readCollectorHashes[$collectorType] = $hashCollector($collectorType); + } + + $storedValueDependencies = []; + foreach ($valueDependencies as [$extensionClass, $key, $value]) { + $extension = $this->valueDependencyCollector->getExtension($extensionClass); + if ($extension === null) { + continue; + } + + $storedValueDependencies[] = [$extensionClass, $extension->keyToResultCache($key), $value]; + } + + return new CollectedDataRuleResult( + $readCollectorHashes, + $reads->isOnlyFilesRead() ? $onlyFiles : null, + $errors, + $storedValueDependencies, + ); + } + + /** + * @param Closure(string): string $hashCollector + * @param array $currentValues id => value, asked for once per run - every rule shares the values of the classes + */ + private function canReuse(CollectedDataRuleResult $previousResult, bool $onlyFiles, Closure $hashCollector, array &$currentValues): bool + { + $previousOnlyFiles = $previousResult->getOnlyFiles(); + if ($previousOnlyFiles !== null && $previousOnlyFiles !== $onlyFiles) { + return false; + } + + foreach ($previousResult->getCollectorHashes() as $collectorType => $hash) { + if ($hashCollector($collectorType) !== $hash) { + return false; + } + } + + // the values the rule declared through DependencyTracker - see ValueDependencyCollector + foreach ($previousResult->getValueDependencies() as [$extensionClass, $storedKey, $value]) { + $extension = $this->valueDependencyCollector->getExtension($extensionClass); + if ($extension === null) { + return false; + } + + $id = ValueDependencyCollector::getId($extensionClass, $storedKey); + $currentValues[$id] ??= $extension->getValue($extension->keyFromResultCache($storedKey)); + if ($currentValues[$id] !== $value) { + return false; + } + } + + return true; + } + + /** + * @param list $errors + * @param list $tempCollectorErrors + */ + private function addRuleErrors(array $errors, CollectedDataNode $node, Scope $scope, array &$tempCollectorErrors): void + { + foreach ($errors as $error) { + if ($error->canBeIgnored()) { + foreach ($this->ignoreErrorExtensions->getAll() as $ignoreErrorExtension) { + if ($ignoreErrorExtension->shouldIgnore($error, $node, $scope)) { + continue 2; + } + } + } + + $tempCollectorErrors[] = $error; + } } private function mergeFilteredPhpErrors(AnalyserResult $analyserResult): AnalyserResult @@ -187,15 +328,18 @@ private function mergeFilteredPhpErrors(AnalyserResult $analyserResult): Analyse /** * @param list $collectorErrors * @param list $locallyIgnoredCollectorErrors + * @param array $collectedDataRuleResults */ private function addUnmatchedIgnoredErrors( AnalyserResult $analyserResult, array $collectorErrors, array $locallyIgnoredCollectorErrors, + array $collectedDataRuleResults = [], + int $reusedRulesCount = 0, ): FinalizerResult { if (!$this->reportUnmatchedIgnoredErrors) { - return new FinalizerResult($analyserResult, $collectorErrors, $locallyIgnoredCollectorErrors); + return new FinalizerResult($analyserResult, $collectorErrors, $locallyIgnoredCollectorErrors, $collectedDataRuleResults, $reusedRulesCount); } $errors = $analyserResult->getUnorderedErrors(); @@ -252,6 +396,8 @@ private function addUnmatchedIgnoredErrors( ), $collectorErrors, $locallyIgnoredCollectorErrors, + $collectedDataRuleResults, + $reusedRulesCount, ); } diff --git a/src/Analyser/CollectedDataRuleResult.php b/src/Analyser/CollectedDataRuleResult.php new file mode 100644 index 00000000000..afe87c62968 --- /dev/null +++ b/src/Analyser/CollectedDataRuleResult.php @@ -0,0 +1,74 @@ + $collectorHashes collector type => hash of its data + * @param list $errors before the ignore extensions and the local ignores + * @param list $valueDependencies [extension class, key as the extension stores it, value] + */ + public function __construct( + private array $collectorHashes, + private ?bool $onlyFiles, + private array $errors, + private array $valueDependencies = [], + ) + { + } + + /** + * @return array + */ + public function getCollectorHashes(): array + { + return $this->collectorHashes; + } + + /** + * Null when the rule did not ask. + */ + public function getOnlyFiles(): ?bool + { + return $this->onlyFiles; + } + + /** + * @return list + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * @return list + */ + public function getValueDependencies(): array + { + return $this->valueDependencies; + } + + /** + * @param callable(string): string $transform + */ + public function transformPaths(callable $transform): self + { + $errors = []; + foreach ($this->errors as $error) { + $errors[] = $error->transformPaths($transform); + } + + return new self($this->collectorHashes, $this->onlyFiles, $errors, $this->valueDependencies); + } + +} diff --git a/src/Analyser/DependencyTracker.php b/src/Analyser/DependencyTracker.php index b48cbf2823b..5947fc3514e 100644 --- a/src/Analyser/DependencyTracker.php +++ b/src/Analyser/DependencyTracker.php @@ -23,6 +23,9 @@ * * It tracks what the analysis of the current file depends on besides the analysed code, so that * the result cache re-analyses the file when that changes - see ResultCacheValueExtension. + * In a rule for CollectedDataNode, it tracks what the errors of the rule depend on: with the + * reuseCollectedDataRuleErrors feature toggle, the result cache reuses the errors of such a rule + * while its collected data and the tracked values stay the same. * * Extensions that get no Scope because they describe a class - class reflection extensions - use * DeclarationDependencyTracker instead. diff --git a/src/Analyser/Error.php b/src/Analyser/Error.php index 4cfa6c7723c..9d116266e4f 100644 --- a/src/Analyser/Error.php +++ b/src/Analyser/Error.php @@ -470,7 +470,7 @@ private function transformTraitContexts(callable $transformPath): array { $result = []; foreach ($this->traitContexts as $contextFilePath => $contextFile) { - $result[$transformPath($contextFilePath)] = $contextFile; + $result[$transformPath($contextFilePath)] = $transformPath($contextFile); } return $result; diff --git a/src/Analyser/FinalizerResult.php b/src/Analyser/FinalizerResult.php index c196b25d99f..36416a4c39d 100644 --- a/src/Analyser/FinalizerResult.php +++ b/src/Analyser/FinalizerResult.php @@ -8,11 +8,14 @@ final class FinalizerResult /** * @param list $collectorErrors * @param list $locallyIgnoredCollectorErrors + * @param array $collectedDataRuleResults */ public function __construct( private AnalyserResult $analyserResult, private array $collectorErrors, private array $locallyIgnoredCollectorErrors, + private array $collectedDataRuleResults = [], + private int $reusedRulesCount = 0, ) { } @@ -46,4 +49,20 @@ public function getLocallyIgnoredCollectorErrors(): array return $this->locallyIgnoredCollectorErrors; } + /** + * @return array + */ + public function getCollectedDataRuleResults(): array + { + return $this->collectedDataRuleResults; + } + + /** + * How many of the rules on CollectedDataNode reused their errors from the result cache. + */ + public function getReusedRulesCount(): int + { + return $this->reusedRulesCount; + } + } diff --git a/src/Analyser/ResultCache/ResultCache.php b/src/Analyser/ResultCache/ResultCache.php index 99889f0a63d..cde318c1d01 100644 --- a/src/Analyser/ResultCache/ResultCache.php +++ b/src/Analyser/ResultCache/ResultCache.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ResultCache; +use PHPStan\Analyser\CollectedDataRuleResult; use PHPStan\Analyser\Error; use PHPStan\Analyser\FileAnalyserResult; use PHPStan\Collectors\CollectedData; @@ -29,6 +30,7 @@ final class ResultCache * @param array> $exportedNodes the decoded ones - see $cachedExportedNodes for the rest * @param array $projectExtensionFiles * @param array $currentFileHashes + * @param array $collectedDataRuleResults */ public function __construct( private array $filesToAnalyse, @@ -49,6 +51,7 @@ public function __construct( private CachedExportedNodes $cachedExportedNodes, private array $projectExtensionFiles, private array $currentFileHashes, + private array $collectedDataRuleResults = [], ) { } @@ -192,4 +195,12 @@ public function getCurrentFileHashes(): array return $this->currentFileHashes; } + /** + * @return array + */ + public function getCollectedDataRuleResults(): array + { + return $this->collectedDataRuleResults; + } + } diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 72c1e6a005a..0296a633cc5 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -4,6 +4,7 @@ use Nette\Neon\Neon; use PHPStan\Analyser\AnalyserResult; +use PHPStan\Analyser\CollectedDataRuleResult; use PHPStan\Analyser\Error; use PHPStan\Analyser\FileAnalyserResult; use PHPStan\Analyser\ValueDependencyCollector; @@ -105,7 +106,7 @@ final class ResultCacheManager */ private const EXTENSIONS_NOT_INVALIDATING_CACHE = ['xdebug', 'blackfire', 'phpstan_turbo']; - private const CACHE_VERSION = 'v22-valueDependencies'; + private const CACHE_VERSION = 'v23-collectedDataRuleResults'; /** * The recorded hash of a dependency that does not exist. A rule can depend on a path rather than on @@ -705,6 +706,16 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $locallyIgnoredErrors = $data['locallyIgnoredErrorsCallback'](); $collectedData = $data['collectedDataCallback'](); $exportedNodes = $data['exportedNodesCallback'](); + $collectedDataRuleResults = $data['collectedDataRuleResults'] ?? []; + if (!is_array($collectedDataRuleResults)) { + throw new RuntimeException('The cached results of the rules on collected data are not an array.'); + } + foreach ($collectedDataRuleResults as $collectedDataRuleResult) { + if (!$collectedDataRuleResult instanceof CollectedDataRuleResult) { + throw new RuntimeException('A cached result of a rule on collected data is not a CollectedDataRuleResult.'); + } + } + $collectedDataRuleResults = $transformer->absolutizeCollectedDataRuleResults($collectedDataRuleResults); } catch (Throwable $e) { @unlink($cacheFilePath); @@ -1087,6 +1098,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? cachedExportedNodes: $cachedExportedNodes->only($keptCachedExportedNodes), projectExtensionFiles: $data['projectExtensionFiles'], currentFileHashes: $currentFileHashes, + collectedDataRuleResults: $collectedDataRuleResults, ); } @@ -1233,7 +1245,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $projectConfigArray = $this->getPathTransformer()->relativizeProjectConfig($projectConfigArray); $meta['projectConfig'] = Neon::encode($projectConfigArray); } - $doSave = function (array $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, ?array $dependencies, ?array $usedTraitDependencies, ?array $valueDependencies, ?array $packageDependencies, array $exportedNodes, CachedExportedNodes $cachedExportedNodes, array $projectExtensionFiles) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): bool { + $doSave = function (array $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, ?array $dependencies, ?array $usedTraitDependencies, ?array $valueDependencies, ?array $packageDependencies, array $exportedNodes, CachedExportedNodes $cachedExportedNodes, array $projectExtensionFiles, array $collectedDataRuleResults) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): bool { if ($onlyFiles) { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because only files were passed as analysed paths.'); @@ -1318,6 +1330,9 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache unset($errorsByFile[$file]); } + // in the order save() writes them, which is the order they are restored in + ksort($collectedDataRuleResults); + $stubFiles = $this->getStubFiles(); if ( !$resultCache->isFullAnalysis() @@ -1328,6 +1343,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache && $linesToIgnore === $resultCache->getLinesToIgnore() && $unmatchedLineIgnores === $resultCache->getUnmatchedLineIgnores() && $collectedDataByFile === $resultCache->getCollectedData() + && $collectedDataRuleResults === $resultCache->getCollectedDataRuleResults() && $packageDependencies === $resultCache->getPackageDependencies() && $exportedNodes === $resultCache->getExportedNodes() && $cachedExportedNodes->getFiles() === $resultCache->getCachedExportedNodes()->getFiles() @@ -1346,7 +1362,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $currentFileHashes[$file] = self::REANALYSE_FILE_HASH_PREFIX . $currentFileHashes[$file]; } - $this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles, $currentFileHashes, $meta, $stubFiles); + $this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles, $currentFileHashes, $meta, $stubFiles, $collectedDataRuleResults); if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache is saved.'); @@ -1356,20 +1372,22 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache }; if ($resultCache->isFullAnalysis()) { - $saved = false; - if ($save !== false) { + return new ResultCacheProcessResult($analyserResult, function (array $collectedDataRuleResults) use ($save, $output, $analyserResult, $projectConfigArray, $doSave, $freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $freshCollectedDataByFile): bool { + if ($save === false) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache was not saved because it was not requested.'); + } + + return false; + } + $projectExtensionFiles = []; if ($analyserResult->getDependencies() !== null) { $projectExtensionFiles = $this->getProjectExtensionFiles($projectConfigArray, $analyserResult->getDependencies()); } - $saved = $doSave($freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $analyserResult->getLinesToIgnore(), $analyserResult->getUnmatchedLineIgnores(), $freshCollectedDataByFile, $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), $analyserResult->getValueDependencies(), $analyserResult->getPackageDependencies(), $this->addNonAnalysedExportedNodes($analyserResult->getExportedNodes(), $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), CachedExportedNodes::createEmpty()), CachedExportedNodes::createEmpty(), $projectExtensionFiles); - } else { - if ($output->isVeryVerbose()) { - $output->writeLineFormatted('Result cache was not saved because it was not requested.'); - } - } - return new ResultCacheProcessResult($analyserResult, $saved); + return $doSave($freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $analyserResult->getLinesToIgnore(), $analyserResult->getUnmatchedLineIgnores(), $freshCollectedDataByFile, $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), $analyserResult->getValueDependencies(), $analyserResult->getPackageDependencies(), $this->addNonAnalysedExportedNodes($analyserResult->getExportedNodes(), $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), CachedExportedNodes::createEmpty()), CachedExportedNodes::createEmpty(), $projectExtensionFiles, $collectedDataRuleResults); + }); } $errorsByFile = $this->mergeErrors($resultCache, $freshErrorsByFile); @@ -1385,8 +1403,11 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $linesToIgnore = $this->mergeLinesToIgnore($resultCache, $analyserResult->getLinesToIgnore()); $unmatchedLineIgnores = $this->mergeUnmatchedLineIgnores($resultCache, $analyserResult->getUnmatchedLineIgnores()); - $saved = false; - if ($save !== false) { + $saveCallback = function (array $collectedDataRuleResults) use ($save, $resultCache, $projectConfigArray, $doSave, $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes): bool { + if ($save === false) { + return false; + } + $projectExtensionFiles = []; foreach ($resultCache->getProjectExtensionFiles() as $file => [$hash, $isAnalysed, $className]) { if ($isAnalysed) { @@ -1407,8 +1428,8 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $projectExtensionFiles[$file] = [$hash, true, $className]; } } - $saved = $doSave($errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles); - } + return $doSave($errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles, $collectedDataRuleResults); + }; $flatErrors = []; foreach ($errorsByFile as $fileErrors) { @@ -1442,7 +1463,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), processedFiles: $analyserResult->getProcessedFiles(), workerCount: $analyserResult->getWorkerCount(), - ), $saved); + ), $saveCallback); } /** @@ -1866,6 +1887,7 @@ private function mergeUnmatchedLineIgnores(ResultCache $resultCache, array $fres * @param array $currentFileHashes * @param mixed[] $meta * @param array $stubFiles + * @param array $collectedDataRuleResults */ private function save( int $lastFullAnalysisTime, @@ -1884,6 +1906,7 @@ private function save( array $currentFileHashes, array $meta, array $stubFiles, + array $collectedDataRuleResults, ): void { $invertedDependencies = []; @@ -1946,6 +1969,7 @@ private function save( } ksort($exportedNodes); + ksort($collectedDataRuleResults); // The only point where the StubFilesExtensions may run: the analysis is over, bootstrapFiles // have been executed, so the extensions can rely on them. restore() reads these hashes back @@ -1968,6 +1992,7 @@ private function save( unset($invertedDependencies); $packageDependencies = $transformer->relativizeFileKeyed($packageDependencies); $projectExtensionFiles = $transformer->relativizeFileKeyed($projectExtensionFiles); + $collectedDataRuleResults = $transformer->relativizeCollectedDataRuleResults($collectedDataRuleResults); $file = $this->cacheFilePath; @@ -2004,6 +2029,7 @@ private function save( $this->writeArrayFrame($handle, $file, 'linesToIgnore', $linesToIgnore); $this->writeArrayFrame($handle, $file, 'unmatchedLineIgnores', $unmatchedLineIgnores); $this->writeArrayFrame($handle, $file, 'collectedData', $collectedData); + $this->writeValueFrame($handle, $file, 'collectedDataRuleResults', $collectedDataRuleResults); // An older PHPStan reading this file absolutizes the dependencies before it gets to the // cacheVersion check that makes it discard the file, and fails on a missing section. $this->writeArrayFrame($handle, $file, 'dependencies', []); diff --git a/src/Analyser/ResultCache/ResultCachePathTransformer.php b/src/Analyser/ResultCache/ResultCachePathTransformer.php index 4413298b015..78ae735942b 100644 --- a/src/Analyser/ResultCache/ResultCachePathTransformer.php +++ b/src/Analyser/ResultCache/ResultCachePathTransformer.php @@ -2,6 +2,8 @@ namespace PHPStan\Analyser\ResultCache; +use PHPStan\Analyser\AnalyserResultFinalizer; +use PHPStan\Analyser\CollectedDataRuleResult; use PHPStan\Analyser\Error; use PHPStan\Collectors\CollectedData; use PHPStan\Collectors\CollectorWithPaths; @@ -114,6 +116,35 @@ public function absolutizeErrors(array $errorsByFile): array return $result; } + /** + * @param array $collectedDataRuleResults + * @return array + */ + public function relativizeCollectedDataRuleResults(array $collectedDataRuleResults): array + { + $result = []; + foreach ($collectedDataRuleResults as $ruleKey => $ruleResult) { + $result[$ruleKey] = $ruleResult->transformPaths(fn (string $path): string => $path === AnalyserResultFinalizer::NO_FILE ? $path : $this->relativizeCompoundKey($path)); + } + + return $result; + } + + /** + * @param array $collectedDataRuleResults + * @return array + */ + public function absolutizeCollectedDataRuleResults(array $collectedDataRuleResults): array + { + $result = []; + foreach ($collectedDataRuleResults as $ruleKey => $ruleResult) { + // an error without a file keeps no file, absolutizing would prefix it with the directory + $result[$ruleKey] = $ruleResult->transformPaths(fn (string $path): string => $path === AnalyserResultFinalizer::NO_FILE ? $path : $this->absolutizeCompoundKey($path)); + } + + return $result; + } + /** * Collected data is opaque to the cache, so only the collector that produced it can rewrite the * paths inside it - it does so by implementing CollectorWithPaths. Collectors that carry no paths diff --git a/src/Analyser/ResultCache/ResultCacheProcessResult.php b/src/Analyser/ResultCache/ResultCacheProcessResult.php index 9789eefcbb3..ee7b5621d9f 100644 --- a/src/Analyser/ResultCache/ResultCacheProcessResult.php +++ b/src/Analyser/ResultCache/ResultCacheProcessResult.php @@ -2,12 +2,17 @@ namespace PHPStan\Analyser\ResultCache; +use Closure; use PHPStan\Analyser\AnalyserResult; +use PHPStan\Analyser\CollectedDataRuleResult; final class ResultCacheProcessResult { - public function __construct(private AnalyserResult $analyserResult, private bool $saved) + /** + * @param Closure(array): bool $saveCallback + */ + public function __construct(private AnalyserResult $analyserResult, private Closure $saveCallback) { } @@ -16,9 +21,17 @@ public function getAnalyserResult(): AnalyserResult return $this->analyserResult; } - public function isSaved(): bool + /** + * Writes the result cache, and says whether it was written. + * + * It is separate from ResultCacheManager::process() so that the caller can run the rules on + * CollectedDataNode on the merged result first, and save their results too. + * + * @param array $collectedDataRuleResults + */ + public function save(array $collectedDataRuleResults): bool { - return $this->saved; + return ($this->saveCallback)($collectedDataRuleResults); } } diff --git a/src/Analyser/ValueDependencyCollector.php b/src/Analyser/ValueDependencyCollector.php index a056ccf6da2..9394281606d 100644 --- a/src/Analyser/ValueDependencyCollector.php +++ b/src/Analyser/ValueDependencyCollector.php @@ -39,14 +39,18 @@ * by the files analysed after it, so it is what the scope's file declares that depends on the * value: a change of it re-analyses the scope's file and every file depending on it. * - * Outside the analysis of a file - in the rules for CollectedDataNode, which run again on every - * run anyway - nothing is collected. + * A rule for CollectedDataNode runs outside the analysis of a file. What it declares is collected + * for the rule itself, between startCollectedDataRule() and finishCollectedDataRule(), and stored + * with its errors: the next run reuses them only while every value is still the same. Outside of + * both, nothing is collected. * * DeclarationDependencyTracker records what a class declares depends on, through extensions that * describe a class rather than analyse code. Their result is remembered for the rest of the process * and reused by every file analysed after it without asking the extension again, so these are kept * for the whole process too, by class, and every file depending on the class - or on a class * extending it or implementing it - gets them as its own dependencies when its analysis finishes. + * A rule for CollectedDataNode gets those of every class, because it is not known which classes + * it read. * * @phpstan-type ValueDependencies = array{ * values: array, @@ -59,6 +63,9 @@ final class ValueDependencyCollector private ?string $analysedFile = null; + /** @var array|null id => [extension class, key, value], while a rule for CollectedDataNode runs */ + private ?array $collectedDataRuleValues = null; + /** @var array id => [extension class, key, value] */ private array $values = []; @@ -100,6 +107,12 @@ public function startFile(string $analysedFile): void */ public function record(string $extensionClass, string $key, Scope $scope, bool $insideWalk): void { + if ($this->collectedDataRuleValues !== null) { + $id = self::getId($extensionClass, $key); + $this->collectedDataRuleValues[$id] ??= [$extensionClass, $key, $this->getRegisteredExtension($extensionClass)->getValue($key)]; + return; + } + if ($this->analysedFile === null) { return; } @@ -154,6 +167,36 @@ public function recordClass(string $className, Scope $scope, bool $insideWalk): $this->record(ClassResultCacheValueExtension::class, ClassResultCacheValueExtension::createKey($className), $scope, $insideWalk); } + public function startCollectedDataRule(): void + { + $this->collectedDataRuleValues = []; + } + + /** + * With what every class seen in this process declares depends on, see recordForClass(): the + * rule may have read any of them, and an extension describes a class only once per process. + * + * @return array id => [extension class, key, value] + */ + public function getCollectedDataRuleValues(): array + { + if ($this->collectedDataRuleValues === null) { + return []; + } + + $values = $this->collectedDataRuleValues; + foreach ($this->classDeclarations as $classValues) { + $values += $classValues; + } + + return $values; + } + + public function finishCollectedDataRule(): void + { + $this->collectedDataRuleValues = null; + } + public function getFileKey(string $file): string { return $this->fileHelper->normalizePath($file); diff --git a/src/Command/AnalyseApplication.php b/src/Command/AnalyseApplication.php index f5868057130..f74503ac2dd 100644 --- a/src/Command/AnalyseApplication.php +++ b/src/Command/AnalyseApplication.php @@ -135,11 +135,21 @@ public function analyse( $processedFiles = $intermediateAnalyserResult->getProcessedFiles(); $resultCacheResult = $resultCacheManager->process($intermediateAnalyserResult, $resultCache, $errorOutput, $onlyFiles, true); - $analyserResult = $this->analyserResultFinalizer->finalize( + $finalizerResult = $this->analyserResultFinalizer->finalize( $this->switchTmpFileInAnalyserResult($resultCacheResult->getAnalyserResult(), $insteadOfFile, $tmpFile), $onlyFiles, $debug, - )->getAnalyserResult(); + $resultCache->getCollectedDataRuleResults(), + ); + if ($finalizerResult->getReusedRulesCount() > 0 && $errorOutput->isVeryVerbose()) { + $errorOutput->writeLineFormatted(sprintf( + 'Result cache reused the errors of %d of %d rules on collected data.', + $finalizerResult->getReusedRulesCount(), + count($finalizerResult->getCollectedDataRuleResults()), + )); + } + $savedResultCache = $resultCacheResult->save($finalizerResult->getCollectedDataRuleResults()); + $analyserResult = $finalizerResult->getAnalyserResult(); $internalErrors = $analyserResult->getInternalErrors(); $errors = array_merge( $analyserResult->getErrors(), @@ -154,7 +164,6 @@ public function analyse( $fileSpecificErrors = $ignoredErrorHelperProcessedResult->getNotIgnoredErrors(); $notFileSpecificErrors = $ignoredErrorHelperProcessedResult->getOtherIgnoreMessages(); $collectedData = $analyserResult->getCollectedData(); - $savedResultCache = $resultCacheResult->isSaved(); } return new AnalysisResult( diff --git a/src/Command/FixerWorkerRunner.php b/src/Command/FixerWorkerRunner.php index cb162b221b4..71f134c11bc 100644 --- a/src/Command/FixerWorkerRunner.php +++ b/src/Command/FixerWorkerRunner.php @@ -190,14 +190,15 @@ function (array $errors, array $locallyIgnoredErrors, array $analysedFiles) use ]); }, )->then(function (AnalyserResult $intermediateAnalyserResult) use ($resultCacheManager, $resultCache, $errorOutput, $isOnlyFiles, $ignoredErrorHelperResult, $inceptionFiles, $out): void { - $analyserResult = $resultCacheManager->process( + $resultCacheResult = $resultCacheManager->process( $intermediateAnalyserResult, $resultCache, $errorOutput, false, true, - )->getAnalyserResult(); - $finalizerResult = $this->analyserResultFinalizer->finalize($analyserResult, $isOnlyFiles, false); + ); + $finalizerResult = $this->analyserResultFinalizer->finalize($resultCacheResult->getAnalyserResult(), $isOnlyFiles, false, $resultCache->getCollectedDataRuleResults()); + $resultCacheResult->save($finalizerResult->getCollectedDataRuleResults()); $internalErrors = []; foreach ($finalizerResult->getAnalyserResult()->getInternalErrors() as $internalError) { diff --git a/src/Node/CollectedDataNode.php b/src/Node/CollectedDataNode.php index 14a12815fe1..db5f89ff32e 100644 --- a/src/Node/CollectedDataNode.php +++ b/src/Node/CollectedDataNode.php @@ -18,7 +18,7 @@ final class CollectedDataNode extends NodeAbstract implements VirtualNode /** * @param CollectorData $collectedData */ - public function __construct(private array $collectedData, private bool $onlyFiles) + public function __construct(private array $collectedData, private bool $onlyFiles, private ?CollectedDataNodeReads $reads = null) { parent::__construct([]); } @@ -31,6 +31,10 @@ public function __construct(private array $collectedData, private bool $onlyFile */ public function get(string $collectorType): array { + if ($this->reads !== null) { + $this->reads->addCollectorType($collectorType); + } + $result = []; foreach ($this->collectedData as $filePath => $collectedDataPerCollector) { if (!isset($collectedDataPerCollector[$collectorType])) { @@ -52,6 +56,10 @@ public function get(string $collectorType): array */ public function isOnlyFilesAnalysis(): bool { + if ($this->reads !== null) { + $this->reads->addOnlyFiles(); + } + return $this->onlyFiles; } diff --git a/src/Node/CollectedDataNodeReads.php b/src/Node/CollectedDataNodeReads.php new file mode 100644 index 00000000000..4b35e6874b7 --- /dev/null +++ b/src/Node/CollectedDataNodeReads.php @@ -0,0 +1,42 @@ + */ + private array $collectorTypes = []; + + private bool $onlyFilesRead = false; + + public function addCollectorType(string $collectorType): void + { + $this->collectorTypes[$collectorType] = true; + } + + public function addOnlyFiles(): void + { + $this->onlyFilesRead = true; + } + + /** + * @return list + */ + public function getCollectorTypes(): array + { + return array_keys($this->collectorTypes); + } + + public function isOnlyFilesRead(): bool + { + return $this->onlyFilesRead; + } + +} diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index af028257400..99f8770e99c 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -304,6 +304,8 @@ private function gatherAnalyserErrorsWithDelayedErrors(array $files): array self::createScopeFactory($reflectionProvider, self::getContainer()->getService('typeSpecifier')), new LocalIgnoresProcessor(), true, + self::getContainer()->getByType(ValueDependencyCollector::class), + false, ); return [ diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index 060c45d6af5..8c68cedc877 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -812,6 +812,8 @@ private function runAnalyser( ), new LocalIgnoresProcessor(), $reportUnmatchedIgnoredErrors, + self::getContainer()->getByType(ValueDependencyCollector::class), + false, ); $analyserResult = $finalizer->finalize($analyserResult, $onlyFiles, false)->getAnalyserResult(); diff --git a/tests/PHPStan/Analyser/CollectedDataReuse/DescribedClassRule.php b/tests/PHPStan/Analyser/CollectedDataReuse/DescribedClassRule.php new file mode 100644 index 00000000000..b73998f02c6 --- /dev/null +++ b/tests/PHPStan/Analyser/CollectedDataReuse/DescribedClassRule.php @@ -0,0 +1,48 @@ + + */ +final class DescribedClassRule implements Rule +{ + + public int $runs = 0; + + public function __construct(private string $file) + { + } + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $this->runs++; + + $contents = file_get_contents($this->file); + + return [ + RuleErrorBuilder::message(sprintf('Class is described by %s.', $contents === false ? 'nothing' : $contents)) + ->file(__FILE__) + ->line(1) + ->identifier('tests.describedClass') + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Analyser/CollectedDataReuse/EarlyReturnRule.php b/tests/PHPStan/Analyser/CollectedDataReuse/EarlyReturnRule.php new file mode 100644 index 00000000000..974bd6c4072 --- /dev/null +++ b/tests/PHPStan/Analyser/CollectedDataReuse/EarlyReturnRule.php @@ -0,0 +1,50 @@ + + */ +final class EarlyReturnRule implements Rule +{ + + public int $runs = 0; + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $this->runs++; + + $first = $node->get(FirstCollector::class); + if ($first === []) { + return []; + } + + $errors = []; + foreach ($node->get(SecondCollector::class) as $file => $values) { + foreach ($values as $value) { + $errors[] = RuleErrorBuilder::message('Second collected ' . $value . '.') + ->file($file) + ->line(1) + ->identifier('tests.second') + ->build(); + } + } + + return $errors; + } + +} diff --git a/tests/PHPStan/Analyser/CollectedDataReuse/FirstCollector.php b/tests/PHPStan/Analyser/CollectedDataReuse/FirstCollector.php new file mode 100644 index 00000000000..21dcae3ccd9 --- /dev/null +++ b/tests/PHPStan/Analyser/CollectedDataReuse/FirstCollector.php @@ -0,0 +1,26 @@ + + */ +final class FirstCollector implements Collector +{ + + public function getNodeType(): string + { + return Node::class; + } + + public function processNode(Node $node, Scope&DependencyTracker $scope) + { + return null; + } + +} diff --git a/tests/PHPStan/Analyser/CollectedDataReuse/OnlyFilesRule.php b/tests/PHPStan/Analyser/CollectedDataReuse/OnlyFilesRule.php new file mode 100644 index 00000000000..f5e1805c064 --- /dev/null +++ b/tests/PHPStan/Analyser/CollectedDataReuse/OnlyFilesRule.php @@ -0,0 +1,37 @@ + + */ +final class OnlyFilesRule implements Rule +{ + + public int $runs = 0; + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $this->runs++; + + if ($node->isOnlyFilesAnalysis()) { + return []; + } + + return [ + RuleErrorBuilder::message('Full analysis.')->file(__FILE__)->line(2)->identifier('tests.full')->build(), + ]; + } + +} diff --git a/tests/PHPStan/Analyser/CollectedDataReuse/SecondCollector.php b/tests/PHPStan/Analyser/CollectedDataReuse/SecondCollector.php new file mode 100644 index 00000000000..574e0dff4e7 --- /dev/null +++ b/tests/PHPStan/Analyser/CollectedDataReuse/SecondCollector.php @@ -0,0 +1,26 @@ + + */ +final class SecondCollector implements Collector +{ + + public function getNodeType(): string + { + return Node::class; + } + + public function processNode(Node $node, Scope&DependencyTracker $scope) + { + return null; + } + +} diff --git a/tests/PHPStan/Analyser/CollectedDataReuse/ThrowingRule.php b/tests/PHPStan/Analyser/CollectedDataReuse/ThrowingRule.php new file mode 100644 index 00000000000..bfe08ed4778 --- /dev/null +++ b/tests/PHPStan/Analyser/CollectedDataReuse/ThrowingRule.php @@ -0,0 +1,32 @@ + + */ +final class ThrowingRule implements Rule +{ + + public int $runs = 0; + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $this->runs++; + $node->get(FirstCollector::class); + + throw new FunctionNotFoundException('notThere'); + } + +} diff --git a/tests/PHPStan/Analyser/CollectedDataReuse/TrackingRule.php b/tests/PHPStan/Analyser/CollectedDataReuse/TrackingRule.php new file mode 100644 index 00000000000..6b52f2b2fd0 --- /dev/null +++ b/tests/PHPStan/Analyser/CollectedDataReuse/TrackingRule.php @@ -0,0 +1,61 @@ + + */ +final class TrackingRule implements Rule +{ + + public int $runs = 0; + + public function __construct(private string $file, private string $directory, private string $valueFile) + { + } + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + /** + * @param Scope&DependencyTracker $scope + */ + public function processNode(Node $node, Scope $scope): array + { + $this->runs++; + + $scope->trackFileDependency($this->file); + $scope->trackDirectoryDependency($this->directory, '*.txt'); + $scope->trackValueDependency(FileResultCacheValueExtension::class, $this->valueFile); + + $contents = file_get_contents($this->file); + $files = glob($this->directory . '/*.txt'); + $value = file_get_contents($this->valueFile); + + return [ + RuleErrorBuilder::message(sprintf('File says %s, directory has %d files, value is %s.', $contents === false ? 'nothing' : $contents, $files === false ? 0 : count($files), $value === false ? 'nothing' : $value)) + ->file(__FILE__) + ->line(1) + ->identifier('tests.tracking') + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Analyser/CollectedDataReuse/UnregisteredValueExtension.php b/tests/PHPStan/Analyser/CollectedDataReuse/UnregisteredValueExtension.php new file mode 100644 index 00000000000..07680c5c8cf --- /dev/null +++ b/tests/PHPStan/Analyser/CollectedDataReuse/UnregisteredValueExtension.php @@ -0,0 +1,25 @@ + + */ +final class UnregisteredValueRule implements Rule +{ + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + /** + * @param Scope&DependencyTracker $scope + */ + public function processNode(Node $node, Scope $scope): array + { + $scope->trackValueDependency(UnregisteredValueExtension::class, 'key'); + + return [ + RuleErrorBuilder::message('Unregistered value.') + ->file(__FILE__) + ->line(1) + ->identifier('tests.unregistered') + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Analyser/CollectedDataRuleReuseTest.php b/tests/PHPStan/Analyser/CollectedDataRuleReuseTest.php new file mode 100644 index 00000000000..9a71818d42c --- /dev/null +++ b/tests/PHPStan/Analyser/CollectedDataRuleReuseTest.php @@ -0,0 +1,326 @@ +finalize([$rule], [ + self::B => [SecondCollector::class => ['b1']], + ], false, []); + $this->assertSame(1, $rule->runs); + $this->assertSame([], $this->getMessages($result)); + + // SecondCollector changed, but the rule did not ask for it + $result = $this->finalize([$rule], [ + self::B => [SecondCollector::class => ['b2']], + ], false, $result->getCollectedDataRuleResults()); + $this->assertSame(1, $rule->runs); + $this->assertSame(1, $result->getReusedRulesCount()); + + // FirstCollector changed, so the rule runs and now asks for both + $result = $this->finalize([$rule], [ + self::A => [FirstCollector::class => ['a1']], + self::B => [SecondCollector::class => ['b2']], + ], false, $result->getCollectedDataRuleResults()); + $this->assertSame(2, $rule->runs); + $this->assertSame(['Second collected b2.'], $this->getMessages($result)); + + // SecondCollector changed again, and this time the rule asked for it + $result = $this->finalize([$rule], [ + self::A => [FirstCollector::class => ['a1']], + self::B => [SecondCollector::class => ['b3']], + ], false, $result->getCollectedDataRuleResults()); + $this->assertSame(3, $rule->runs); + $this->assertSame(['Second collected b3.'], $this->getMessages($result)); + + // nothing changed: the errors come from the previous run + $result = $this->finalize([$rule], [ + self::A => [FirstCollector::class => ['a1']], + self::B => [SecondCollector::class => ['b3']], + ], false, $result->getCollectedDataRuleResults()); + $this->assertSame(3, $rule->runs); + $this->assertSame(1, $result->getReusedRulesCount()); + $this->assertSame(['Second collected b3.'], $this->getMessages($result)); + } + + public function testOrderOfFilesDoesNotMatter(): void + { + $rule = new EarlyReturnRule(); + $result = $this->finalize([$rule], [ + self::A => [FirstCollector::class => ['a1']], + self::B => [SecondCollector::class => ['b1']], + self::C => [SecondCollector::class => ['c1']], + ], false, []); + + // a merged result cache lists the files in another order than a full analysis does + $result = $this->finalize([$rule], [ + self::C => [SecondCollector::class => ['c1']], + self::A => [FirstCollector::class => ['a1']], + self::B => [SecondCollector::class => ['b1']], + ], false, $result->getCollectedDataRuleResults()); + $this->assertSame(1, $rule->runs); + $this->assertSame(1, $result->getReusedRulesCount()); + } + + public function testOnlyFilesAnalysis(): void + { + $onlyFilesRule = new OnlyFilesRule(); + $earlyReturnRule = new EarlyReturnRule(); + $collectedData = [ + self::A => [FirstCollector::class => ['a1']], + ]; + + $result = $this->finalize([$onlyFilesRule, $earlyReturnRule], $collectedData, false, []); + $this->assertSame(['Full analysis.'], $this->getMessages($result)); + + // only the rule that asked for isOnlyFilesAnalysis() runs again + $result = $this->finalize([$onlyFilesRule, $earlyReturnRule], $collectedData, true, $result->getCollectedDataRuleResults()); + $this->assertSame(2, $onlyFilesRule->runs); + $this->assertSame(1, $earlyReturnRule->runs); + $this->assertSame([], $this->getMessages($result)); + } + + public function testRuleThatThrowsIsNotReused(): void + { + $rule = new ThrowingRule(); + $collectedData = [ + self::A => [FirstCollector::class => ['a1']], + ]; + + $result = $this->finalize([$rule], $collectedData, false, []); + $this->assertSame([], $result->getCollectedDataRuleResults()); + + $result = $this->finalize([$rule], $collectedData, false, $result->getCollectedDataRuleResults()); + $this->assertSame(2, $rule->runs); + $this->assertSame(['Function notThere not found while trying to analyse it - discovering symbols is probably not configured properly.'], $this->getMessages($result)); + } + + public function testTwoRulesOfTheSameClassKeepTheirOwnResults(): void + { + $first = new OnlyFilesRule(); + $second = new EarlyReturnRule(); + $third = new EarlyReturnRule(); + $collectedData = [ + self::A => [FirstCollector::class => ['a1']], + self::B => [SecondCollector::class => ['b1']], + ]; + + $result = $this->finalize([$first, $second, $third], $collectedData, false, []); + $this->assertCount(3, $result->getCollectedDataRuleResults()); + + $result = $this->finalize([$first, $second, $third], $collectedData, false, $result->getCollectedDataRuleResults()); + $this->assertSame(3, $result->getReusedRulesCount()); + $this->assertSame(['Full analysis.', 'Second collected b1.', 'Second collected b1.'], $this->getMessages($result)); + } + + public function testRuleRunsAgainWhenATrackedValueChanged(): void + { + $directory = sys_get_temp_dir() . '/phpstan-collected-data-reuse-' . uniqid(); + mkdir($directory); + $file = $directory . '/value.php'; + file_put_contents($file, 'one'); + file_put_contents($directory . '/a.txt', ''); + $valueFile = $directory . '/value.data'; + file_put_contents($valueFile, 'a'); + + $rule = new TrackingRule($file, $directory, $valueFile); + $collectedData = [ + self::A => [FirstCollector::class => ['a1']], + ]; + + $result = $this->finalize([$rule], $collectedData, false, []); + $this->assertSame(1, $rule->runs); + $this->assertSame(['File says one, directory has 1 files, value is a.'], $this->getMessages($result)); + + // nothing changed + $result = $this->finalize([$rule], $collectedData, false, $result->getCollectedDataRuleResults()); + $this->assertSame(1, $rule->runs); + $this->assertSame(1, $result->getReusedRulesCount()); + $this->assertSame(['File says one, directory has 1 files, value is a.'], $this->getMessages($result)); + + // the tracked file changed - in size too, FileContentHasher memoizes by mtime and size + file_put_contents($file, 'three'); + clearstatcache(); + $result = $this->finalize([$rule], $collectedData, false, $result->getCollectedDataRuleResults()); + $this->assertSame(2, $rule->runs); + $this->assertSame(['File says three, directory has 1 files, value is a.'], $this->getMessages($result)); + + // a file appeared in the tracked directory + file_put_contents($directory . '/b.txt', ''); + $result = $this->finalize([$rule], $collectedData, false, $result->getCollectedDataRuleResults()); + $this->assertSame(3, $rule->runs); + $this->assertSame(['File says three, directory has 2 files, value is a.'], $this->getMessages($result)); + + // a file that does not match the pattern does not count + file_put_contents($directory . '/c.md', ''); + $result = $this->finalize([$rule], $collectedData, false, $result->getCollectedDataRuleResults()); + $this->assertSame(3, $rule->runs); + $this->assertSame(1, $result->getReusedRulesCount()); + + // the value declared through trackValueDependency() changed + file_put_contents($valueFile, 'bb'); + clearstatcache(); + $result = $this->finalize([$rule], $collectedData, false, $result->getCollectedDataRuleResults()); + $this->assertSame(4, $rule->runs); + $this->assertSame(['File says three, directory has 2 files, value is bb.'], $this->getMessages($result)); + + unlink($valueFile); + unlink($directory . '/c.md'); + unlink($directory . '/b.txt'); + unlink($directory . '/a.txt'); + unlink($file); + rmdir($directory); + } + + public function testRuleRunsAgainWhenWhatAClassDeclaresChanged(): void + { + $file = sys_get_temp_dir() . '/phpstan-collected-data-reuse-' . uniqid() . '.txt'; + file_put_contents($file, 'one'); + $classReflection = self::createReflectionProvider()->getClass(FirstCollector::class); + + $rule = new DescribedClassRule($file); + $collectedData = [ + self::A => [FirstCollector::class => ['a1']], + ]; + + // a new process each run: an extension describes the class once, before the rule asks for it + /** @param array $previousResults */ + $finalize = function (array $previousResults) use ($rule, $collectedData, $file, $classReflection): FinalizerResult { + $valueDependencyCollector = new ValueDependencyCollector( + self::getContainer()->getExtensionsCollection(ResultCacheValueExtension::class), + self::getContainer()->getByType(FileHelper::class), + ); + (new ValueDependencyDeclarationDependencyTracker($valueDependencyCollector))->trackFileDependency($classReflection, $file); + + return $this->finalize([$rule], $collectedData, false, $previousResults, true, $valueDependencyCollector); + }; + + $result = $finalize([]); + $this->assertSame(1, $rule->runs); + $this->assertSame(['Class is described by one.'], $this->getMessages($result)); + + // nothing changed + $result = $finalize($result->getCollectedDataRuleResults()); + $this->assertSame(1, $rule->runs); + $this->assertSame(1, $result->getReusedRulesCount()); + + // the file the extension described the class from changed - in size too, FileContentHasher memoizes by mtime and size + file_put_contents($file, 'three'); + clearstatcache(); + $result = $finalize($result->getCollectedDataRuleResults()); + $this->assertSame(2, $rule->runs); + $this->assertSame(['Class is described by three.'], $this->getMessages($result)); + + unlink($file); + } + + public function testEveryRuleRunsWithoutTheFeatureToggle(): void + { + $rule = new EarlyReturnRule(); + $collectedData = [ + self::A => [FirstCollector::class => ['a1']], + self::B => [SecondCollector::class => ['b1']], + ]; + + $result = $this->finalize([$rule], $collectedData, false, [], false); + $this->assertSame([], $result->getCollectedDataRuleResults()); + + $previousResults = $this->finalize([$rule], $collectedData, false, [])->getCollectedDataRuleResults(); + $this->assertCount(1, $previousResults); + $this->assertSame(2, $rule->runs); + + $result = $this->finalize([$rule], $collectedData, false, $previousResults, false); + $this->assertSame(3, $rule->runs); + $this->assertSame(0, $result->getReusedRulesCount()); + $this->assertSame(['Second collected b1.'], $this->getMessages($result)); + + // without the toggle, the declared values are not asked for, as before + $result = $this->finalize([new UnregisteredValueRule()], $collectedData, false, [], false); + $this->assertSame(['Unregistered value.'], $this->getMessages($result)); + } + + /** + * @param list> $rules + * @param CollectorData $collectedData + * @param array $previousResults + */ + private function finalize(array $rules, array $collectedData, bool $onlyFiles, array $previousResults, bool $reuse = true, ?ValueDependencyCollector $valueDependencyCollector = null): FinalizerResult + { + $finalizer = new AnalyserResultFinalizer( + new DirectRegistry($rules), + self::getContainer()->getExtensionsCollection(IgnoreErrorExtension::class), + self::getContainer()->getByType(RuleErrorTransformer::class), + self::createScopeFactory(self::createReflectionProvider(), self::getContainer()->getService('typeSpecifier')), + new LocalIgnoresProcessor(), + false, + $valueDependencyCollector ?? self::getContainer()->getByType(ValueDependencyCollector::class), + $reuse, + ); + + return $finalizer->finalize(new AnalyserResult( + unorderedErrors: [], + filteredPhpErrors: [], + allPhpErrors: [], + locallyIgnoredErrors: [], + linesToIgnore: [], + unmatchedLineIgnores: [], + internalErrors: [], + collectedData: $collectedData, + dependencies: [], + usedTraitDependencies: [], + valueDependencies: null, + packageDependencies: [], + exportedNodes: [], + reachedInternalErrorsCountLimit: false, + peakMemoryUsageBytes: 0, + processedFiles: [], + ), $onlyFiles, false, $previousResults); + } + + /** + * @return list + */ + private function getMessages(FinalizerResult $result): array + { + return array_map(static fn (Error $error): string => $error->getMessage(), $result->getCollectorErrors()); + } + +} diff --git a/tests/PHPStan/Analyser/ResultCache/ResultCachePathTransformerTest.php b/tests/PHPStan/Analyser/ResultCache/ResultCachePathTransformerTest.php index 4d396844674..551bd449f08 100644 --- a/tests/PHPStan/Analyser/ResultCache/ResultCachePathTransformerTest.php +++ b/tests/PHPStan/Analyser/ResultCache/ResultCachePathTransformerTest.php @@ -2,6 +2,9 @@ namespace PHPStan\Analyser\ResultCache; +use PHPStan\Analyser\AnalyserResultFinalizer; +use PHPStan\Analyser\CollectedDataRuleResult; +use PHPStan\Analyser\Error; use PHPUnit\Framework\TestCase; use function str_replace; use const DIRECTORY_SEPARATOR; @@ -103,6 +106,51 @@ public function testNormalizeMetaIsTheRoundTripFixedPoint(): void $this->assertSame($canonical, $transformer->absolutizeMeta($transformer->relativizeMeta($raw))); } + /** + * The stored errors of rules on CollectedDataNode keep trait contexts, and both the key and the + * "path (in context of class X)" value hold a path. + */ + public function testCollectedDataRuleResultsTraitContextsAreRelativized(): void + { + $transformer = new ResultCachePathTransformer(self::raw('/opt/project/vendor/phpstan/phpstan')); + $traitFile = self::canonical('/opt/project/src/SomeTrait.php'); + $error = new Error( + 'Message', + $traitFile . ' (in context of class Foo)', + 10, + filePath: $traitFile, + traitContexts: [$traitFile => $traitFile . ' (in context of class Foo)'], + ); + $results = ['SomeRule#0' => new CollectedDataRuleResult([], null, [$error])]; + + $relativized = $transformer->relativizeCollectedDataRuleResults($results); + $this->assertSame( + ['../../../src/SomeTrait.php' => '../../../src/SomeTrait.php (in context of class Foo)'], + $relativized['SomeRule#0']->getErrors()[0]->getTraitContexts(), + ); + + $absolutized = $transformer->absolutizeCollectedDataRuleResults($relativized); + $this->assertSame( + [$traitFile => $traitFile . ' (in context of class Foo)'], + $absolutized['SomeRule#0']->getErrors()[0]->getTraitContexts(), + ); + } + + /** + * A rule on CollectedDataNode can report an error without a file. It has no path to relativize, + * and absolutizing it must not prefix it with a directory. + */ + public function testCollectedDataRuleResultsKeepAnErrorWithoutFile(): void + { + $transformer = new ResultCachePathTransformer(self::raw('/opt/project/vendor/phpstan/phpstan')); + $error = new Error('Message', AnalyserResultFinalizer::NO_FILE, null); + $results = ['SomeRule#0' => new CollectedDataRuleResult([], null, [$error])]; + + $roundTrip = $transformer->absolutizeCollectedDataRuleResults($transformer->relativizeCollectedDataRuleResults($results)); + $this->assertSame(AnalyserResultFinalizer::NO_FILE, $roundTrip['SomeRule#0']->getErrors()[0]->getFile()); + $this->assertSame(AnalyserResultFinalizer::NO_FILE, $roundTrip['SomeRule#0']->getErrors()[0]->getFilePath()); + } + /** * The path as its source spells it: '/'-separated, with a drive letter on Windows. */