Skip to content

Commit f26be0a

Browse files
committed
Add external file dependency tracking for incremental cache invalidation
Introduces ExternalFileDependencyRegistrar, a new @api service that allows extensions to declare that an analyzed file depends on an external (non-analyzed) file. When that external file changes, only the dependent analyzed files are re-analyzed instead of triggering full cache invalidation. This is an alternative to ResultCacheMetaExtension for cases where external data changes (e.g. Symfony DI container XML) should not cause full cache invalidation but rather surgical re-analysis of affected files only. The mechanism integrates with the existing result cache system: external dependencies are tracked during analysis, stored in the cache alongside regular file dependencies, and checked during cache restore. Co-Authored-By: Claude Code
1 parent 38036bf commit f26be0a

File tree

13 files changed

+204
-7
lines changed

13 files changed

+204
-7
lines changed

src/Analyser/Analyser.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public function analyse(
7575
$dependencies = [];
7676
$usedTraitDependencies = [];
7777
$exportedNodes = [];
78+
$externalFileDependencies = [];
7879
foreach ($files as $file) {
7980
if ($preFileCallback !== null) {
8081
$preFileCallback($file);
@@ -99,6 +100,10 @@ public function analyse(
99100
$collectedData = array_merge($collectedData, $fileAnalyserResult->getCollectedData());
100101
$dependencies[$file] = $fileAnalyserResult->getDependencies();
101102
$usedTraitDependencies[$file] = $fileAnalyserResult->getUsedTraitDependencies();
103+
$fileExternalDeps = $fileAnalyserResult->getExternalFileDependencies();
104+
if (count($fileExternalDeps) > 0) {
105+
$externalFileDependencies[$file] = $fileExternalDeps;
106+
}
102107

103108
$fileExportedNodes = $fileAnalyserResult->getExportedNodes();
104109
if (count($fileExportedNodes) > 0) {
@@ -143,6 +148,7 @@ public function analyse(
143148
exportedNodes: $exportedNodes,
144149
reachedInternalErrorsCountLimit: $reachedInternalErrorsCountLimit,
145150
peakMemoryUsageBytes: memory_get_peak_usage(true),
151+
externalFileDependencies: $internalErrorsCount === 0 ? $externalFileDependencies : null,
146152
);
147153
}
148154

src/Analyser/AnalyserResult.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ final class AnalyserResult
2828
* @param array<string, array<string>>|null $dependencies
2929
* @param array<string, array<string>>|null $usedTraitDependencies
3030
* @param array<string, array<RootExportedNode>> $exportedNodes
31+
* @param array<string, list<string>>|null $externalFileDependencies
3132
*/
3233
public function __construct(
3334
private array $unorderedErrors,
@@ -43,6 +44,7 @@ public function __construct(
4344
private array $exportedNodes,
4445
private bool $reachedInternalErrorsCountLimit,
4546
private int $peakMemoryUsageBytes,
47+
private ?array $externalFileDependencies = null,
4648
)
4749
{
4850
}
@@ -159,6 +161,14 @@ public function getExportedNodes(): array
159161
return $this->exportedNodes;
160162
}
161163

164+
/**
165+
* @return array<string, list<string>>|null
166+
*/
167+
public function getExternalFileDependencies(): ?array
168+
{
169+
return $this->externalFileDependencies;
170+
}
171+
162172
public function hasReachedInternalErrorsCountLimit(): bool
163173
{
164174
return $this->reachedInternalErrorsCountLimit;

src/Analyser/AnalyserResultFinalizer.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ public function finalize(AnalyserResult $analyserResult, bool $onlyFiles, bool $
148148
exportedNodes: $analyserResult->getExportedNodes(),
149149
reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(),
150150
peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(),
151+
externalFileDependencies: $analyserResult->getExternalFileDependencies(),
151152
), $collectorErrors, $locallyIgnoredCollectorErrors);
152153
}
153154

@@ -167,6 +168,7 @@ private function mergeFilteredPhpErrors(AnalyserResult $analyserResult): Analyse
167168
exportedNodes: $analyserResult->getExportedNodes(),
168169
reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(),
169170
peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(),
171+
externalFileDependencies: $analyserResult->getExternalFileDependencies(),
170172
);
171173
}
172174

@@ -231,6 +233,7 @@ private function addUnmatchedIgnoredErrors(
231233
exportedNodes: $analyserResult->getExportedNodes(),
232234
reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(),
233235
peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(),
236+
externalFileDependencies: $analyserResult->getExternalFileDependencies(),
234237
),
235238
$collectorErrors,
236239
$locallyIgnoredCollectorErrors,
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser;
4+
5+
use PHPStan\DependencyInjection\AutowiredService;
6+
use function array_unique;
7+
use function array_values;
8+
9+
/**
10+
* Allows extensions to declare that the currently analyzed file depends on
11+
* an external (non-analyzed) file. When that external file changes, only
12+
* the dependent analyzed files are re-analyzed instead of the entire project.
13+
*
14+
* This is an alternative to ResultCacheMetaExtension for cases where
15+
* external data changes should not cause full cache invalidation.
16+
*
17+
* @api
18+
*/
19+
#[AutowiredService]
20+
final class ExternalFileDependencyRegistrar
21+
{
22+
23+
/** @var list<string> */
24+
private array $currentFileDependencies = [];
25+
26+
/**
27+
* Register a dependency on an external file for the currently analyzed file.
28+
*/
29+
public function add(string $externalFilePath): void
30+
{
31+
$this->currentFileDependencies[] = $externalFilePath;
32+
}
33+
34+
/**
35+
* @return list<string>
36+
* @internal Used by FileAnalyser after each file analysis
37+
*/
38+
public function getAndReset(): array
39+
{
40+
$deps = array_values(array_unique($this->currentFileDependencies));
41+
$this->currentFileDependencies = [];
42+
43+
return $deps;
44+
}
45+
46+
}

src/Analyser/FileAnalyser.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public function __construct(
6060
private IgnoreErrorExtensionProvider $ignoreErrorExtensionProvider,
6161
private RuleErrorTransformer $ruleErrorTransformer,
6262
private LocalIgnoresProcessor $localIgnoresProcessor,
63+
private ExternalFileDependencyRegistrar $externalFileDependencyRegistrar,
6364
#[AutowiredParameter]
6465
private bool $reportIgnoresWithoutComments,
6566
)
@@ -247,6 +248,7 @@ public function analyseFile(
247248
$linesToIgnore,
248249
$unmatchedLineIgnores,
249250
$processedFiles,
251+
$this->externalFileDependencyRegistrar->getAndReset(),
250252
);
251253
}
252254

