From d8bd33abf09017549c884b5076def06eca5ac8f1 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Sun, 11 Oct 2026 22:15:37 +0200 Subject: [PATCH 1/7] Re-run only a changed rule on the files that need it When an extension file that changed is a class that implements only Rule, the result cache no longer starts a full analysis. It re-analyses only the files that passed a node of the rule's old or new type to the rules, and in these files only that rule runs. - Error records the class of the rule that reported it, and the analysed file when the error is in another file. - FileAnalyserCallback records the node classes of each analysed file, and the result cache stores them with the node types of each rule. - In a rule-scoped file, FileAnalyser runs only the changed rules, no collectors, and leaves the local ignores to the result cache. - ResultCacheManager::process() keeps the cached data of the rule-scoped files, replaces the errors of the changed rules and matches the line ignores of each rule-scoped file again. The parallel workers get the rule scope from a file that an environment variable names. This is a prototype for phpstan/phpstan#15460. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Analyser/Analyser.php | 3 + src/Analyser/AnalyserResult.php | 10 + src/Analyser/AnalyserResultFinalizer.php | 3 + src/Analyser/Error.php | 88 +++++ src/Analyser/FileAnalyser.php | 18 + src/Analyser/FileAnalyserCallback.php | 33 +- src/Analyser/FileAnalyserResult.php | 11 + src/Analyser/ResultCache/ResultCache.php | 76 ++++ .../ResultCache/ResultCacheManager.php | 366 +++++++++++++++++- src/Analyser/RuleScopedAnalysis.php | 97 +++++ src/Analyser/RuleScopedRegistry.php | 44 +++ src/Command/AnalyseApplication.php | 25 ++ src/Parallel/ParallelAnalyser.php | 11 +- src/Parallel/WorkerRunner.php | 3 + 14 files changed, 778 insertions(+), 10 deletions(-) create mode 100644 src/Analyser/RuleScopedAnalysis.php create mode 100644 src/Analyser/RuleScopedRegistry.php diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 2de73b41052..11f71027a3d 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -76,6 +76,7 @@ public function analyse( $valueDependencies = ['values' => [], '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..b9894d21a60 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)] @@ -233,6 +241,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 +411,13 @@ 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 (is_array($data['ruleScope'] ?? null)) { + $ruleScopeData = [ + 'nodeTypes' => $transformer->absolutizeFileKeyed($data['ruleScope']['nodeTypes']), + 'ruleNodeTypes' => $data['ruleScope']['ruleNodeTypes'], + ]; + } try { $cachedValueDependencies = $this->decodeValueDependencies($data['valueDependencies'] ?? ['paths' => [], 'entries' => []], $transformer); } catch (Throwable $e) { @@ -627,6 +645,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 +654,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 +669,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 +1062,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 +1111,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 +1145,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 +1294,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 +1393,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 +1408,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 +1424,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 +1434,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 +1449,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 +1484,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 +1947,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 +1966,7 @@ private function save( array $currentFileHashes, array $meta, array $stubFiles, + array $nodeTypes, ): void { $invertedDependencies = []; @@ -1968,6 +2051,11 @@ private function save( unset($invertedDependencies); $packageDependencies = $transformer->relativizeFileKeyed($packageDependencies); $projectExtensionFiles = $transformer->relativizeFileKeyed($projectExtensionFiles); + ksort($nodeTypes); + $ruleScope = [ + 'nodeTypes' => $transformer->relativizeFileKeyed($nodeTypes), + 'ruleNodeTypes' => $this->getRuleNodeTypes(), + ]; $file = $this->cacheFilePath; @@ -2010,6 +2098,7 @@ private function save( $this->writeValueFrame($handle, $file, 'dependencyGraph', $dependencyGraph); $this->writeValueFrame($handle, $file, 'valueDependencies', $encodedValueDependencies); $this->writeArrayFrame($handle, $file, 'packageDependencies', $packageDependencies); + $this->writeValueFrame($handle, $file, 'ruleScope', $ruleScope); $this->writeExportedNodes($handle, $file, $exportedNodes, $cachedExportedNodes, $transformer); fclose($handle); $closed = true; @@ -2731,6 +2820,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/RuleScopedAnalysis.php b/src/Analyser/RuleScopedAnalysis.php new file mode 100644 index 00000000000..a12c2480826 --- /dev/null +++ b/src/Analyser/RuleScopedAnalysis.php @@ -0,0 +1,97 @@ + */ + private array $ruleClasses = []; + + /** @var array */ + private array $files = []; + + private bool $loaded = false; + + /** + * @param list $ruleClasses + * @param list $files + */ + public function set(array $ruleClasses, array $files): void + { + $this->loaded = true; + $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 + { + $this->load(); + + return isset($this->files[$file]); + } + + /** + * @return array + */ + public function getRuleClasses(): array + { + $this->load(); + + return $this->ruleClasses; + } + + private function load(): void + { + if ($this->loaded) { + return; + } + + $this->loaded = true; + $path = getenv(self::ENVIRONMENT_VARIABLE); + if (in_array($path, [false, ''], true)) { + return; + } + + $contents = @file_get_contents($path); + if ($contents === false) { + return; + } + + $data = json_decode($contents, true); + if (!is_array($data) || !is_array($data['ruleClasses'] ?? null) || !is_array($data['files'] ?? null)) { + return; + } + + $this->set( + array_values(array_filter($data['ruleClasses'], 'is_string')), + array_values(array_filter($data['files'], 'is_string')), + ); + } + +} 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..684c3fb08c1 100644 --- a/src/Command/AnalyseApplication.php +++ b/src/Command/AnalyseApplication.php @@ -2,12 +2,14 @@ namespace PHPStan\Command; +use Nette\Utils\Json; use PHPStan\Analyser\AnalyserResult; use PHPStan\Analyser\AnalyserResultFinalizer; use PHPStan\Analyser\Error; 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; @@ -22,10 +24,15 @@ use function fclose; use function feof; use function fgets; +use function file_put_contents; use function fopen; use function memory_get_peak_usage; use function microtime; +use function putenv; use function sprintf; +use function sys_get_temp_dir; +use function uniqid; +use function unlink; /** * @phpstan-import-type CollectorData from CollectedData @@ -44,6 +51,7 @@ public function __construct( private ResultCacheManagerFactory $resultCacheManagerFactory, private IgnoredErrorHelper $ignoredErrorHelper, private StubFilesProvider $stubFilesProvider, + private RuleScopedAnalysis $ruleScopedAnalysis, ) { } @@ -90,6 +98,17 @@ public function analyse( } } else { $resultCache = $resultCacheManager->restore($files, $debug, $onlyFiles, $projectConfigArray, $errorOutput); + $ruleScopeFile = null; + if ($resultCache->getRuleScopedFiles() !== []) { + $this->ruleScopedAnalysis->set($resultCache->getChangedRuleClasses(), $resultCache->getRuleScopedFiles()); + // the parallel workers - spawned or forked - inherit the environment + $ruleScopeFile = sprintf('%s/phpstan-rule-scope-%s.json', sys_get_temp_dir(), uniqid()); + file_put_contents($ruleScopeFile, Json::encode([ + 'ruleClasses' => $resultCache->getChangedRuleClasses(), + 'files' => $resultCache->getRuleScopedFiles(), + ])); + putenv(sprintf('%s=%s', RuleScopedAnalysis::ENVIRONMENT_VARIABLE, $ruleScopeFile)); + } $intermediateAnalyserResult = $this->runAnalyser( $resultCache->getFilesToAnalyse(), $files, @@ -101,6 +120,10 @@ public function analyse( $errorOutput, $input, ); + if ($ruleScopeFile !== null) { + putenv(RuleScopedAnalysis::ENVIRONMENT_VARIABLE); + @unlink($ruleScopeFile); + } $projectStubFiles = $this->stubFilesProvider->getProjectStubFiles(); @@ -129,6 +152,7 @@ public function analyse( peakMemoryUsageBytes: $intermediateAnalyserResult->getPeakMemoryUsageBytes(), processedFiles: $intermediateAnalyserResult->getProcessedFiles(), workerCount: $intermediateAnalyserResult->getWorkerCount(), + nodeTypes: $intermediateAnalyserResult->getNodeTypes(), ); } @@ -354,6 +378,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..65cfd60fb06 100644 --- a/src/Parallel/ParallelAnalyser.php +++ b/src/Parallel/ParallelAnalyser.php @@ -129,6 +129,8 @@ public function analyse( $exportedNodes = []; /** @var list $allProcessedFiles */ $allProcessedFiles = []; + /** @var array> $nodeTypes */ + $nodeTypes = []; /** @var Deferred $deferred */ $deferred = new Deferred(); @@ -136,7 +138,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 +175,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 { @@ -287,7 +290,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 +394,10 @@ public function analyse( $allProcessedFiles[] = $processedFile; } + foreach ($json['nodeTypes'] ?? [] as $file => $fileNodeTypes) { + $nodeTypes[$file] = $fileNodeTypes; + } + if ($postFileCallback !== null) { $postFileCallback(count($json['files']), $json['processedFiles']); } diff --git a/src/Parallel/WorkerRunner.php b/src/Parallel/WorkerRunner.php index 3a3f4e466a9..c5d867db897 100644 --- a/src/Parallel/WorkerRunner.php +++ b/src/Parallel/WorkerRunner.php @@ -212,6 +212,7 @@ private function runWorker( $packageDependencies = []; $exportedNodes = []; $processedFiles = []; + $nodeTypes = []; foreach ($files as $file) { try { if ($file === $insteadOfFile) { @@ -228,6 +229,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 +275,7 @@ private function runWorker( 'exportedNodes' => $exportedNodes, 'files' => $files, 'processedFiles' => $processedFiles, + 'nodeTypes' => $nodeTypes, 'internalErrorsCount' => $internalErrorsCount, ]]); }); From 7958e0318caf203b2af5b69469e3f7a345560bc7 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Sun, 11 Oct 2026 22:23:23 +0200 Subject: [PATCH 2/7] Store node types only for the project's own rules, and update the e2e tests Only a rule of the project itself can change without a full analysis, so the result cache now stores node types only for those rules. Before, it stored them for every registered rule, which made the cache file of e2e/result-cache-truncated four times larger and moved where its cut lands. An edited project rule is now re-run instead of starting a full analysis, so the result-cache-extension e2e tests check for that. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/e2e-tests.yml | 13 ++++++++----- src/Analyser/ResultCache/ResultCacheManager.php | 14 +++++++++++++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b0f15a8a69d..41a6174902e 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1455,28 +1455,30 @@ jobs: composer install # The rule comes from a package Composer symlinked out of the project, so the file the # class reflects to is the source directory, outside vendor/ and outside the analysed - # paths. Editing it has to invalidate the cache: the rule decides what every file reports. + # paths. Editing it has to re-run the rule on every file it can report on. OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") echo "$OUTPUT" ../bashunit -a contains 'the rule says one thing' "$OUTPUT" patch -p0 < patch.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 'Changed rule: ' "$OUTPUT" + ../bashunit -a not_contains 'Result cache not used' "$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-extension-autoload-dev composer install # The rule lives in a directory that is only in autoload-dev and is not analysed, so - # nothing in the dependency graph points at it. Editing it still has to invalidate. + # nothing in the dependency graph points at it. Editing it still has to re-run the rule. OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") echo "$OUTPUT" ../bashunit -a contains 'the rule says one thing' "$OUTPUT" patch -p0 < patch.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 'Changed rule: ' "$OUTPUT" + ../bashunit -a not_contains 'Result cache not used' "$OUTPUT" ../bashunit -a contains 'the rule says another thing' "$OUTPUT" ../bashunit -a not_contains 'the rule says one thing' "$OUTPUT" - script: | @@ -1491,7 +1493,8 @@ jobs: patch -p0 < patch.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 'Changed rule: ' "$OUTPUT" + ../bashunit -a not_contains 'Result cache not used' "$OUTPUT" ../bashunit -a contains 'the rule says another thing' "$OUTPUT" ../bashunit -a not_contains 'the rule says one thing' "$OUTPUT" - script: | diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index b9894d21a60..7b318e93fe2 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -2050,11 +2050,23 @@ private function save( $encodedValueDependencies = $this->encodeValueDependencies($valueDependencies, $transformer); unset($invertedDependencies); $packageDependencies = $transformer->relativizeFileKeyed($packageDependencies); + // 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); $projectExtensionFiles = $transformer->relativizeFileKeyed($projectExtensionFiles); ksort($nodeTypes); $ruleScope = [ 'nodeTypes' => $transformer->relativizeFileKeyed($nodeTypes), - 'ruleNodeTypes' => $this->getRuleNodeTypes(), + 'ruleNodeTypes' => $projectRuleNodeTypes, ]; $file = $this->cacheFilePath; From df03c93eb010189e45791615416db9d38446d1fb Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Sun, 11 Oct 2026 22:38:45 +0200 Subject: [PATCH 3/7] Send the rule scope to the workers with each job The main process now adds the changed rules and the job's rule-scoped files to each job request, and the worker sets them before it analyses the job. This replaces the environment variable and the temporary file, and works the same for spawned and forked workers. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Analyser/RuleScopedAnalysis.php | 48 ++--------------------------- src/Command/AnalyseApplication.php | 23 ++------------ src/Parallel/ParallelAnalyser.php | 40 ++++++++++++++++++++++-- src/Parallel/WorkerRunner.php | 8 ++++- 4 files changed, 48 insertions(+), 71 deletions(-) diff --git a/src/Analyser/RuleScopedAnalysis.php b/src/Analyser/RuleScopedAnalysis.php index a12c2480826..19fe913d68b 100644 --- a/src/Analyser/RuleScopedAnalysis.php +++ b/src/Analyser/RuleScopedAnalysis.php @@ -3,42 +3,30 @@ namespace PHPStan\Analyser; use PHPStan\DependencyInjection\AutowiredService; -use function array_filter; -use function array_values; -use function file_get_contents; -use function getenv; -use function in_array; -use function is_array; -use function json_decode; /** * Files that are re-analysed only because a rule changed. In them only the changed rules run, no - * collector runs, and the local ignores are matched later by the result cache, which knows the + * collector runs, and the result cache matches the local ignores later, because it knows the * cached errors of the other rules. * - * Parallel workers read the state from the file that the environment variable points at. + * The main process sets it from the result cache; a parallel worker sets it from each job it gets. */ #[AutowiredService] final class RuleScopedAnalysis { - public const ENVIRONMENT_VARIABLE = 'PHPSTAN_RULE_SCOPED_ANALYSIS'; - /** @var array */ private array $ruleClasses = []; /** @var array */ private array $files = []; - private bool $loaded = false; - /** * @param list $ruleClasses * @param list $files */ public function set(array $ruleClasses, array $files): void { - $this->loaded = true; $this->ruleClasses = []; foreach ($ruleClasses as $ruleClass) { $this->ruleClasses[$ruleClass] = true; @@ -51,8 +39,6 @@ public function set(array $ruleClasses, array $files): void public function isScoped(string $file): bool { - $this->load(); - return isset($this->files[$file]); } @@ -61,37 +47,7 @@ public function isScoped(string $file): bool */ public function getRuleClasses(): array { - $this->load(); - return $this->ruleClasses; } - private function load(): void - { - if ($this->loaded) { - return; - } - - $this->loaded = true; - $path = getenv(self::ENVIRONMENT_VARIABLE); - if (in_array($path, [false, ''], true)) { - return; - } - - $contents = @file_get_contents($path); - if ($contents === false) { - return; - } - - $data = json_decode($contents, true); - if (!is_array($data) || !is_array($data['ruleClasses'] ?? null) || !is_array($data['files'] ?? null)) { - return; - } - - $this->set( - array_values(array_filter($data['ruleClasses'], 'is_string')), - array_values(array_filter($data['files'], 'is_string')), - ); - } - } diff --git a/src/Command/AnalyseApplication.php b/src/Command/AnalyseApplication.php index 684c3fb08c1..910412c4bed 100644 --- a/src/Command/AnalyseApplication.php +++ b/src/Command/AnalyseApplication.php @@ -2,7 +2,6 @@ namespace PHPStan\Command; -use Nette\Utils\Json; use PHPStan\Analyser\AnalyserResult; use PHPStan\Analyser\AnalyserResultFinalizer; use PHPStan\Analyser\Error; @@ -24,15 +23,10 @@ use function fclose; use function feof; use function fgets; -use function file_put_contents; use function fopen; use function memory_get_peak_usage; use function microtime; -use function putenv; use function sprintf; -use function sys_get_temp_dir; -use function uniqid; -use function unlink; /** * @phpstan-import-type CollectorData from CollectedData @@ -98,17 +92,8 @@ public function analyse( } } else { $resultCache = $resultCacheManager->restore($files, $debug, $onlyFiles, $projectConfigArray, $errorOutput); - $ruleScopeFile = null; - if ($resultCache->getRuleScopedFiles() !== []) { - $this->ruleScopedAnalysis->set($resultCache->getChangedRuleClasses(), $resultCache->getRuleScopedFiles()); - // the parallel workers - spawned or forked - inherit the environment - $ruleScopeFile = sprintf('%s/phpstan-rule-scope-%s.json', sys_get_temp_dir(), uniqid()); - file_put_contents($ruleScopeFile, Json::encode([ - 'ruleClasses' => $resultCache->getChangedRuleClasses(), - 'files' => $resultCache->getRuleScopedFiles(), - ])); - putenv(sprintf('%s=%s', RuleScopedAnalysis::ENVIRONMENT_VARIABLE, $ruleScopeFile)); - } + // 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, @@ -120,10 +105,6 @@ public function analyse( $errorOutput, $input, ); - if ($ruleScopeFile !== null) { - putenv(RuleScopedAnalysis::ENVIRONMENT_VARIABLE); - @unlink($ruleScopeFile); - } $projectStubFiles = $this->stubFilesProvider->getProjectStubFiles(); diff --git a/src/Parallel/ParallelAnalyser.php b/src/Parallel/ParallelAnalyser.php index 65cfd60fb06..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(); @@ -220,7 +254,7 @@ public function analyse( } $job = array_pop($jobs); - $process->request(['action' => 'analyse', 'files' => $job]); + $process->request($job); }); }); /** @var string $serverAddress */ @@ -418,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 c5d867db897..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 = []; From aa314c8d8a246cc64d018714b19a34abb3f90f81 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Sun, 11 Oct 2026 22:38:37 +0200 Subject: [PATCH 4/7] Test that an edited project rule gives the same errors as a cold run The new e2e test edits the project's own rules and collector, and runs PHPStan after each edit. It checks message changes, an ignore comment that stops or starts to match, an error in a trait, an error that a rule reports into another file, a CollectedDataNode rule, and a collector. The expected lines come from a cold run of the same files. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/e2e-tests.yml | 57 +++++++++++++++++++ e2e/result-cache-rule-edit/.gitignore | 2 + .../collected-data-rule.patch | 11 ++++ e2e/result-cache-rule-edit/collector.patch | 13 +++++ e2e/result-cache-rule-edit/composer.json | 8 +++ e2e/result-cache-rule-edit/phpstan.neon | 16 ++++++ e2e/result-cache-rule-edit/rule-message.patch | 15 +++++ .../rule-reports-strlen.patch | 18 ++++++ .../rule-stops-foo.patch | 12 ++++ .../rules/CalledCollector.php | 27 +++++++++ .../rules/CalledRule.php | 37 ++++++++++++ e2e/result-cache-rule-edit/rules/R.php | 40 +++++++++++++ e2e/result-cache-rule-edit/src/A.php | 12 ++++ e2e/result-cache-rule-edit/src/C.php | 8 +++ e2e/result-cache-rule-edit/src/NoCalls.php | 8 +++ e2e/result-cache-rule-edit/src/Other.php | 8 +++ e2e/result-cache-rule-edit/src/T.php | 12 ++++ e2e/result-cache-rule-edit/tmp/.gitignore | 2 + 18 files changed, 306 insertions(+) create mode 100644 e2e/result-cache-rule-edit/.gitignore create mode 100644 e2e/result-cache-rule-edit/collected-data-rule.patch create mode 100644 e2e/result-cache-rule-edit/collector.patch create mode 100644 e2e/result-cache-rule-edit/composer.json create mode 100644 e2e/result-cache-rule-edit/phpstan.neon create mode 100644 e2e/result-cache-rule-edit/rule-message.patch create mode 100644 e2e/result-cache-rule-edit/rule-reports-strlen.patch create mode 100644 e2e/result-cache-rule-edit/rule-stops-foo.patch create mode 100644 e2e/result-cache-rule-edit/rules/CalledCollector.php create mode 100644 e2e/result-cache-rule-edit/rules/CalledRule.php create mode 100644 e2e/result-cache-rule-edit/rules/R.php create mode 100644 e2e/result-cache-rule-edit/src/A.php create mode 100644 e2e/result-cache-rule-edit/src/C.php create mode 100644 e2e/result-cache-rule-edit/src/NoCalls.php create mode 100644 e2e/result-cache-rule-edit/src/Other.php create mode 100644 e2e/result-cache-rule-edit/src/T.php create mode 100644 e2e/result-cache-rule-edit/tmp/.gitignore diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 41a6174902e..173993bde6c 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1497,6 +1497,63 @@ jobs: ../bashunit -a not_contains 'Result cache not used' "$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. 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/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..f01c7481965 --- /dev/null +++ b/e2e/result-cache-rule-edit/phpstan.neon @@ -0,0 +1,16 @@ +parameters: + level: 0 + tmpDir: tmp + 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 @@ + Date: Sun, 11 Oct 2026 22:47:08 +0200 Subject: [PATCH 5/7] Name the node classes of the rule scope once in the result cache The rule scope stored a list of node class names for each file, so the cache file repeated the same names for every file. It now stores the classes once in a table, and each file stores a list of positions in it, as the dependency graph does with paths. restore() decodes the section back to class names, so findRuleScopedFiles() and mergeNodeTypes() do not change. A malformed rule scope now makes restore() discard the cache, as other decode errors do. This includes a cache written in the earlier format of this section. On rector-src the rule scope section was 4,104,301 bytes and is now 1,265,225. Co-Authored-By: Claude Opus 5.5 --- .../ResultCache/ResultCacheManager.php | 88 +++++++++++++++++-- 1 file changed, 79 insertions(+), 9 deletions(-) diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 7b318e93fe2..ad240993726 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -412,11 +412,20 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $fileStatSignaturesChanged = $this->fileStatSignaturesDiffer($data['dependencies']); $data['packageDependencies'] = $transformer->absolutizeFileKeyed($data['packageDependencies'] ?? []); $ruleScopeData = null; - if (is_array($data['ruleScope'] ?? null)) { - $ruleScopeData = [ - 'nodeTypes' => $transformer->absolutizeFileKeyed($data['ruleScope']['nodeTypes']), - 'ruleNodeTypes' => $data['ruleScope']['ruleNodeTypes'], - ]; + if (array_key_exists('ruleScope', $data)) { + try { + $ruleScopeData = $this->decodeRuleScope($data['ruleScope'], $transformer); + } catch (Throwable $e) { + @unlink($cacheFilePath); + + return $this->fullAnalysis( + sprintf('Result cache not used because an error occurred while loading the cache file: %s', $e->getMessage()), + $allAnalysedFiles, + $this->getMeta($allAnalysedFiles, $projectConfigArray), + $currentFileHashes, + $output, + ); + } } try { $cachedValueDependencies = $this->decodeValueDependencies($data['valueDependencies'] ?? ['paths' => [], 'entries' => []], $transformer); @@ -2064,10 +2073,7 @@ private function save( ksort($projectRuleNodeTypes); $projectExtensionFiles = $transformer->relativizeFileKeyed($projectExtensionFiles); ksort($nodeTypes); - $ruleScope = [ - 'nodeTypes' => $transformer->relativizeFileKeyed($nodeTypes), - 'ruleNodeTypes' => $projectRuleNodeTypes, - ]; + $ruleScope = $this->encodeRuleScope($transformer->relativizeFileKeyed($nodeTypes), $projectRuleNodeTypes); $file = $this->cacheFilePath; @@ -2229,6 +2235,70 @@ private function decodeDependencyGraph($dependencyGraph, ResultCachePathTransfor return $dependencies; } + /** + * The node types of each file name the same few node classes again and again, so the classes are + * named once in a table and each file refers to them by position, as the dependency graph does + * with paths. + * + * @param array> $nodeTypes + * @param array> $ruleNodeTypes + * @return array{nodeClasses: list, nodeTypes: array>, ruleNodeTypes: array>} + */ + private function encodeRuleScope(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>} + */ + private function decodeRuleScope($ruleScope, ResultCachePathTransformer $transformer): 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' => $transformer->absolutizeFileKeyed($nodeTypes), 'ruleNodeTypes' => $ruleScope['ruleNodeTypes']]; + } + /** * The exported nodes, as an index of the files and the lengths of their serialized nodes, followed * by the serialized nodes back to back - so that the next run can find the few it needs without From 4e1c12daee79363f5bed3098ac5eeea6a05c75f7 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Mon, 12 Oct 2026 09:05:16 +0200 Subject: [PATCH 6/7] Move the rule scope codec into its own class and test it RuleScopeCodec holds the encode and decode steps of the ruleScope section, which were private methods of ResultCacheManager. The manager still turns the file keys into absolute paths after decode. The test checks that each node class is named once, that a round trip gives the same data, and that each malformed shape throws. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ResultCache/ResultCacheManager.php | 69 +-------------- src/Analyser/ResultCache/RuleScopeCodec.php | 82 +++++++++++++++++ .../ResultCache/RuleScopeCodecTest.php | 87 +++++++++++++++++++ 3 files changed, 172 insertions(+), 66 deletions(-) create mode 100644 src/Analyser/ResultCache/RuleScopeCodec.php create mode 100644 tests/PHPStan/Analyser/ResultCache/RuleScopeCodecTest.php diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index ad240993726..4ea887ae0d4 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -414,7 +414,8 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $ruleScopeData = null; if (array_key_exists('ruleScope', $data)) { try { - $ruleScopeData = $this->decodeRuleScope($data['ruleScope'], $transformer); + $ruleScopeData = RuleScopeCodec::decode($data['ruleScope']); + $ruleScopeData['nodeTypes'] = $transformer->absolutizeFileKeyed($ruleScopeData['nodeTypes']); } catch (Throwable $e) { @unlink($cacheFilePath); @@ -2073,7 +2074,7 @@ private function save( ksort($projectRuleNodeTypes); $projectExtensionFiles = $transformer->relativizeFileKeyed($projectExtensionFiles); ksort($nodeTypes); - $ruleScope = $this->encodeRuleScope($transformer->relativizeFileKeyed($nodeTypes), $projectRuleNodeTypes); + $ruleScope = RuleScopeCodec::encode($transformer->relativizeFileKeyed($nodeTypes), $projectRuleNodeTypes); $file = $this->cacheFilePath; @@ -2235,70 +2236,6 @@ private function decodeDependencyGraph($dependencyGraph, ResultCachePathTransfor return $dependencies; } - /** - * The node types of each file name the same few node classes again and again, so the classes are - * named once in a table and each file refers to them by position, as the dependency graph does - * with paths. - * - * @param array> $nodeTypes - * @param array> $ruleNodeTypes - * @return array{nodeClasses: list, nodeTypes: array>, ruleNodeTypes: array>} - */ - private function encodeRuleScope(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>} - */ - private function decodeRuleScope($ruleScope, ResultCachePathTransformer $transformer): 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' => $transformer->absolutizeFileKeyed($nodeTypes), 'ruleNodeTypes' => $ruleScope['ruleNodeTypes']]; - } - /** * The exported nodes, as an index of the files and the lengths of their serialized nodes, followed * by the serialized nodes back to back - so that the next run can find the few it needs without 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/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); + } + +} From 0b4a99d6dae78119637d96e61d7e58531c911672 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Mon, 12 Oct 2026 10:17:16 +0200 Subject: [PATCH 7/7] Make rule-scoped re-analysis opt-in By default, a changed rule of the project starts a full analysis again, as on 2.3.x. A project turns the rule-scoped re-analysis on with resultCacheRuleScopedReanalysis: true. When the option is off, the result cache does not store the rule scope, so the cache file does not grow. The result-cache-extension e2e tests assert the full analysis again, and result-cache-rule-edit turns the option on. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/e2e-tests.yml | 18 +++++----- conf/config.neon | 1 + conf/parametersSchema.neon | 1 + e2e/result-cache-rule-edit/phpstan.neon | 1 + .../ResultCache/ResultCacheManager.php | 35 +++++++++++-------- 5 files changed, 32 insertions(+), 24 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 173993bde6c..3bb268a85f8 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1455,30 +1455,28 @@ jobs: composer install # The rule comes from a package Composer symlinked out of the project, so the file the # class reflects to is the source directory, outside vendor/ and outside the analysed - # paths. Editing it has to re-run the rule on every file it can report on. + # paths. Editing it has to invalidate the cache: the rule decides what every file reports. OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") echo "$OUTPUT" ../bashunit -a contains 'the rule says one thing' "$OUTPUT" patch -p0 < patch.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 '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-extension-autoload-dev composer install # The rule lives in a directory that is only in autoload-dev and is not analysed, so - # nothing in the dependency graph points at it. Editing it still has to re-run the rule. + # nothing in the dependency graph points at it. Editing it still has to invalidate. OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") echo "$OUTPUT" ../bashunit -a contains 'the rule says one thing' "$OUTPUT" patch -p0 < patch.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 '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: | @@ -1493,15 +1491,15 @@ jobs: patch -p0 < patch.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 '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. A run follows - # each edit below. The expected lines come from a cold run of the same files. + # 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" 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/phpstan.neon b/e2e/result-cache-rule-edit/phpstan.neon index f01c7481965..053d39e1cc8 100644 --- a/e2e/result-cache-rule-edit/phpstan.neon +++ b/e2e/result-cache-rule-edit/phpstan.neon @@ -1,6 +1,7 @@ parameters: level: 0 tmpDir: tmp + resultCacheRuleScopedReanalysis: true paths: - src - rules diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 4ea887ae0d4..33c38508931 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -231,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%')] @@ -412,7 +414,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $fileStatSignaturesChanged = $this->fileStatSignaturesDiffer($data['dependencies']); $data['packageDependencies'] = $transformer->absolutizeFileKeyed($data['packageDependencies'] ?? []); $ruleScopeData = null; - if (array_key_exists('ruleScope', $data)) { + if ($this->ruleScopedReanalysis && array_key_exists('ruleScope', $data)) { try { $ruleScopeData = RuleScopeCodec::decode($data['ruleScope']); $ruleScopeData['nodeTypes'] = $transformer->absolutizeFileKeyed($ruleScopeData['nodeTypes']); @@ -2060,21 +2062,24 @@ private function save( $encodedValueDependencies = $this->encodeValueDependencies($valueDependencies, $transformer); unset($invertedDependencies); $packageDependencies = $transformer->relativizeFileKeyed($packageDependencies); - // 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; - } + $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]; + $projectRuleNodeTypes[$extensionClassName] = $allRuleNodeTypes[$extensionClassName]; + } + ksort($projectRuleNodeTypes); + ksort($nodeTypes); + $ruleScope = RuleScopeCodec::encode($transformer->relativizeFileKeyed($nodeTypes), $projectRuleNodeTypes); } - ksort($projectRuleNodeTypes); $projectExtensionFiles = $transformer->relativizeFileKeyed($projectExtensionFiles); - ksort($nodeTypes); - $ruleScope = RuleScopeCodec::encode($transformer->relativizeFileKeyed($nodeTypes), $projectRuleNodeTypes); $file = $this->cacheFilePath; @@ -2117,7 +2122,9 @@ private function save( $this->writeValueFrame($handle, $file, 'dependencyGraph', $dependencyGraph); $this->writeValueFrame($handle, $file, 'valueDependencies', $encodedValueDependencies); $this->writeArrayFrame($handle, $file, 'packageDependencies', $packageDependencies); - $this->writeValueFrame($handle, $file, 'ruleScope', $ruleScope); + if ($ruleScope !== null) { + $this->writeValueFrame($handle, $file, 'ruleScope', $ruleScope); + } $this->writeExportedNodes($handle, $file, $exportedNodes, $cachedExportedNodes, $transformer); fclose($handle); $closed = true;