diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b0f15a8a69d..3bb268a85f8 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1494,6 +1494,64 @@ jobs: ../bashunit -a contains 'Result cache not used because extension file' "$OUTPUT" ../bashunit -a contains 'the rule says another thing' "$OUTPUT" ../bashunit -a not_contains 'the rule says one thing' "$OUTPUT" + - script: | + cd e2e/result-cache-rule-edit + composer install + # Composer loads the rules and the collector, which are the project's own code, and the project + # turns on resultCacheRuleScopedReanalysis. A run follows each edit below. The expected lines + # come from a cold run of the same files. + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'A.php:8:R: foo v1' "$OUTPUT" + ../bashunit -a contains 'A.php:10:No error with identifier r.foo is reported on line 10.' "$OUTPUT" + ../bashunit -a contains 'C.php:1:Called v1: foo' "$OUTPUT" + # The rule changes its messages. The trait error and the error that R reports into + # Other.php follow the edit. A cold run reports the unmatched ignore on that line too. + patch -p0 < rule-message.patch + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Changed rule: ' "$OUTPUT" + ../bashunit -a not_contains 'Result cache not used' "$OUTPUT" + ../bashunit -a contains 'A.php:8:R: foo v2' "$OUTPUT" + ../bashunit -a contains 'T.php (in context of class ResultCacheE2ERuleEdit\C):9:R: foo v2' "$OUTPUT" + ../bashunit -a contains 'Other.php:3:R: reported in Other.php v2' "$OUTPUT" + ../bashunit -a contains 'Other.php:3:No error with identifier r.elsewhere is reported on line 3.' "$OUTPUT" + ../bashunit -a not_contains 'v1 [identifier=r.' "$OUTPUT" + # The rule stops reporting foo(), so the ignore comment on A.php line 7 matches nothing. + patch -p0 < rule-stops-foo.patch + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Changed rule: ' "$OUTPUT" + ../bashunit -a not_contains 'Result cache not used' "$OUTPUT" + ../bashunit -a contains 'A.php:7:No error with identifier r.foo is reported on line 7.' "$OUTPUT" + ../bashunit -a not_contains 'R: foo' "$OUTPUT" + # The rule reports foo() again, and strlen() under the same identifier. The ignore comment + # on A.php line 10 now matches, so its unmatched-ignore error goes away. + patch -p0 < rule-reports-strlen.patch + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Changed rule: ' "$OUTPUT" + ../bashunit -a not_contains 'Result cache not used' "$OUTPUT" + ../bashunit -a contains 'A.php:8:R: foo v3' "$OUTPUT" + ../bashunit -a contains 'T.php (in context of class ResultCacheE2ERuleEdit\C):9:R: foo v3' "$OUTPUT" + ../bashunit -a contains 'Other.php:3:R: reported in Other.php v3' "$OUTPUT" + ../bashunit -a not_contains 'No error with identifier r.foo' "$OUTPUT" + # The CollectedDataNode rule changes. It runs on the collected data, not on files. + patch -p0 < collected-data-rule.patch + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Re-running it on 0 files' "$OUTPUT" + ../bashunit -a not_contains 'Result cache not used' "$OUTPUT" + ../bashunit -a contains 'A.php:1:Called v2: foo' "$OUTPUT" + ../bashunit -a contains 'C.php:1:Called v2: foo' "$OUTPUT" + ../bashunit -a not_contains 'Called v1' "$OUTPUT" + # A changed collector can change the collected data of every file, so PHPStan analyses all files again. + patch -p0 < collector.patch + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache not used because extension file' "$OUTPUT" + ../bashunit -a contains 'A.php:1:Called v2: foo' "$OUTPUT" + ../bashunit -a not_contains 'C.php:1:Called' "$OUTPUT" - script: | cd e2e/result-cache-extension-path-package-update composer install diff --git a/conf/config.neon b/conf/config.neon index 52860b21fe9..2e15fca3388 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -187,6 +187,7 @@ parameters: resultCachePath: %tmpDir%/resultCache.php resultCacheSkipIfOlderThanDays: 7 resultCacheChecksProjectExtensionFilesDependencies: false + resultCacheRuleScopedReanalysis: false dynamicConstantNames: - ICONV_IMPL - LIBXML_VERSION diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 3bae9593991..1a42c670948 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -175,6 +175,7 @@ parametersSchema: resultCachePath: string() resultCacheSkipIfOlderThanDays: int() resultCacheChecksProjectExtensionFilesDependencies: bool() + resultCacheRuleScopedReanalysis: bool() dynamicConstantNames: arrayOf(string()) customRulesetUsed: schema(bool(), nullable()) rootDir: string() diff --git a/e2e/result-cache-rule-edit/.gitignore b/e2e/result-cache-rule-edit/.gitignore new file mode 100644 index 00000000000..de4a392c331 --- /dev/null +++ b/e2e/result-cache-rule-edit/.gitignore @@ -0,0 +1,2 @@ +/vendor +/composer.lock diff --git a/e2e/result-cache-rule-edit/collected-data-rule.patch b/e2e/result-cache-rule-edit/collected-data-rule.patch new file mode 100644 index 00000000000..102aa39c6d2 --- /dev/null +++ b/e2e/result-cache-rule-edit/collected-data-rule.patch @@ -0,0 +1,11 @@ +--- rules/CalledRule.php ++++ rules/CalledRule.php +@@ -27,7 +27,7 @@ + if ($name !== 'foo') { + continue; + } +- $errors[] = RuleErrorBuilder::message('Called v1: ' . $name)->identifier('called.foo')->file($file)->line(1)->build(); ++ $errors[] = RuleErrorBuilder::message('Called v2: ' . $name)->identifier('called.foo')->file($file)->line(1)->build(); + } + } + diff --git a/e2e/result-cache-rule-edit/collector.patch b/e2e/result-cache-rule-edit/collector.patch new file mode 100644 index 00000000000..be462e2d54a --- /dev/null +++ b/e2e/result-cache-rule-edit/collector.patch @@ -0,0 +1,13 @@ +--- rules/CalledCollector.php ++++ rules/CalledCollector.php +@@ -21,6 +21,10 @@ + + public function processNode(Node $node, Scope $scope) + { ++ if ($scope->isInTrait()) { ++ return null; ++ } ++ + return $node->name instanceof Name ? $node->name->toString() : null; + } + diff --git a/e2e/result-cache-rule-edit/composer.json b/e2e/result-cache-rule-edit/composer.json new file mode 100644 index 00000000000..f65b7d24a82 --- /dev/null +++ b/e2e/result-cache-rule-edit/composer.json @@ -0,0 +1,8 @@ +{ + "name": "phpstan/result-cache-rule-edit-e2e", + "autoload": { + "psr-4": { + "ResultCacheE2ERuleEdit\\": "rules/" + } + } +} diff --git a/e2e/result-cache-rule-edit/phpstan.neon b/e2e/result-cache-rule-edit/phpstan.neon new file mode 100644 index 00000000000..053d39e1cc8 --- /dev/null +++ b/e2e/result-cache-rule-edit/phpstan.neon @@ -0,0 +1,17 @@ +parameters: + level: 0 + tmpDir: tmp + resultCacheRuleScopedReanalysis: true + paths: + - src + - rules + +rules: + - ResultCacheE2ERuleEdit\R + - ResultCacheE2ERuleEdit\CalledRule + +services: + - + class: ResultCacheE2ERuleEdit\CalledCollector + tags: + - phpstan.collector diff --git a/e2e/result-cache-rule-edit/rule-message.patch b/e2e/result-cache-rule-edit/rule-message.patch new file mode 100644 index 00000000000..ddb16bd737e --- /dev/null +++ b/e2e/result-cache-rule-edit/rule-message.patch @@ -0,0 +1,15 @@ +--- rules/R.php ++++ rules/R.php +@@ -28,10 +28,10 @@ + + $name = $node->name->toString(); + if ($name === 'foo') { +- return [RuleErrorBuilder::message('R: foo v1')->identifier('r.foo')->build()]; ++ return [RuleErrorBuilder::message('R: foo v2')->identifier('r.foo')->build()]; + } + if ($name === 'elsewhere') { +- return [RuleErrorBuilder::message('R: reported in Other.php v1')->identifier('r.elsewhere')->file(realpath(__DIR__ . '/../src/Other.php'))->line(3)->build()]; ++ return [RuleErrorBuilder::message('R: reported in Other.php v2')->identifier('r.elsewhere')->file(realpath(__DIR__ . '/../src/Other.php'))->line(3)->build()]; + } + + return []; diff --git a/e2e/result-cache-rule-edit/rule-reports-strlen.patch b/e2e/result-cache-rule-edit/rule-reports-strlen.patch new file mode 100644 index 00000000000..eef3162b21d --- /dev/null +++ b/e2e/result-cache-rule-edit/rule-reports-strlen.patch @@ -0,0 +1,18 @@ +--- rules/R.php ++++ rules/R.php +@@ -27,8 +27,14 @@ + } + + $name = $node->name->toString(); ++ if ($name === 'foo') { ++ return [RuleErrorBuilder::message('R: foo v3')->identifier('r.foo')->build()]; ++ } ++ if ($name === 'strlen') { ++ return [RuleErrorBuilder::message('R: strlen v3')->identifier('r.foo')->build()]; ++ } + if ($name === 'elsewhere') { +- return [RuleErrorBuilder::message('R: reported in Other.php v2')->identifier('r.elsewhere')->file(realpath(__DIR__ . '/../src/Other.php'))->line(3)->build()]; ++ return [RuleErrorBuilder::message('R: reported in Other.php v3')->identifier('r.elsewhere')->file(realpath(__DIR__ . '/../src/Other.php'))->line(3)->build()]; + } + + return []; diff --git a/e2e/result-cache-rule-edit/rule-stops-foo.patch b/e2e/result-cache-rule-edit/rule-stops-foo.patch new file mode 100644 index 00000000000..da3a07c0a6f --- /dev/null +++ b/e2e/result-cache-rule-edit/rule-stops-foo.patch @@ -0,0 +1,12 @@ +--- rules/R.php ++++ rules/R.php +@@ -27,9 +27,6 @@ + } + + $name = $node->name->toString(); +- if ($name === 'foo') { +- return [RuleErrorBuilder::message('R: foo v2')->identifier('r.foo')->build()]; +- } + if ($name === 'elsewhere') { + return [RuleErrorBuilder::message('R: reported in Other.php v2')->identifier('r.elsewhere')->file(realpath(__DIR__ . '/../src/Other.php'))->line(3)->build()]; + } diff --git a/e2e/result-cache-rule-edit/rules/CalledCollector.php b/e2e/result-cache-rule-edit/rules/CalledCollector.php new file mode 100644 index 00000000000..e90f16c83b7 --- /dev/null +++ b/e2e/result-cache-rule-edit/rules/CalledCollector.php @@ -0,0 +1,27 @@ + + */ +final class CalledCollector implements Collector +{ + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope) + { + return $node->name instanceof Name ? $node->name->toString() : null; + } + +} diff --git a/e2e/result-cache-rule-edit/rules/CalledRule.php b/e2e/result-cache-rule-edit/rules/CalledRule.php new file mode 100644 index 00000000000..e3692f62e0b --- /dev/null +++ b/e2e/result-cache-rule-edit/rules/CalledRule.php @@ -0,0 +1,37 @@ + + */ +final class CalledRule implements Rule +{ + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + foreach ($node->get(CalledCollector::class) as $file => $names) { + foreach ($names as $name) { + if ($name !== 'foo') { + continue; + } + $errors[] = RuleErrorBuilder::message('Called v1: ' . $name)->identifier('called.foo')->file($file)->line(1)->build(); + } + } + + return $errors; + } + +} diff --git a/e2e/result-cache-rule-edit/rules/R.php b/e2e/result-cache-rule-edit/rules/R.php new file mode 100644 index 00000000000..1e81d913aab --- /dev/null +++ b/e2e/result-cache-rule-edit/rules/R.php @@ -0,0 +1,40 @@ + + */ +final class R implements Rule +{ + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Name) { + return []; + } + + $name = $node->name->toString(); + if ($name === 'foo') { + return [RuleErrorBuilder::message('R: foo v1')->identifier('r.foo')->build()]; + } + if ($name === 'elsewhere') { + return [RuleErrorBuilder::message('R: reported in Other.php v1')->identifier('r.elsewhere')->file(realpath(__DIR__ . '/../src/Other.php'))->line(3)->build()]; + } + + return []; + } + +} diff --git a/e2e/result-cache-rule-edit/src/A.php b/e2e/result-cache-rule-edit/src/A.php new file mode 100644 index 00000000000..0458c37119e --- /dev/null +++ b/e2e/result-cache-rule-edit/src/A.php @@ -0,0 +1,12 @@ + [], 'dependents' => []]; $packageDependencies = []; $exportedNodes = []; + $nodeTypes = []; $allProcessedFiles = []; foreach ($files as $file) { if ($preFileCallback !== null) { @@ -104,6 +105,7 @@ public function analyse( $usedTraitDependencies[$file] = $fileAnalyserResult->getUsedTraitDependencies(); $valueDependencies = ValueDependencyCollector::merge($valueDependencies, $fileAnalyserResult->getValueDependencies()); $packageDependencies[$file] = $fileAnalyserResult->getPackageDependencies(); + $nodeTypes[$file] = $fileAnalyserResult->getNodeTypes(); $fileExportedNodes = $fileAnalyserResult->getExportedNodes(); if (count($fileExportedNodes) > 0) { @@ -152,6 +154,7 @@ public function analyse( // analysis ran in this process - its peak is read where the number is printed peakMemoryUsageBytes: 0, processedFiles: $allProcessedFiles, + nodeTypes: $nodeTypes, ); } diff --git a/src/Analyser/AnalyserResult.php b/src/Analyser/AnalyserResult.php index 4b814297f14..d7a4a873748 100644 --- a/src/Analyser/AnalyserResult.php +++ b/src/Analyser/AnalyserResult.php @@ -32,6 +32,7 @@ final class AnalyserResult * @param array>|null $packageDependencies * @param array> $exportedNodes * @param list $processedFiles + * @param array> $nodeTypes the node classes each analysed file passed to the rules */ public function __construct( private array $unorderedErrors, @@ -51,10 +52,19 @@ public function __construct( private int $peakMemoryUsageBytes, private array $processedFiles, private int $workerCount = 0, + private array $nodeTypes = [], ) { } + /** + * @return array> + */ + public function getNodeTypes(): array + { + return $this->nodeTypes; + } + /** * How many parallel workers produced this result; 0 when the analysis ran in * the main process. diff --git a/src/Analyser/AnalyserResultFinalizer.php b/src/Analyser/AnalyserResultFinalizer.php index c40bc1066bb..1d01ee19a2c 100644 --- a/src/Analyser/AnalyserResultFinalizer.php +++ b/src/Analyser/AnalyserResultFinalizer.php @@ -158,6 +158,7 @@ public function finalize(AnalyserResult $analyserResult, bool $onlyFiles, bool $ peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), processedFiles: $analyserResult->getProcessedFiles(), workerCount: $analyserResult->getWorkerCount(), + nodeTypes: $analyserResult->getNodeTypes(), ), $collectorErrors, $locallyIgnoredCollectorErrors); } @@ -181,6 +182,7 @@ private function mergeFilteredPhpErrors(AnalyserResult $analyserResult): Analyse peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), processedFiles: $analyserResult->getProcessedFiles(), workerCount: $analyserResult->getWorkerCount(), + nodeTypes: $analyserResult->getNodeTypes(), ); } @@ -249,6 +251,7 @@ private function addUnmatchedIgnoredErrors( peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), processedFiles: $analyserResult->getProcessedFiles(), workerCount: $analyserResult->getWorkerCount(), + nodeTypes: $analyserResult->getNodeTypes(), ), $collectorErrors, $locallyIgnoredCollectorErrors, diff --git a/src/Analyser/Error.php b/src/Analyser/Error.php index 4cfa6c7723c..ad96ffd1953 100644 --- a/src/Analyser/Error.php +++ b/src/Analyser/Error.php @@ -43,6 +43,8 @@ public function __construct( private array $metadata = [], private ?FixedErrorDiff $fixedErrorDiff = null, private array $traitContexts = [], + private ?string $ruleClass = null, + private ?string $originFile = null, ) { if ($this->identifier !== null && !self::validateIdentifier($this->identifier)) { @@ -88,6 +90,8 @@ public function changeFilePath(string $newFilePath): self $this->identifier, $this->metadata, $this->fixedErrorDiff, + ruleClass: $this->ruleClass, + originFile: $this->originFile, ); } @@ -107,6 +111,8 @@ public function changeTraitFilePath(string $newFilePath): self $this->metadata, $this->fixedErrorDiff, $this->traitContexts, + ruleClass: $this->ruleClass, + originFile: $this->originFile, ); } @@ -130,6 +136,8 @@ public function removeTraitContext(): self $this->metadata, $this->fixedErrorDiff, $this->traitContexts, + ruleClass: $this->ruleClass, + originFile: $this->originFile, ); } @@ -171,6 +179,8 @@ public function withTraitContexts(array $traitContexts): self $this->metadata, $this->fixedErrorDiff, $traitContexts, + ruleClass: $this->ruleClass, + originFile: $this->originFile, ); } @@ -197,6 +207,8 @@ public function asReportedInTraitContext(string $contextFilePath): self $this->identifier, $this->metadata, $this->fixedErrorDiff, + ruleClass: $this->ruleClass, + originFile: $this->originFile, ); } @@ -224,6 +236,8 @@ public function transformPaths(callable $transformPath): self $this->metadata, $this->fixedErrorDiff, $this->transformTraitContexts($transformPath), + ruleClass: $this->ruleClass, + originFile: $this->originFile === null ? null : $transformPath($this->originFile), ); } @@ -267,6 +281,8 @@ public function withoutTip(): self $this->metadata, $this->fixedErrorDiff, $this->traitContexts, + ruleClass: $this->ruleClass, + originFile: $this->originFile, ); } @@ -290,6 +306,8 @@ public function doNotIgnore(): self $this->metadata, $this->fixedErrorDiff, $this->traitContexts, + ruleClass: $this->ruleClass, + originFile: $this->originFile, ); } @@ -313,6 +331,8 @@ public function withIdentifier(string $identifier): self $this->metadata, $this->fixedErrorDiff, $this->traitContexts, + ruleClass: $this->ruleClass, + originFile: $this->originFile, ); } @@ -339,6 +359,8 @@ public function withMetadata(array $metadata): self $metadata, $this->fixedErrorDiff, $this->traitContexts, + ruleClass: $this->ruleClass, + originFile: $this->originFile, ); } @@ -355,6 +377,66 @@ public function getNodeType(): ?string return $this->nodeType; } + /** + * The class of the rule that reported this error, when a rule did. The result cache uses it to + * re-run a single changed rule. + */ + public function getRuleClass(): ?string + { + return $this->ruleClass; + } + + /** + * The analysed file whose analysis reported this error, when that is another file than the + * one the error is in. Only the ignores of that file can match the error. + */ + public function getOriginFile(): ?string + { + return $this->originFile; + } + + public function withOriginFile(string $originFile): self + { + return new self( + $this->message, + $this->file, + $this->line, + $this->canBeIgnored, + $this->filePath, + $this->traitFilePath, + $this->tip, + $this->nodeLine, + $this->nodeType, + $this->identifier, + $this->metadata, + $this->fixedErrorDiff, + $this->traitContexts, + $this->ruleClass, + $originFile, + ); + } + + public function withRuleClass(string $ruleClass): self + { + return new self( + $this->message, + $this->file, + $this->line, + $this->canBeIgnored, + $this->filePath, + $this->traitFilePath, + $this->tip, + $this->nodeLine, + $this->nodeType, + $this->identifier, + $this->metadata, + $this->fixedErrorDiff, + $this->traitContexts, + $ruleClass, + $this->originFile, + ); + } + /** * Error identifier set via `RuleErrorBuilder::identifier()`. * @@ -410,6 +492,8 @@ public function jsonSerialize() 'fixedErrorDiffHash' => $fixedErrorDiffHash, 'fixedErrorDiffDiff' => $fixedErrorDiffDiff, 'traitContexts' => $this->traitContexts, + 'ruleClass' => $this->ruleClass, + 'originFile' => $this->originFile, ]; } @@ -437,6 +521,8 @@ public static function decode(array $json): self $json['metadata'] ?? [], $fixedErrorDiff, $json['traitContexts'] ?? [], + $json['ruleClass'] ?? null, + $json['originFile'] ?? null, ); } @@ -459,6 +545,8 @@ public static function __set_state(array $properties): self $properties['metadata'] ?? [], $properties['fixedErrorDiff'] ?? null, $properties['traitContexts'] ?? [], + $properties['ruleClass'] ?? null, + $properties['originFile'] ?? null, ); } diff --git a/src/Analyser/FileAnalyser.php b/src/Analyser/FileAnalyser.php index ad0dad34c3f..0703d223971 100644 --- a/src/Analyser/FileAnalyser.php +++ b/src/Analyser/FileAnalyser.php @@ -69,6 +69,7 @@ public function __construct( private bool $reportIgnoresWithoutComments, private ValueDependencyCollector $valueDependencyCollector, private DependencyResolver $dependencyResolver, + private ?RuleScopedAnalysis $ruleScopedAnalysis = null, ) { } @@ -103,6 +104,13 @@ public function analyseFile( $exportedNodes = []; $linesToIgnore = []; $unmatchedLineIgnores = []; + $nodeTypes = []; + // re-analysed only for a changed rule: the result cache keeps everything else of the file + $ruleScoped = $this->ruleScopedAnalysis !== null && $this->ruleScopedAnalysis->isScoped($file); + if ($ruleScoped) { + $ruleRegistry = new RuleScopedRegistry($ruleRegistry, $this->ruleScopedAnalysis->getRuleClasses()); + $collectorRegistry = new CollectorRegistry([]); + } $this->valueDependencyCollector->startFile($file); if (is_file($file)) { try { @@ -145,6 +153,7 @@ public function analyseFile( $unmatchedLineIgnores = $nodeCallback->getUnmatchedLineIgnores(); $temporaryFileErrors = $nodeCallback->getTemporaryFileErrors(); $processedFiles = $nodeCallback->getProcessedFiles(); + $nodeTypes = $nodeCallback->getNodeTypes(); if ($this->reportIgnoresWithoutComments) { foreach ($linesToIgnore as $ignoredFile => $lines) { @@ -185,6 +194,14 @@ public function analyseFile( } } + if ($ruleScoped) { + // the result cache matches the local ignores against these and the cached errors + foreach ($temporaryFileErrors as $temporaryFileError) { + $fileErrors[] = $temporaryFileError; + } + $temporaryFileErrors = []; + } + $localIgnoresProcessorResult = $this->localIgnoresProcessor->process( $temporaryFileErrors, $linesToIgnore, @@ -264,6 +281,7 @@ public function analyseFile( $linesToIgnore, $unmatchedLineIgnores, $processedFiles, + $nodeTypes, ); } diff --git a/src/Analyser/FileAnalyserCallback.php b/src/Analyser/FileAnalyserCallback.php index 4dac2fa0ccf..03abbdc45e8 100644 --- a/src/Analyser/FileAnalyserCallback.php +++ b/src/Analyser/FileAnalyserCallback.php @@ -59,6 +59,14 @@ final class FileAnalyserCallback /** @var array>> */ private array $collectorsByNodeType = []; + /** + * The classes of the nodes this file passed to the rules, so that the result cache can tell + * which files a changed rule needs. + * + * @var array, true> + */ + private array $nodeTypes = []; + /** * Whether a node of the class can be exported or change the PHPDoc name scope - most cannot, and * the walk does not ask DependencyResolver about them at all. @@ -98,6 +106,7 @@ public function __invoke(Node $node, Scope $scope): void $this->fileCollectedData[$scope->getFile()][$node->getCollectorType()][] = $node->getData(); return; } + $this->nodeTypes[$nodeType] = true; $parserNodes = $this->parserNodes; @@ -154,7 +163,8 @@ public function __invoke(Node $node, Scope $scope): void ->withMetadata([ InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), - ]); + ]) + ->withRuleClass(get_class($rule)); continue; } catch (IdentifierNotFound $e) { $this->fileErrors[] = (new Error(sprintf('Reflection error: %s not found.', $e->getIdentifier()->getName()), $this->file, $node->getStartLine(), $e, tip: 'Learn more at https://phpstan.org/user-guide/discovering-symbols')) @@ -162,7 +172,8 @@ public function __invoke(Node $node, Scope $scope): void ->withMetadata([ InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), - ]); + ]) + ->withRuleClass(get_class($rule)); continue; } catch (UnableToCompileNode | CircularReference $e) { $this->fileErrors[] = (new Error(sprintf('Reflection error: %s', $e->getMessage()), $this->file, $node->getStartLine(), $e)) @@ -170,7 +181,8 @@ public function __invoke(Node $node, Scope $scope): void ->withMetadata([ InternalError::STACK_TRACE_METADATA_KEY => InternalError::prepareTrace($e), InternalError::STACK_TRACE_AS_STRING_METADATA_KEY => $e->getTraceAsString(), - ]); + ]) + ->withRuleClass(get_class($rule)); continue; } @@ -192,6 +204,13 @@ public function __invoke(Node $node, Scope $scope): void } } + if ($error->getRuleClass() === null) { + $error = $error->withRuleClass(get_class($rule)); + } + if ($error->getFilePath() !== $this->file && $error->getOriginFile() === null) { + // reported in another file: only this file's ignores could match it + $error = $error->withOriginFile($this->file); + } $this->temporaryFileErrors[] = $error; } } @@ -322,6 +341,14 @@ public function getTemporaryFileErrors(): array return $this->temporaryFileErrors; } + /** + * @return list> + */ + public function getNodeTypes(): array + { + return array_keys($this->nodeTypes); + } + /** * @return list */ diff --git a/src/Analyser/FileAnalyserResult.php b/src/Analyser/FileAnalyserResult.php index 693a01335fd..081c61342f2 100644 --- a/src/Analyser/FileAnalyserResult.php +++ b/src/Analyser/FileAnalyserResult.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser; +use PhpParser\Node; use PHPStan\Collectors\CollectedData; use PHPStan\Dependency\RootExportedNode; @@ -28,6 +29,7 @@ final class FileAnalyserResult * @param LinesToIgnore $linesToIgnore * @param LinesToIgnore $unmatchedLineIgnores * @param list $processedFiles + * @param list> $nodeTypes */ public function __construct( private array $errors, @@ -43,10 +45,19 @@ public function __construct( private array $linesToIgnore, private array $unmatchedLineIgnores, private array $processedFiles, + private array $nodeTypes = [], ) { } + /** + * @return list> + */ + public function getNodeTypes(): array + { + return $this->nodeTypes; + } + /** * @return list */ diff --git a/src/Analyser/ResultCache/ResultCache.php b/src/Analyser/ResultCache/ResultCache.php index 99889f0a63d..53720e3e726 100644 --- a/src/Analyser/ResultCache/ResultCache.php +++ b/src/Analyser/ResultCache/ResultCache.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\FileAnalyserResult; use PHPStan\Collectors\CollectedData; use PHPStan\Dependency\RootExportedNode; +use function array_fill_keys; /** * @phpstan-import-type LinesToIgnore from FileAnalyserResult @@ -29,6 +30,9 @@ final class ResultCache * @param array> $exportedNodes the decoded ones - see $cachedExportedNodes for the rest * @param array $projectExtensionFiles * @param array $currentFileHashes + * @param list $ruleScopedFiles files in $filesToAnalyse that are re-analysed only for $changedRuleClasses + * @param list $changedRuleClasses + * @param array> $nodeTypes the cached node classes per analysed file */ public function __construct( private array $filesToAnalyse, @@ -49,10 +53,82 @@ public function __construct( private CachedExportedNodes $cachedExportedNodes, private array $projectExtensionFiles, private array $currentFileHashes, + private array $ruleScopedFiles = [], + private array $changedRuleClasses = [], + private array $nodeTypes = [], ) { } + /** + * @return list + */ + public function getRuleScopedFiles(): array + { + return $this->ruleScopedFiles; + } + + /** + * @return list + */ + public function getChangedRuleClasses(): array + { + return $this->changedRuleClasses; + } + + /** + * @return array> + */ + public function getNodeTypes(): array + { + return $this->nodeTypes; + } + + /** + * The same cache with only the files that are re-analysed with all rules, for the merges that + * replace everything of a re-analysed file. + */ + public function withoutRuleScopedFiles(): self + { + if ($this->ruleScopedFiles === []) { + return $this; + } + + $ruleScoped = array_fill_keys($this->ruleScopedFiles, true); + $filesToAnalyse = []; + foreach ($this->filesToAnalyse as $file) { + if (isset($ruleScoped[$file])) { + continue; + } + + $filesToAnalyse[] = $file; + } + + return new self( + $filesToAnalyse, + $this->fullAnalysis, + $this->fullAnalysisReason, + $this->lastFullAnalysisTime, + $this->meta, + $this->errors, + $this->locallyIgnoredErrors, + $this->linesToIgnore, + $this->unmatchedLineIgnores, + $this->collectedData, + $this->dependencies, + $this->usedTraitDependencies, + $this->valueDependencies, + $this->packageDependencies, + $this->exportedNodes, + $this->cachedExportedNodes, + $this->projectExtensionFiles, + $this->currentFileHashes, + [], + $this->changedRuleClasses, + $this->nodeTypes, + ); + } + /** * @return string[] */ diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 72c1e6a005a..33c38508931 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -3,9 +3,11 @@ namespace PHPStan\Analyser\ResultCache; use Nette\Neon\Neon; +use PhpParser\Node; use PHPStan\Analyser\AnalyserResult; use PHPStan\Analyser\Error; use PHPStan\Analyser\FileAnalyserResult; +use PHPStan\Analyser\LocalIgnoresProcessor; use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Collectors\CollectedData; use PHPStan\Command\Output; @@ -30,6 +32,8 @@ use PHPStan\Php\PhpVersion; use PHPStan\PhpDoc\StubFilesProvider; use PHPStan\Reflection\BetterReflection\SourceStubber\ExtensionVersionProvider; +use PHPStan\Rules\MultipleNodeTypesRule; +use PHPStan\Rules\Rule; use PHPStan\ShouldNotHappenException; use ReflectionClass; use ReflectionException; @@ -45,6 +49,7 @@ use function array_unique; use function array_values; use function class_exists; +use function class_implements; use function count; use function error_get_last; use function explode; @@ -56,11 +61,13 @@ use function fstat; use function ftell; use function fwrite; +use function get_class; use function get_loaded_extensions; use function getmypid; use function hash_file; use function implode; use function in_array; +use function is_a; use function is_array; use function is_dir; use function is_file; @@ -189,6 +196,7 @@ final class ResultCacheManager * @param list> $parametersNotInvalidatingCache * @param array $fileReplacements * @param ExtensionsCollection $resultCacheMetaExtensions + * @param ExtensionsCollection> $rules */ public function __construct( #[AutowiredExtensions(of: ResultCacheMetaExtension::class)] @@ -223,6 +231,8 @@ public function __construct( private array $fileReplacements, #[AutowiredParameter(ref: '%resultCacheChecksProjectExtensionFilesDependencies%')] private bool $checkDependenciesOfProjectExtensionFiles, + #[AutowiredParameter(ref: '%resultCacheRuleScopedReanalysis%')] + private bool $ruleScopedReanalysis, #[AutowiredParameter] private array $parametersNotInvalidatingCache, #[AutowiredParameter(ref: '%resultCacheSkipIfOlderThanDays%')] @@ -233,6 +243,9 @@ public function __construct( private ComposerPhpVersionFactory $composerPhpVersionFactory, private FileStatSignatures $fileStatSignatures, private ValueDependencyCollector $valueDependencyCollector, + #[AutowiredExtensions(of: Rule::class)] + private ExtensionsCollection $rules, + private LocalIgnoresProcessor $localIgnoresProcessor, ) { } @@ -400,6 +413,23 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $currentFileHashes = $this->hashAnalysedFiles($analysedFileStats, $data['dependencies']); $fileStatSignaturesChanged = $this->fileStatSignaturesDiffer($data['dependencies']); $data['packageDependencies'] = $transformer->absolutizeFileKeyed($data['packageDependencies'] ?? []); + $ruleScopeData = null; + if ($this->ruleScopedReanalysis && array_key_exists('ruleScope', $data)) { + try { + $ruleScopeData = RuleScopeCodec::decode($data['ruleScope']); + $ruleScopeData['nodeTypes'] = $transformer->absolutizeFileKeyed($ruleScopeData['nodeTypes']); + } catch (Throwable $e) { + @unlink($cacheFilePath); + + return $this->fullAnalysis( + sprintf('Result cache not used because an error occurred while loading the cache file: %s', $e->getMessage()), + $allAnalysedFiles, + $this->getMeta($allAnalysedFiles, $projectConfigArray), + $currentFileHashes, + $output, + ); + } + } try { $cachedValueDependencies = $this->decodeValueDependencies($data['valueDependencies'] ?? ['paths' => [], 'entries' => []], $transformer); } catch (Throwable $e) { @@ -627,6 +657,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? ); } + $changedRuleClasses = []; /** * An extension file is code that runs during the analysis, so a change in one can change any * result. That holds whether or not the file is part of the analysed paths - a file outside them @@ -635,7 +666,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? * * @var string $fileHash */ - foreach ($data['projectExtensionFiles'] as $extensionFile => [$fileHash]) { + foreach ($data['projectExtensionFiles'] as $extensionFile => [$fileHash, , $extensionClassName]) { if (!is_file($extensionFile)) { return $this->fullAnalysis( sprintf('Result cache not used because extension file %s was not found.', $extensionFile), @@ -650,6 +681,13 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? continue; } + // a rule runs only on the nodes of its type, so a changed rule needs only the files that + // have them, and only itself + if ($ruleScopeData !== null && $this->isPlainRule($extensionClassName)) { + $changedRuleClasses[$extensionClassName] = true; + continue; + } + return $this->fullAnalysis( sprintf('Result cache not used because extension file %s hash does not match.', $extensionFile), $allAnalysedFiles, @@ -1036,6 +1074,34 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? } $filesToAnalyse = array_unique($filesToAnalyse); + $ruleScopedFiles = []; + if ($changedRuleClasses !== [] && $ruleScopeData !== null) { + $ruleScopedFiles = $this->findRuleScopedFiles(array_keys($changedRuleClasses), $ruleScopeData, $allAnalysedFiles, $filesToAnalyse); + if ($ruleScopedFiles === null) { + return $this->fullAnalysis( + 'Result cache not used because a changed rule is not registered any more.', + $allAnalysedFiles, + $meta, + $currentFileHashes, + $output, + ); + } + + if ($output->isVeryVerbose()) { + $output->writeLineFormatted(sprintf( + 'Changed %s: %s. Re-running %s on %d %s.', + count($changedRuleClasses) === 1 ? 'rule' : 'rules', + implode(', ', array_keys($changedRuleClasses)), + count($changedRuleClasses) === 1 ? 'it' : 'them', + count($ruleScopedFiles), + count($ruleScopedFiles) === 1 ? 'file' : 'files', + )); + } + + foreach ($ruleScopedFiles as $ruleScopedFile) { + $filesToAnalyse[] = $ruleScopedFile; + } + } $filesToAnalyseCount = count($filesToAnalyse); if ($output->isVeryVerbose()) { @@ -1057,7 +1123,11 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? // The merge in process() replaces the entries of re-analysed files without reading them, so // they need not stay in memory alongside the fresh results while the analysis runs. + $ruleScopedFilesLookup = array_fill_keys($ruleScopedFiles, true); foreach ($filesToAnalyse as $fileToAnalyse) { + if (isset($ruleScopedFilesLookup[$fileToAnalyse])) { + continue; + } unset( $filteredErrors[$fileToAnalyse], $filteredLocallyIgnoredErrors[$fileToAnalyse], @@ -1087,6 +1157,9 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? cachedExportedNodes: $cachedExportedNodes->only($keptCachedExportedNodes), projectExtensionFiles: $data['projectExtensionFiles'], currentFileHashes: $currentFileHashes, + ruleScopedFiles: $ruleScopedFiles, + changedRuleClasses: array_keys($changedRuleClasses), + nodeTypes: $ruleScopeData['nodeTypes'] ?? [], ); } @@ -1233,7 +1306,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 $nodeTypes) 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.'); @@ -1332,6 +1405,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache && $exportedNodes === $resultCache->getExportedNodes() && $cachedExportedNodes->getFiles() === $resultCache->getCachedExportedNodes()->getFiles() && $projectExtensionFiles === $resultCache->getProjectExtensionFiles() + && $nodeTypes === $resultCache->getNodeTypes() && $stubFiles === $this->restoredStubFiles && is_file($this->cacheFilePath) ) { @@ -1346,7 +1420,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, $nodeTypes); if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache is saved.'); @@ -1362,7 +1436,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache if ($analyserResult->getDependencies() !== null) { $projectExtensionFiles = $this->getProjectExtensionFiles($projectConfigArray, $analyserResult->getDependencies()); } - $saved = $doSave($freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $analyserResult->getLinesToIgnore(), $analyserResult->getUnmatchedLineIgnores(), $freshCollectedDataByFile, $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), $analyserResult->getValueDependencies(), $analyserResult->getPackageDependencies(), $this->addNonAnalysedExportedNodes($analyserResult->getExportedNodes(), $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), CachedExportedNodes::createEmpty()), CachedExportedNodes::createEmpty(), $projectExtensionFiles); + $saved = $doSave($freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $analyserResult->getLinesToIgnore(), $analyserResult->getUnmatchedLineIgnores(), $freshCollectedDataByFile, $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), $analyserResult->getValueDependencies(), $analyserResult->getPackageDependencies(), $this->addNonAnalysedExportedNodes($analyserResult->getExportedNodes(), $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), CachedExportedNodes::createEmpty()), CachedExportedNodes::createEmpty(), $projectExtensionFiles, $analyserResult->getNodeTypes()); } else { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because it was not requested.'); @@ -1372,6 +1446,9 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache return new ResultCacheProcessResult($analyserResult, $saved); } + // the rule-scoped files keep everything cached but the errors of the changed rules + $ruleScopedResultCache = $resultCache; + $resultCache = $resultCache->withoutRuleScopedFiles(); $errorsByFile = $this->mergeErrors($resultCache, $freshErrorsByFile); $locallyIgnoredErrorsByFile = $this->mergeLocallyIgnoredErrors($resultCache, $freshLocallyIgnoredErrorsByFile); $collectedDataByFile = $this->mergeCollectedData($resultCache, $freshCollectedDataByFile); @@ -1384,6 +1461,18 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $exportedNodes = $this->addNonAnalysedExportedNodes($this->mergeExportedNodes($resultCache, $analyserResult->getExportedNodes()), $dependencies, $usedTraitDependencies, $cachedExportedNodes); $linesToIgnore = $this->mergeLinesToIgnore($resultCache, $analyserResult->getLinesToIgnore()); $unmatchedLineIgnores = $this->mergeUnmatchedLineIgnores($resultCache, $analyserResult->getUnmatchedLineIgnores()); + if ($ruleScopedResultCache->getChangedRuleClasses() !== []) { + [$errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores] = $this->mergeRuleScopedErrors( + $ruleScopedResultCache, + $resultCache->getFilesToAnalyse(), + $errorsByFile, + $locallyIgnoredErrorsByFile, + $linesToIgnore, + $unmatchedLineIgnores, + $analyserResult, + ); + } + $nodeTypes = $this->mergeNodeTypes($resultCache, $analyserResult->getNodeTypes()); $saved = false; if ($save !== false) { @@ -1407,7 +1496,11 @@ 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); + if ($ruleScopedResultCache->getChangedRuleClasses() !== [] && $dependencies !== null) { + // the changed rules' new hashes, whether or not their files are analysed + $projectExtensionFiles = $this->getProjectExtensionFiles($projectConfigArray, $dependencies); + } + $saved = $doSave($errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles, $nodeTypes); } $flatErrors = []; @@ -1866,6 +1959,7 @@ private function mergeUnmatchedLineIgnores(ResultCache $resultCache, array $fres * @param array $currentFileHashes * @param mixed[] $meta * @param array $stubFiles + * @param array> $nodeTypes */ private function save( int $lastFullAnalysisTime, @@ -1884,6 +1978,7 @@ private function save( array $currentFileHashes, array $meta, array $stubFiles, + array $nodeTypes, ): void { $invertedDependencies = []; @@ -1967,6 +2062,23 @@ private function save( $encodedValueDependencies = $this->encodeValueDependencies($valueDependencies, $transformer); unset($invertedDependencies); $packageDependencies = $transformer->relativizeFileKeyed($packageDependencies); + $ruleScope = null; + if ($this->ruleScopedReanalysis) { + // only a rule of the project itself can change without a full analysis, so only those rules + // need their node types remembered + $projectRuleNodeTypes = []; + $allRuleNodeTypes = $this->getRuleNodeTypes(); + foreach ($projectExtensionFiles as [, , $extensionClassName]) { + if (!array_key_exists($extensionClassName, $allRuleNodeTypes)) { + continue; + } + + $projectRuleNodeTypes[$extensionClassName] = $allRuleNodeTypes[$extensionClassName]; + } + ksort($projectRuleNodeTypes); + ksort($nodeTypes); + $ruleScope = RuleScopeCodec::encode($transformer->relativizeFileKeyed($nodeTypes), $projectRuleNodeTypes); + } $projectExtensionFiles = $transformer->relativizeFileKeyed($projectExtensionFiles); $file = $this->cacheFilePath; @@ -2010,6 +2122,9 @@ private function save( $this->writeValueFrame($handle, $file, 'dependencyGraph', $dependencyGraph); $this->writeValueFrame($handle, $file, 'valueDependencies', $encodedValueDependencies); $this->writeArrayFrame($handle, $file, 'packageDependencies', $packageDependencies); + if ($ruleScope !== null) { + $this->writeValueFrame($handle, $file, 'ruleScope', $ruleScope); + } $this->writeExportedNodes($handle, $file, $exportedNodes, $cachedExportedNodes, $transformer); fclose($handle); $closed = true; @@ -2731,6 +2846,273 @@ private function addNonAnalysedExportedNodes(array $exportedNodes, ?array $depen return $exportedNodes; } + /** + * Whether a changed extension class is only a rule, so that re-running it is enough. Anything + * else that PHPStan calls during the analysis - a collector, a type extension - can change any + * result. + */ + private function isPlainRule(string $className): bool + { + if (!class_exists($className) || !is_a($className, Rule::class, true)) { + return false; + } + + foreach (class_implements($className) as $interface) { + if (!str_starts_with($interface, 'PHPStan\\')) { + continue; + } + + if (in_array($interface, [Rule::class, MultipleNodeTypesRule::class], true)) { + continue; + } + + return false; + } + + return true; + } + + /** + * @return array> rule class => the node types it runs on + */ + private function getRuleNodeTypes(): array + { + $ruleNodeTypes = []; + foreach ($this->rules->getAll() as $rule) { + foreach ($rule instanceof MultipleNodeTypesRule ? $rule->getNodeTypes() : [$rule->getNodeType()] as $nodeType) { + $ruleNodeTypes[get_class($rule)][] = $nodeType; + } + } + + ksort($ruleNodeTypes); + + return $ruleNodeTypes; + } + + /** + * The analysed files, apart from the ones re-analysed anyway, that passed a node of a changed + * rule's type to the rules - with the node types of the rule now and when the cache was saved. + * Null when a changed rule is not registered any more. + * + * @param list $changedRuleClasses + * @param array{nodeTypes: array>, ruleNodeTypes: array>} $ruleScopeData + * @param array $allAnalysedFiles + * @param array $filesToAnalyse + * @return list|null + */ + private function findRuleScopedFiles(array $changedRuleClasses, array $ruleScopeData, array $allAnalysedFiles, array $filesToAnalyse): ?array + { + $currentRuleNodeTypes = $this->getRuleNodeTypes(); + $ruleNodeTypes = []; + foreach ($changedRuleClasses as $changedRuleClass) { + if (!array_key_exists($changedRuleClass, $currentRuleNodeTypes)) { + return null; + } + + foreach ($currentRuleNodeTypes[$changedRuleClass] as $nodeType) { + $ruleNodeTypes[$nodeType] = true; + } + foreach ($ruleScopeData['ruleNodeTypes'][$changedRuleClass] ?? [] as $nodeType) { + $ruleNodeTypes[$nodeType] = true; + } + } + + $alreadyAnalysed = array_fill_keys($filesToAnalyse, true); + $matchesByNodeType = []; + $ruleScopedFiles = []; + foreach ($allAnalysedFiles as $analysedFile) { + if (isset($alreadyAnalysed[$analysedFile])) { + continue; + } + + if (!array_key_exists($analysedFile, $ruleScopeData['nodeTypes'])) { + // nothing recorded about the file, so it might need the rule + $ruleScopedFiles[] = $analysedFile; + continue; + } + + foreach ($ruleScopeData['nodeTypes'][$analysedFile] as $fileNodeType) { + if (!array_key_exists($fileNodeType, $matchesByNodeType)) { + $matchesByNodeType[$fileNodeType] = false; + foreach (array_keys($ruleNodeTypes) as $ruleNodeType) { + if (is_a($fileNodeType, $ruleNodeType, true)) { + $matchesByNodeType[$fileNodeType] = true; + break; + } + } + } + + if (!$matchesByNodeType[$fileNodeType]) { + continue; + } + + $ruleScopedFiles[] = $analysedFile; + break; + } + } + + return $ruleScopedFiles; + } + + /** + * Replaces the cached errors of the changed rules with the fresh ones, and matches the local + * ignores of each rule-scoped file again against its cached and fresh errors - in those files + * only the changed rules ran, so the analysis could not match them itself. + * + * @param array $fullyReanalysedFiles + * @param array> $errorsByFile + * @param array> $locallyIgnoredErrorsByFile + * @param array $linesToIgnore + * @param array $unmatchedLineIgnores + * @return array{array>, array>, array, array} + */ + private function mergeRuleScopedErrors( + ResultCache $resultCache, + array $fullyReanalysedFiles, + array $errorsByFile, + array $locallyIgnoredErrorsByFile, + array $linesToIgnore, + array $unmatchedLineIgnores, + AnalyserResult $analyserResult, + ): array + { + $changedRuleClasses = array_fill_keys($resultCache->getChangedRuleClasses(), true); + $fullyReanalysed = array_fill_keys($fullyReanalysedFiles, true); + $ruleScoped = array_fill_keys($resultCache->getRuleScopedFiles(), true); + + // a rule can report an error in another file, so the old errors go from every file + $withoutChangedRules = static function (array $errorsByFile) use ($changedRuleClasses, $fullyReanalysed): array { + foreach ($errorsByFile as $file => $errors) { + if (isset($fullyReanalysed[$file])) { + continue; + } + + $kept = []; + foreach ($errors as $error) { + if ($error->getRuleClass() !== null && isset($changedRuleClasses[$error->getRuleClass()])) { + continue; + } + + $kept[] = $error; + } + + if ($kept === []) { + unset($errorsByFile[$file]); + continue; + } + + $errorsByFile[$file] = $kept; + } + + return $errorsByFile; + }; + $errorsByFile = $withoutChangedRules($errorsByFile); + $locallyIgnoredErrorsByFile = $withoutChangedRules($locallyIgnoredErrorsByFile); + + // the rule-scoped files reported only the changed rules' errors, the rest of what they + // reported is cached + $freshErrorsByFile = []; + foreach ($analyserResult->getErrors() as $error) { + if ($error->getRuleClass() === null || !isset($changedRuleClasses[$error->getRuleClass()])) { + continue; + } + + $file = $error->getFilePath(); + if (isset($fullyReanalysed[$file])) { + continue; + } + + $freshErrorsByFile[$file][] = $error; + } + + $freshLinesToIgnore = $analyserResult->getLinesToIgnore(); + $freshUnmatchedLineIgnores = $analyserResult->getUnmatchedLineIgnores(); + foreach (array_keys($ruleScoped) as $file) { + // the errors another file's analysis reported here were never matched against this + // file's ignores, so they stay as they are + $errors = []; + $foreignErrors = []; + foreach ([$errorsByFile[$file] ?? [], $locallyIgnoredErrorsByFile[$file] ?? [], $freshErrorsByFile[$file] ?? []] as $someErrors) { + foreach ($someErrors as $error) { + if ($error->getOriginFile() !== null) { + $foreignErrors[] = $error; + continue; + } + + $errors[] = $error; + } + } + unset($freshErrorsByFile[$file]); + + $result = $this->localIgnoresProcessor->process($errors, $freshLinesToIgnore[$file] ?? [], $freshUnmatchedLineIgnores[$file] ?? []); + + $fileErrors = $result->getFileErrors(); + foreach ($foreignErrors as $foreignError) { + $fileErrors[] = $foreignError; + } + if ($fileErrors === []) { + unset($errorsByFile[$file]); + } else { + $errorsByFile[$file] = $fileErrors; + } + + $fileLocallyIgnoredErrors = $result->getLocallyIgnoredErrors(); + if ($fileLocallyIgnoredErrors === []) { + unset($locallyIgnoredErrorsByFile[$file]); + } else { + $locallyIgnoredErrorsByFile[$file] = $fileLocallyIgnoredErrors; + } + + $linesToIgnore[$file] = array_filter($result->getLinesToIgnore(), static fn (array $lines): bool => $lines !== []); + if ($linesToIgnore[$file] === []) { + unset($linesToIgnore[$file]); + } + $unmatchedLineIgnores[$file] = array_filter($result->getUnmatchedLineIgnores(), static fn (array $lines): bool => $lines !== []); + if ($unmatchedLineIgnores[$file] !== []) { + continue; + } + + unset($unmatchedLineIgnores[$file]); + } + + // errors the changed rules reported in files that were not re-analysed + foreach ($freshErrorsByFile as $file => $errors) { + foreach ($errors as $error) { + $errorsByFile[$file][] = $error; + } + } + + return [$errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores]; + } + + /** + * @param array> $freshNodeTypes + * @return array> + */ + private function mergeNodeTypes(ResultCache $resultCache, array $freshNodeTypes): array + { + $nodeTypes = []; + $currentFileHashes = $resultCache->getCurrentFileHashes(); + foreach ($resultCache->getNodeTypes() as $file => $fileNodeTypes) { + if (!array_key_exists($file, $currentFileHashes)) { + continue; + } + + $nodeTypes[$file] = $fileNodeTypes; + } + + foreach ($resultCache->getFilesToAnalyse() as $file) { + if (!array_key_exists($file, $freshNodeTypes)) { + unset($nodeTypes[$file]); + continue; + } + + $nodeTypes[$file] = $freshNodeTypes[$file]; + } + + return $nodeTypes; + } + /** * @param string[] $allAnalysedFiles * @param mixed[]|null $projectConfigArray diff --git a/src/Analyser/ResultCache/RuleScopeCodec.php b/src/Analyser/ResultCache/RuleScopeCodec.php new file mode 100644 index 00000000000..4791e9a9d5b --- /dev/null +++ b/src/Analyser/ResultCache/RuleScopeCodec.php @@ -0,0 +1,82 @@ +> $nodeTypes + * @param array> $ruleNodeTypes + * @return array{nodeClasses: list, nodeTypes: array>, ruleNodeTypes: array>} + */ + public static function encode(array $nodeTypes, array $ruleNodeTypes): array + { + $ids = []; + $nodeClasses = []; + $encodedNodeTypes = []; + foreach ($nodeTypes as $file => $fileNodeTypes) { + $fileNodeTypeIds = []; + foreach ($fileNodeTypes as $nodeType) { + if (!isset($ids[$nodeType])) { + $ids[$nodeType] = count($nodeClasses); + $nodeClasses[] = $nodeType; + } + $fileNodeTypeIds[] = $ids[$nodeType]; + } + $encodedNodeTypes[$file] = $fileNodeTypeIds; + } + + return ['nodeClasses' => $nodeClasses, 'nodeTypes' => $encodedNodeTypes, 'ruleNodeTypes' => $ruleNodeTypes]; + } + + /** + * @param mixed $ruleScope + * @return array{nodeTypes: array>, ruleNodeTypes: array>} + * @throws RuntimeException when the section is malformed + */ + public static function decode($ruleScope): array + { + if ( + !is_array($ruleScope) + || !is_array($ruleScope['nodeClasses'] ?? null) + || !is_array($ruleScope['nodeTypes'] ?? null) + || !is_array($ruleScope['ruleNodeTypes'] ?? null) + ) { + throw new RuntimeException('The rule scope is malformed.'); + } + + $nodeClasses = $ruleScope['nodeClasses']; + $nodeTypes = []; + foreach ($ruleScope['nodeTypes'] as $file => $fileNodeTypeIds) { + if (!is_array($fileNodeTypeIds)) { + throw new RuntimeException('The rule scope is malformed.'); + } + + $fileNodeTypes = []; + foreach ($fileNodeTypeIds as $nodeTypeId) { + if (!is_int($nodeTypeId) || !isset($nodeClasses[$nodeTypeId])) { + throw new RuntimeException('The rule scope is malformed.'); + } + $fileNodeTypes[] = $nodeClasses[$nodeTypeId]; + } + $nodeTypes[$file] = $fileNodeTypes; + } + + return ['nodeTypes' => $nodeTypes, 'ruleNodeTypes' => $ruleScope['ruleNodeTypes']]; + } + +} diff --git a/src/Analyser/RuleScopedAnalysis.php b/src/Analyser/RuleScopedAnalysis.php new file mode 100644 index 00000000000..19fe913d68b --- /dev/null +++ b/src/Analyser/RuleScopedAnalysis.php @@ -0,0 +1,53 @@ + */ + private array $ruleClasses = []; + + /** @var array */ + private array $files = []; + + /** + * @param list $ruleClasses + * @param list $files + */ + public function set(array $ruleClasses, array $files): void + { + $this->ruleClasses = []; + foreach ($ruleClasses as $ruleClass) { + $this->ruleClasses[$ruleClass] = true; + } + $this->files = []; + foreach ($files as $file) { + $this->files[$file] = true; + } + } + + public function isScoped(string $file): bool + { + return isset($this->files[$file]); + } + + /** + * @return array + */ + public function getRuleClasses(): array + { + return $this->ruleClasses; + } + +} diff --git a/src/Analyser/RuleScopedRegistry.php b/src/Analyser/RuleScopedRegistry.php new file mode 100644 index 00000000000..8aff81980aa --- /dev/null +++ b/src/Analyser/RuleScopedRegistry.php @@ -0,0 +1,44 @@ +> */ + private array $cache = []; + + /** + * @param array $ruleClasses + */ + public function __construct(private Registry $registry, private array $ruleClasses) + { + } + + #[Override] + public function getRules(string $nodeType): array + { + if (!isset($this->cache[$nodeType])) { + $rules = []; + foreach ($this->registry->getRules($nodeType) as $rule) { + if (!isset($this->ruleClasses[get_class($rule)])) { + continue; + } + + $rules[] = $rule; + } + + $this->cache[$nodeType] = $rules; + } + + return $this->cache[$nodeType]; + } + +} diff --git a/src/Command/AnalyseApplication.php b/src/Command/AnalyseApplication.php index f5868057130..910412c4bed 100644 --- a/src/Command/AnalyseApplication.php +++ b/src/Command/AnalyseApplication.php @@ -8,6 +8,7 @@ use PHPStan\Analyser\FileAnalyserResult; use PHPStan\Analyser\Ignore\IgnoredErrorHelper; use PHPStan\Analyser\ResultCache\ResultCacheManagerFactory; +use PHPStan\Analyser\RuleScopedAnalysis; use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Collectors\CollectedData; use PHPStan\DependencyInjection\AutowiredService; @@ -44,6 +45,7 @@ public function __construct( private ResultCacheManagerFactory $resultCacheManagerFactory, private IgnoredErrorHelper $ignoredErrorHelper, private StubFilesProvider $stubFilesProvider, + private RuleScopedAnalysis $ruleScopedAnalysis, ) { } @@ -90,6 +92,8 @@ public function analyse( } } else { $resultCache = $resultCacheManager->restore($files, $debug, $onlyFiles, $projectConfigArray, $errorOutput); + // the in-process analysis reads it here, the parallel workers get it with each job + $this->ruleScopedAnalysis->set($resultCache->getChangedRuleClasses(), $resultCache->getRuleScopedFiles()); $intermediateAnalyserResult = $this->runAnalyser( $resultCache->getFilesToAnalyse(), $files, @@ -129,6 +133,7 @@ public function analyse( peakMemoryUsageBytes: $intermediateAnalyserResult->getPeakMemoryUsageBytes(), processedFiles: $intermediateAnalyserResult->getProcessedFiles(), workerCount: $intermediateAnalyserResult->getWorkerCount(), + nodeTypes: $intermediateAnalyserResult->getNodeTypes(), ); } @@ -354,6 +359,7 @@ private function switchTmpFileInAnalyserResult( peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), processedFiles: $analyserResult->getProcessedFiles(), workerCount: $analyserResult->getWorkerCount(), + nodeTypes: $analyserResult->getNodeTypes(), ); } diff --git a/src/Parallel/ParallelAnalyser.php b/src/Parallel/ParallelAnalyser.php index c1e07733662..c2bbabe1146 100644 --- a/src/Parallel/ParallelAnalyser.php +++ b/src/Parallel/ParallelAnalyser.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\AnalyserResult; use PHPStan\Analyser\Error; use PHPStan\Analyser\InternalError; +use PHPStan\Analyser\RuleScopedAnalysis; use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Cache\ArenaCache; use PHPStan\Command\CommandHelper; @@ -27,6 +28,7 @@ use React\Socket\TcpServer; use Symfony\Component\Console\Input\InputInterface; use Throwable; +use function array_keys; use function array_map; use function array_pop; use function array_reverse; @@ -61,11 +63,39 @@ public function __construct( private ForkParallelChecker $forkParallelChecker, private PreForkDirectorySymbolScanner $preForkDirectorySymbolScanner, private WorkerRunner $workerRunner, + private RuleScopedAnalysis $ruleScopedAnalysis, ) { $this->processTimeout = max($processTimeout, self::DEFAULT_TIMEOUT); } + /** + * @param array $files + * @return array{action: 'analyse', files: array, ruleScope?: array{ruleClasses: list, files: list}} + */ + private function createAnalyseRequest(array $files): array + { + $request = ['action' => 'analyse', 'files' => $files]; + $ruleScopedFiles = []; + foreach ($files as $file) { + if (!$this->ruleScopedAnalysis->isScoped($file)) { + continue; + } + + $ruleScopedFiles[] = $file; + } + + if ($ruleScopedFiles !== []) { + // the files that only the changed rules re-analyse - see RuleScopedAnalysis + $request['ruleScope'] = [ + 'ruleClasses' => array_keys($this->ruleScopedAnalysis->getRuleClasses()), + 'files' => $ruleScopedFiles, + ]; + } + + return $request; + } + /** * @param string[] $allAnalysedFiles * @param Closure(int, list=): void|null $postFileCallback @@ -87,7 +117,11 @@ public function analyse( ?Output $errorOutput, ): PromiseInterface { - $jobs = array_reverse($schedule->getJobs()); + // each job travels to a worker as the request that analyses it + $jobs = []; + foreach (array_reverse($schedule->getJobs()) as $job) { + $jobs[] = $this->createAnalyseRequest($job); + } $numberOfProcesses = $schedule->getNumberOfProcesses(); @@ -129,6 +163,8 @@ public function analyse( $exportedNodes = []; /** @var list $allProcessedFiles */ $allProcessedFiles = []; + /** @var array> $nodeTypes */ + $nodeTypes = []; /** @var Deferred $deferred */ $deferred = new Deferred(); @@ -136,7 +172,7 @@ public function analyse( $useFork = $this->forkParallelChecker->isSupported(); $server = new TcpServer('127.0.0.1:0', $loop); - $this->processPool = new ProcessPool($server, static function () use ($deferred, &$jobs, &$internalErrors, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$valueDependencies, &$packageDependencies, &$exportedNodes, &$peakMemoryUsages, &$allProcessedFiles, $arenaName): void { + $this->processPool = new ProcessPool($server, static function () use ($deferred, &$jobs, &$internalErrors, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$valueDependencies, &$packageDependencies, &$exportedNodes, &$peakMemoryUsages, &$allProcessedFiles, &$nodeTypes, $arenaName): void { if ($arenaName !== null) { ArenaCache::destroy(); } @@ -173,6 +209,7 @@ public function analyse( peakMemoryUsageBytes: $peakMemoryUsages === [] ? 0 : max($peakMemoryUsages), processedFiles: $allProcessedFiles, workerCount: count($peakMemoryUsages), + nodeTypes: $nodeTypes, )); }); $server->on('connection', function (ConnectionInterface $connection) use (&$jobs, $arenaName, $expectedWorkerCount, &$helloCount, $errorOutput, $useFork): void { @@ -217,7 +254,7 @@ public function analyse( } $job = array_pop($jobs); - $process->request(['action' => 'analyse', 'files' => $job]); + $process->request($job); }); }); /** @var string $serverAddress */ @@ -287,7 +324,7 @@ public function analyse( $insteadOfFile, $input, ); - $process->start(function (array $json) use ($process, &$internalErrors, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$valueDependencies, &$packageDependencies, &$exportedNodes, &$peakMemoryUsages, &$jobs, $postFileCallback, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, $processIdentifier, $onFileAnalysisHandler, &$allProcessedFiles): void { + $process->start(function (array $json) use ($process, &$internalErrors, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$valueDependencies, &$packageDependencies, &$exportedNodes, &$peakMemoryUsages, &$jobs, $postFileCallback, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, $processIdentifier, $onFileAnalysisHandler, &$allProcessedFiles, &$nodeTypes): void { $fileErrors = []; foreach ($json['errors'] as $jsonError) { $fileErrors[] = Error::decode($jsonError); @@ -391,6 +428,10 @@ public function analyse( $allProcessedFiles[] = $processedFile; } + foreach ($json['nodeTypes'] ?? [] as $file => $fileNodeTypes) { + $nodeTypes[$file] = $fileNodeTypes; + } + if ($postFileCallback !== null) { $postFileCallback(count($json['files']), $json['processedFiles']); } @@ -411,7 +452,7 @@ public function analyse( } $job = array_pop($jobs); - $process->request(['action' => 'analyse', 'files' => $job]); + $process->request($job); }, $handleError, function ($exitCode, string $output, ?int $termSignal) use (&$someChildEnded, &$internalErrors, &$internalErrorsCount, $processIdentifier): void { // The main process is not sampled here any more: its own peak comes // later (collecting the workers' results, saving the result cache) and diff --git a/src/Parallel/WorkerRunner.php b/src/Parallel/WorkerRunner.php index 3a3f4e466a9..6e7ac9c636b 100644 --- a/src/Parallel/WorkerRunner.php +++ b/src/Parallel/WorkerRunner.php @@ -7,6 +7,7 @@ use PHPStan\Analyser\FileAnalyser; use PHPStan\Analyser\InternalError; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\RuleScopedAnalysis; use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Collectors\Registry as CollectorRegistry; use PHPStan\Command\BootstrapFilesRunner; @@ -60,6 +61,7 @@ public function __construct( private NodeScopeResolver $nodeScopeResolver, #[AutowiredParameter(ref: '%parallel.buffer%')] private int $decoderBufferSize, + private RuleScopedAnalysis $ruleScopedAnalysis, ) { } @@ -190,12 +192,16 @@ private function runWorker( $fileAnalyser = $this->fileAnalyser; $ruleRegistry = $this->ruleRegistry; $collectorRegistry = $this->collectorRegistry; - $in->on('data', static function (array $json) use ($fileAnalyser, $ruleRegistry, $collectorRegistry, $out, $analysedFiles, $tmpFile, $insteadOfFile): void { + $ruleScopedAnalysis = $this->ruleScopedAnalysis; + $in->on('data', static function (array $json) use ($fileAnalyser, $ruleRegistry, $collectorRegistry, $ruleScopedAnalysis, $out, $analysedFiles, $tmpFile, $insteadOfFile): void { $action = $json['action']; if ($action !== 'analyse') { return; } + // which of the job's files only the changed rules re-analyse - see ParallelAnalyser + $ruleScopedAnalysis->set($json['ruleScope']['ruleClasses'] ?? [], $json['ruleScope']['files'] ?? []); + $internalErrorsCount = 0; $files = $json['files']; $errors = []; @@ -212,6 +218,7 @@ private function runWorker( $packageDependencies = []; $exportedNodes = []; $processedFiles = []; + $nodeTypes = []; foreach ($files as $file) { try { if ($file === $insteadOfFile) { @@ -228,6 +235,7 @@ private function runWorker( $valueDependencies = ValueDependencyCollector::merge($valueDependencies, $fileAnalyserResult->getValueDependencies()); $packageDependencies[$file] = $fileAnalyserResult->getPackageDependencies(); $exportedNodes[$file] = $fileAnalyserResult->getExportedNodes(); + $nodeTypes[$file] = $fileAnalyserResult->getNodeTypes(); $processedFiles = array_merge($processedFiles, $fileAnalyserResult->getProcessedFiles()); foreach ($fileErrors as $fileError) { $errors[] = $fileError; @@ -273,6 +281,7 @@ private function runWorker( 'exportedNodes' => $exportedNodes, 'files' => $files, 'processedFiles' => $processedFiles, + 'nodeTypes' => $nodeTypes, 'internalErrorsCount' => $internalErrorsCount, ]]); }); diff --git a/tests/PHPStan/Analyser/ResultCache/RuleScopeCodecTest.php b/tests/PHPStan/Analyser/ResultCache/RuleScopeCodecTest.php new file mode 100644 index 00000000000..f5f22c70f7b --- /dev/null +++ b/tests/PHPStan/Analyser/ResultCache/RuleScopeCodecTest.php @@ -0,0 +1,87 @@ + ['PhpParser\Node\Stmt\Class_', 'PhpParser\Node\Expr\New_'], + 'src/B.php' => ['PhpParser\Node\Expr\New_'], + 'src/C.php' => [], + ], + ['App\Rules\NewRule' => ['PhpParser\Node\Expr\New_']], + ); + + $this->assertSame([ + 'nodeClasses' => ['PhpParser\Node\Stmt\Class_', 'PhpParser\Node\Expr\New_'], + 'nodeTypes' => [ + 'src/A.php' => [0, 1], + 'src/B.php' => [1], + 'src/C.php' => [], + ], + 'ruleNodeTypes' => ['App\Rules\NewRule' => ['PhpParser\Node\Expr\New_']], + ], $encoded); + } + + public function testRoundTrip(): void + { + $nodeTypes = [ + 'src/A.php' => ['PhpParser\Node\Stmt\Class_', 'PhpParser\Node\Expr\New_'], + 'src/B.php' => ['PhpParser\Node\Expr\New_', 'PHPStan\Node\InClassNode'], + 'src/C.php' => [], + ]; + $ruleNodeTypes = [ + 'App\Rules\NewRule' => ['PhpParser\Node\Expr\New_'], + 'App\Rules\TwoNodesRule' => ['PhpParser\Node\Stmt\Class_', 'PHPStan\Node\InClassNode'], + ]; + + $this->assertSame( + ['nodeTypes' => $nodeTypes, 'ruleNodeTypes' => $ruleNodeTypes], + RuleScopeCodec::decode(RuleScopeCodec::encode($nodeTypes, $ruleNodeTypes)), + ); + } + + public function testEmptyRoundTrip(): void + { + $this->assertSame( + ['nodeTypes' => [], 'ruleNodeTypes' => []], + RuleScopeCodec::decode(RuleScopeCodec::encode([], [])), + ); + } + + /** + * @return iterable + */ + public static function dataMalformed(): iterable + { + yield 'not an array' => [null]; + yield 'no class table' => [['nodeTypes' => [], 'ruleNodeTypes' => []]]; + yield 'no node types' => [['nodeClasses' => [], 'ruleNodeTypes' => []]]; + yield 'no rule node types' => [['nodeClasses' => [], 'nodeTypes' => []]]; + yield 'node types of a file are not a list' => [['nodeClasses' => ['Foo'], 'nodeTypes' => ['src/A.php' => 0], 'ruleNodeTypes' => []]]; + yield 'position past the class table' => [['nodeClasses' => ['Foo'], 'nodeTypes' => ['src/A.php' => [0, 1]], 'ruleNodeTypes' => []]]; + yield 'position is not an integer' => [['nodeClasses' => ['Foo'], 'nodeTypes' => ['src/A.php' => ['0']], 'ruleNodeTypes' => []]]; + yield 'class names in place of positions' => [['nodeTypes' => ['src/A.php' => ['Foo']], 'ruleNodeTypes' => []]]; + } + + /** + * @param mixed $ruleScope + */ + #[DataProvider('dataMalformed')] + public function testMalformed($ruleScope): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The rule scope is malformed.'); + + RuleScopeCodec::decode($ruleScope); + } + +}