src/Analyser/FileAnalyserResult.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ final class FileAnalyserResult
2525
* @param LinesToIgnore $linesToIgnore
2626
* @param LinesToIgnore $unmatchedLineIgnores
2727
* @param list<string> $processedFiles
28+
* @param list<string> $externalFileDependencies
2829
*/
2930
public function __construct(
3031
private array $errors,
@@ -38,6 +39,7 @@ public function __construct(
3839
private array $linesToIgnore,
3940
private array $unmatchedLineIgnores,
4041
private array $processedFiles,
42+
private array $externalFileDependencies = [],
4143
)
4244
{
4345
}
@@ -130,4 +132,12 @@ public function getProcessedFiles(): array
130132
return $this->processedFiles;
131133
}
132134

135+
/**
136+
* @return list<string>
137+
*/
138+
public function getExternalFileDependencies(): array
139+
{
140+
return $this->externalFileDependencies;
141+
}
142+
133143
}

src/Analyser/ResultCache/ResultCache.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ final class ResultCache
2727
* @param array<string, array<RootExportedNode>> $exportedNodes
2828
* @param array<string, array{string, bool, string}> $projectExtensionFiles
2929
* @param array<string, string> $currentFileHashes
30+
* @param array<string, array<string>> $externalFileDependencies
3031
*/
3132
public function __construct(
3233
private array $filesToAnalyse,
@@ -43,6 +44,7 @@ public function __construct(
4344
private array $exportedNodes,
4445
private array $projectExtensionFiles,
4546
private array $currentFileHashes,
47+
private array $externalFileDependencies = [],
4648
)
4749
{
4850
}
@@ -153,4 +155,14 @@ public function getCurrentFileHashes(): array
153155
return $this->currentFileHashes;
154156
}
155157

158+
/**
159+
* Inverted external file dependencies: external file => dependent analyzed files.
160+
*
161+
* @return array<string, array<string>>
162+
*/
163+
public function getExternalFileDependencies(): array
164+
{
165+
return $this->externalFileDependencies;
166+
}
167+
156168
}

