From e95662afa2dc072e5460a2df6d1665c10be7a674 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 29 Oct 2026 00:30:34 +0100 Subject: [PATCH 1/2] Run a rule on CollectedDataNode every time when it does not call get() Such a rule works with something other than the collected data, for example a tool that it runs over the whole project. Its errors were reused on every warm run. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Analyser/AnalyserResultFinalizer.php | 6 +- src/Analyser/DependencyTracker.php | 3 +- .../CollectedDataReuse/DescribedClassRule.php | 2 + .../NoCollectedDataRule.php | 60 +++++++++++++++++++ .../CollectedDataReuse/OnlyFilesRule.php | 2 + .../CollectedDataReuse/TrackingRule.php | 2 + .../Analyser/CollectedDataRuleReuseTest.php | 24 ++++++++ 7 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Analyser/CollectedDataReuse/NoCollectedDataRule.php diff --git a/src/Analyser/AnalyserResultFinalizer.php b/src/Analyser/AnalyserResultFinalizer.php index 9428ffa3be9..265962f0c59 100644 --- a/src/Analyser/AnalyserResultFinalizer.php +++ b/src/Analyser/AnalyserResultFinalizer.php @@ -55,7 +55,8 @@ public function __construct( * @param Closure(): void|null $postRuleCallback called when the rule's processNode() has returned or thrown, before its errors are processed * @param array $previousCollectedDataRuleResults the rule results of the * previous run, from the result cache - a rule whose input did not change reuses its errors. - * Only with the reuseCollectedDataRuleErrors feature toggle, otherwise every rule runs. + * Only with the reuseCollectedDataRuleErrors feature toggle, otherwise every rule runs. A rule that + * does not call get() on the node always runs. */ public function finalize( AnalyserResult $analyserResult, @@ -179,7 +180,8 @@ public function finalize( $errors[] = $this->ruleErrorTransformer->transform($ruleError, $scope, [], $ruleNode); } - if ($this->reuseCollectedDataRuleErrors) { + // a rule that never asked for collected data works with something else, so it runs every time + if ($this->reuseCollectedDataRuleErrors && $reads->getCollectorTypes() !== []) { $collectedDataRuleResults[$ruleKey] = $this->createRuleResult($reads, $onlyFiles, $hashCollector, $errors, $ruleValueDependencies); } diff --git a/src/Analyser/DependencyTracker.php b/src/Analyser/DependencyTracker.php index 5947fc3514e..52a9447a97b 100644 --- a/src/Analyser/DependencyTracker.php +++ b/src/Analyser/DependencyTracker.php @@ -25,7 +25,8 @@ * the result cache re-analyses the file when that changes - see ResultCacheValueExtension. * In a rule for CollectedDataNode, it tracks what the errors of the rule depend on: with the * reuseCollectedDataRuleErrors feature toggle, the result cache reuses the errors of such a rule - * while its collected data and the tracked values stay the same. + * while its collected data and the tracked values stay the same. A rule that does not call get() on + * the node runs every time. * * Extensions that get no Scope because they describe a class - class reflection extensions - use * DeclarationDependencyTracker instead. diff --git a/tests/PHPStan/Analyser/CollectedDataReuse/DescribedClassRule.php b/tests/PHPStan/Analyser/CollectedDataReuse/DescribedClassRule.php index b73998f02c6..cb84b7184df 100644 --- a/tests/PHPStan/Analyser/CollectedDataReuse/DescribedClassRule.php +++ b/tests/PHPStan/Analyser/CollectedDataReuse/DescribedClassRule.php @@ -34,6 +34,8 @@ public function processNode(Node $node, Scope $scope): array { $this->runs++; + // a rule that does not ask for collected data runs every time + $node->get(FirstCollector::class); $contents = file_get_contents($this->file); return [ diff --git a/tests/PHPStan/Analyser/CollectedDataReuse/NoCollectedDataRule.php b/tests/PHPStan/Analyser/CollectedDataReuse/NoCollectedDataRule.php new file mode 100644 index 00000000000..932b9b30438 --- /dev/null +++ b/tests/PHPStan/Analyser/CollectedDataReuse/NoCollectedDataRule.php @@ -0,0 +1,60 @@ + + */ +final class NoCollectedDataRule implements Rule +{ + + public int $runs = 0; + + public function __construct(private string $file, private bool $track) + { + } + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + /** + * @param Scope&DependencyTracker $scope + */ + public function processNode(Node $node, Scope $scope): array + { + $this->runs++; + + if ($node->isOnlyFilesAnalysis()) { + return []; + } + + if ($this->track) { + $scope->trackFileDependency($this->file); + } + + $contents = file_get_contents($this->file); + + return [ + RuleErrorBuilder::message(sprintf('Tool says %s.', $contents === false ? 'nothing' : $contents)) + ->file(__FILE__) + ->line(1) + ->identifier('tests.noCollectedData') + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Analyser/CollectedDataReuse/OnlyFilesRule.php b/tests/PHPStan/Analyser/CollectedDataReuse/OnlyFilesRule.php index f5e1805c064..4e4a9724a55 100644 --- a/tests/PHPStan/Analyser/CollectedDataReuse/OnlyFilesRule.php +++ b/tests/PHPStan/Analyser/CollectedDataReuse/OnlyFilesRule.php @@ -25,6 +25,8 @@ public function processNode(Node $node, Scope $scope): array { $this->runs++; + // a rule that does not ask for collected data runs every time + $node->get(FirstCollector::class); if ($node->isOnlyFilesAnalysis()) { return []; } diff --git a/tests/PHPStan/Analyser/CollectedDataReuse/TrackingRule.php b/tests/PHPStan/Analyser/CollectedDataReuse/TrackingRule.php index 6b52f2b2fd0..d329ec486ef 100644 --- a/tests/PHPStan/Analyser/CollectedDataReuse/TrackingRule.php +++ b/tests/PHPStan/Analyser/CollectedDataReuse/TrackingRule.php @@ -41,6 +41,8 @@ public function processNode(Node $node, Scope $scope): array { $this->runs++; + // a rule that does not ask for collected data runs every time + $node->get(FirstCollector::class); $scope->trackFileDependency($this->file); $scope->trackDirectoryDependency($this->directory, '*.txt'); $scope->trackValueDependency(FileResultCacheValueExtension::class, $this->valueFile); diff --git a/tests/PHPStan/Analyser/CollectedDataRuleReuseTest.php b/tests/PHPStan/Analyser/CollectedDataRuleReuseTest.php index 1aab55f6e6c..2512a5a1687 100644 --- a/tests/PHPStan/Analyser/CollectedDataRuleReuseTest.php +++ b/tests/PHPStan/Analyser/CollectedDataRuleReuseTest.php @@ -5,6 +5,7 @@ use PHPStan\Analyser\CollectedDataReuse\DescribedClassRule; use PHPStan\Analyser\CollectedDataReuse\EarlyReturnRule; use PHPStan\Analyser\CollectedDataReuse\FirstCollector; +use PHPStan\Analyser\CollectedDataReuse\NoCollectedDataRule; use PHPStan\Analyser\CollectedDataReuse\OnlyFilesRule; use PHPStan\Analyser\CollectedDataReuse\SecondCollector; use PHPStan\Analyser\CollectedDataReuse\ThrowingRule; @@ -118,6 +119,29 @@ public function testOnlyFilesAnalysis(): void $this->assertSame([], $this->getMessages($result)); } + public function testRuleThatDoesNotAskForCollectedDataRunsEveryTime(): void + { + $file = sys_get_temp_dir() . '/phpstan-collected-data-reuse-' . uniqid() . '.txt'; + file_put_contents($file, 'one'); + $collectedData = [ + self::A => [FirstCollector::class => ['a1']], + ]; + + // tracking a value is not enough, the rule has to ask for collected data + foreach ([false, true] as $track) { + $rule = new NoCollectedDataRule($file, $track); + $result = $this->finalize([$rule], $collectedData, false, []); + $this->assertSame([], $result->getCollectedDataRuleResults()); + + $result = $this->finalize([$rule], $collectedData, false, $result->getCollectedDataRuleResults()); + $this->assertSame(2, $rule->runs); + $this->assertSame(0, $result->getReusedRulesCount()); + $this->assertSame(['Tool says one.'], $this->getMessages($result)); + } + + unlink($file); + } + public function testRuleThatThrowsIsNotReused(): void { $rule = new ThrowingRule(); From 0ffa003897ac51199286baffc162b61df7606e8e Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 29 Oct 2026 00:30:34 +0100 Subject: [PATCH 2/2] Run every rule on CollectedDataNode with -vvv With -vvv the diagnose extensions print, and a rule can fill what they print only while it runs. The rule results are still saved for the next run. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/e2e-tests.yml | 5 +++++ src/Command/AnalyseApplication.php | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index bc5354b9873..abe8caeedbb 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -226,6 +226,11 @@ jobs: ../bashunit assert contains 'Caller.php:10:Call to function ResultCacheCollectedDataRules\pure() on a separate line has no effect.' "$OUTPUT" echo "$OUTPUT" | grep -E 'Result cache reused the errors of ([0-9]+) of \1 rules on collected data\.' ../bashunit assert contains 'Result cache was not rewritten because it is unchanged.' "$OUTPUT" + # With -vvv the diagnose extensions print what the rules filled while they ran, so every rule runs. + OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan analyse -vvv --error-format raw") + echo "$OUTPUT" + ../bashunit assert contains 'Caller.php:10:Call to function ResultCacheCollectedDataRules\pure() on a separate line has no effect.' "$OUTPUT" + ../bashunit assert not_contains 'Result cache reused the errors' "$OUTPUT" # src/Other.php is analysed again, but what it collects stays the same. echo '// changed' >> src/Other.php OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") diff --git a/src/Command/AnalyseApplication.php b/src/Command/AnalyseApplication.php index 78a8ea9cb53..a25f577141c 100644 --- a/src/Command/AnalyseApplication.php +++ b/src/Command/AnalyseApplication.php @@ -143,7 +143,8 @@ public function analyse( $debug, $preRuleCallback, $postRuleCallback, - $resultCache->getCollectedDataRuleResults(), + // with -vvv the diagnose extensions print, and a rule can fill what they print only while it runs + $errorOutput->isDebug() ? [] : $resultCache->getCollectedDataRuleResults(), ); if ($finalizerResult->getReusedRulesCount() > 0 && $errorOutput->isVeryVerbose()) { $errorOutput->writeLineFormatted(sprintf(