src/Analyser/ResultCache/ResultCacheManager.php

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,22 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ?
356356
$filesToAnalyse = [];
357357
$invertedDependenciesToReturn = [];
358358
$invertedUsedTraitDependenciesToReturn = [];
359+
360+
// Check external file dependencies for incremental re-analysis
361+
$cachedExternalDependencies = $data['externalDependencies'] ?? [];
362+
$externalDependenciesToReturn = $cachedExternalDependencies;
363+
foreach ($cachedExternalDependencies as $externalFile => $externalData) {
364+
if (!is_file($externalFile) || $this->getFileHash($externalFile) !== $externalData['fileHash']) {
365+
if ($output->isVeryVerbose()) {
366+
$output->writeLineFormatted(sprintf('External file %s changed, re-analysing dependent files.', $externalFile));
367+
}
368+
foreach ($externalData['dependentFiles'] as $dependentFile) {
369+
if (is_file($dependentFile)) {
370+
$filesToAnalyse[] = $dependentFile;
371+
}
372+
}
373+
}
374+
}
359375
$errors = $data['errorsCallback']();
360376
$locallyIgnoredErrors = $data['locallyIgnoredErrorsCallback']();
361377
$linesToIgnore = $data['linesToIgnore'];
@@ -515,6 +531,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ?
515531
exportedNodes: $filteredExportedNodes,
516532
projectExtensionFiles: $data['projectExtensionFiles'],
517533
currentFileHashes: $currentFileHashes,
534+
externalFileDependencies: $externalDependenciesToReturn,
518535
);
519536
}
520537

@@ -624,7 +641,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache
624641
if ($projectConfigArray !== null) {
625642
$meta['projectConfig'] = Neon::encode($projectConfigArray);
626643
}
627-
$doSave = function (array $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, ?array $dependencies, ?array $usedTraitDependencies, array $exportedNodes, array $projectExtensionFiles) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): bool {
644+
$doSave = function (array $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, ?array $dependencies, ?array $usedTraitDependencies, array $exportedNodes, array $projectExtensionFiles, array $externalFileDependencies = []) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): bool {
628645
if ($onlyFiles) {
629646
if ($output->isVeryVerbose()) {
630647
$output->writeLineFormatted('Result cache was not saved because only files were passed as analysed paths.');
@@ -672,7 +689,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache
672689
}
673690
}
674691

675-
$this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $exportedNodes, $projectExtensionFiles, $resultCache->getCurrentFileHashes(), $meta);
692+
$this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $exportedNodes, $projectExtensionFiles, $resultCache->getCurrentFileHashes(), $meta, $externalFileDependencies);
676693

677694
if ($output->isVeryVerbose()) {
678695
$output->writeLineFormatted('Result cache is saved.');
@@ -688,7 +705,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache
688705
if ($analyserResult->getDependencies() !== null) {
689706
$projectExtensionFiles = $this->getProjectExtensionFiles($projectConfigArray, $analyserResult->getDependencies());
690707
}
691-
$saved = $doSave($freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $analyserResult->getLinesToIgnore(), $analyserResult->getUnmatchedLineIgnores(), $freshCollectedDataByFile, $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), $analyserResult->getExportedNodes(), $projectExtensionFiles);
708+
$saved = $doSave($freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $analyserResult->getLinesToIgnore(), $analyserResult->getUnmatchedLineIgnores(), $freshCollectedDataByFile, $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), $analyserResult->getExportedNodes(), $projectExtensionFiles, $analyserResult->getExternalFileDependencies() ?? []);
692709
} else {
693710
if ($output->isVeryVerbose()) {
694711
$output->writeLineFormatted('Result cache was not saved because it was not requested.');
@@ -706,6 +723,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache
706723
$exportedNodes = $this->mergeExportedNodes($resultCache, $analyserResult->getExportedNodes());
707724
$linesToIgnore = $this->mergeLinesToIgnore($resultCache, $analyserResult->getLinesToIgnore());
708725
$unmatchedLineIgnores = $this->mergeUnmatchedLineIgnores($resultCache, $analyserResult->getUnmatchedLineIgnores());
726+
$externalFileDependencies = $this->mergeExternalFileDependencies($resultCache->getExternalFileDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getExternalFileDependencies());
709727

710728
$saved = false;
711729
if ($save !== false) {
@@ -729,7 +747,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache
729747
$projectExtensionFiles[$file] = [$hash, true, $className];
730748
}
731749
}
732-
$saved = $doSave($errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $exportedNodes, $projectExtensionFiles);
750+
$saved = $doSave($errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $exportedNodes, $projectExtensionFiles, $externalFileDependencies);
733751
}
734752

735753
$flatErrors = [];
@@ -760,6 +778,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache
760778
exportedNodes: $exportedNodes,
761779
reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(),
762780
peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(),
781+
externalFileDependencies: $externalFileDependencies !== [] ? $externalFileDependencies : null,
763782
), $saved);
764783
}
765784

@@ -944,6 +963,45 @@ private function mergeUnmatchedLineIgnores(ResultCache $resultCache, array $fres
944963
return $newUnmatchedLineIgnores;
945964
}
946965

966+
/**
967+
* Merges cached inverted external dependencies with fresh analysis results.
968+
*
969+
* @param array<string, array<string>> $cachedExternalDependencies Inverted: external file => dependent analyzed files
970+
* @param string[] $filesToAnalyse Files that were re-analyzed
971+
* @param array<string, list<string>>|null $freshExternalDependencies Non-inverted: analyzed file => external files
972+
* @return array<string, list<string>> Non-inverted: analyzed file => external files
973+
*/
974+
private function mergeExternalFileDependencies(
975+
array $cachedExternalDependencies,
976+
array $filesToAnalyse,
977+
?array $freshExternalDependencies,
978+
): array
979+
{
980+
if ($freshExternalDependencies === null) {
981+
return [];
982+
}
983+
984+
// Un-invert cached external dependencies: external file => [dependents] → dependent => [external files]
985+
$cachedPerFile = [];
986+
foreach ($cachedExternalDependencies as $externalFile => $externalData) {
987+
$dependentFiles = $externalData['dependentFiles'] ?? $externalData;
988+
foreach ($dependentFiles as $dependentFile) {
989+
$cachedPerFile[$dependentFile][] = $externalFile;
990+
}
991+
}
992+
993+
// Replace re-analyzed files with fresh data
994+
$merged = $cachedPerFile;
995+
foreach ($filesToAnalyse as $file) {
996+
unset($merged[$file]);
997+
if (array_key_exists($file, $freshExternalDependencies)) {
998+
$merged[$file] = $freshExternalDependencies[$file];
999+
}
1000+
}
1001+
1002+
return $merged;
1003+
}
1004+
9471005
/**
9481006
* @param array<string, list<Error>> $errors
9491007
* @param array<string, list<Error>> $locallyIgnoredErrors
@@ -956,6 +1014,7 @@ private function mergeUnmatchedLineIgnores(ResultCache $resultCache, array $fres
9561014
* @param array<string, array{string, bool, string}> $projectExtensionFiles
9571015
* @param array<string, string> $currentFileHashes
9581016
* @param mixed[] $meta
1017+
* @param array<string, list<string>> $externalFileDependencies
9591018
*/
9601019
private function save(
9611020
int $lastFullAnalysisTime,
@@ -970,6 +1029,7 @@ private function save(
9701029
array $projectExtensionFiles,
9711030
array $currentFileHashes,
9721031
array $meta,
1032+
array $externalFileDependencies = [],
9731033
): void
9741034
{
9751035
$invertedDependencies = [];
@@ -1043,6 +1103,31 @@ private function save(
10431103

10441104
ksort($exportedNodes);
10451105

1106+
// Build inverted external dependencies: external file => {hash, dependentFiles}
1107+
$invertedExternalDependencies = [];
1108+
foreach ($externalFileDependencies as $analysedFile => $externalFiles) {
1109+
foreach ($externalFiles as $externalFile) {
1110+
if (!array_key_exists($externalFile, $invertedExternalDependencies)) {
1111+
if (!is_file($externalFile)) {
1112+
continue;
1113+
}
1114+
$invertedExternalDependencies[$externalFile] = [
1115+
'fileHash' => $this->getFileHash($externalFile),
1116+
'dependentFiles' => [],
1117+
];
1118+
}
1119+
$invertedExternalDependencies[$externalFile]['dependentFiles'][] = $analysedFile;
1120+
}
1121+
}
1122+
1123+
foreach ($invertedExternalDependencies as $externalFile => $externalData) {
1124+
$dependentFiles = array_values(array_unique($externalData['dependentFiles']));
1125+
sort($dependentFiles);
1126+
$invertedExternalDependencies[$externalFile]['dependentFiles'] = $dependentFiles;
1127+
}
1128+
1129+
ksort($invertedExternalDependencies);
1130+
10461131
$file = $this->cacheFilePath;
10471132

10481133
FileWriter::write(
@@ -1059,7 +1144,8 @@ private function save(
10591144
'unmatchedLineIgnores' => " . var_export($unmatchedLineIgnores, true) . ",
10601145
'collectedDataCallback' => static function (): array { return " . var_export($collectedData, true) . "; },
10611146
'dependencies' => " . var_export($invertedDependencies, true) . ",
1062-
'exportedNodesCallback' => static function (): array { return " . var_export($exportedNodes, true) . '; },
1147+
'exportedNodesCallback' => static function (): array { return " . var_export($exportedNodes, true) . "; },
1148+
'externalDependencies' => " . var_export($invertedExternalDependencies, true) . ',
10631149
];
10641150
',
10651151
);

0 commit comments

Comments
 (0)