Repository navigation
Expand file tree
/
Copy pathResultCacheManager.php
More file actions
3430 lines (3017 loc) · 127 KB
/
Copy pathResultCacheManager.php
File metadata and controls
3430 lines (3017 loc) · 127 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php declare(strict_types = 1);
namespace PHPStan\Analyser\ResultCache;
use Closure;
use Nette\Neon\Neon;
use PHPStan\Analyser\AnalyserResult;
use PHPStan\Analyser\CollectedDataRuleResult;
use PHPStan\Analyser\Error;
use PHPStan\Analyser\FileAnalyserResult;
use PHPStan\Analyser\FinalizerResult;
use PHPStan\Analyser\ValueDependencyCollector;
use PHPStan\Collectors\CollectedData;
use PHPStan\Command\Output;
use PHPStan\Dependency\ExportedNode\ExportedTraitNode;
use PHPStan\Dependency\ExportedNodeFetcher;
use PHPStan\Dependency\PackageDependencyResolver;
use PHPStan\Dependency\RootExportedNode;
use PHPStan\DependencyInjection\AutowiredExtensions;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\ExtensionsCollection;
use PHPStan\DependencyInjection\GenerateFactory;
use PHPStan\DependencyInjection\ProjectConfigHelper;
use PHPStan\ExtensionInstaller\GeneratedConfig;
use PHPStan\File\CouldNotReadFileException;
use PHPStan\File\CouldNotWriteFileException;
use PHPStan\File\FileFinder;
use PHPStan\File\FileHelper;
use PHPStan\File\FileStatSignatures;
use PHPStan\Internal\ArrayHelper;
use PHPStan\Internal\ComposerHelper;
use PHPStan\Php\ComposerPhpVersionFactory;
use PHPStan\Php\PhpVersion;
use PHPStan\PhpDoc\StubFilesProvider;
use PHPStan\Reflection\BetterReflection\SourceStubber\ExtensionVersionProvider;
use PHPStan\ShouldNotHappenException;
use ReflectionClass;
use ReflectionException;
use RuntimeException;
use Throwable;
use function array_diff;
use function array_fill_keys;
use function array_filter;
use function array_intersect;
use function array_key_exists;
use function array_keys;
use function array_map;
use function array_merge;
use function array_unique;
use function array_values;
use function class_exists;
use function count;
use function error_get_last;
use function explode;
use function fclose;
use function fgets;
use function fopen;
use function fread;
use function fseek;
use function fstat;
use function ftell;
use function fwrite;
use function get_loaded_extensions;
use function getmypid;
use function hash_file;
use function implode;
use function in_array;
use function is_array;
use function is_dir;
use function is_file;
use function is_int;
use function is_string;
use function ksort;
use function microtime;
use function min;
use function rename;
use function rtrim;
use function serialize;
use function sort;
use function sprintf;
use function stat;
use function str_ends_with;
use function str_starts_with;
use function stream_copy_to_stream;
use function strlen;
use function substr;
use function time;
use function uasort;
use function uniqid;
use function unlink;
use function unserialize;
use const PHP_VERSION_ID;
use const SEEK_CUR;
/**
* @phpstan-import-type LinesToIgnore from FileAnalyserResult
* @phpstan-import-type ValueDependencies from ValueDependencyCollector
* @phpstan-import-type CollectorData from CollectedData
*/
#[GenerateFactory(interface: ResultCacheManagerFactory::class)]
final class ResultCacheManager
{
/**
* Loaded PHP extensions that cannot change the analysis result, so switching them on or off
* must not invalidate the whole cache. Profilers do not take part in the analysis at all, and
* phpstan_turbo only replaces PHPStan's own classes with native implementations that behave
* identically, so it cannot change what the cache holds. Keeping it here would make every
* cache built where the extension is available useless everywhere it is not: Windows, a PHP
* version with no distributed binary, or a libc the binaries are not built for.
*/
private const EXTENSIONS_NOT_INVALIDATING_CACHE = ['xdebug', 'blackfire', 'phpstan_turbo'];
private const CACHE_VERSION = 'v25-lazyCollectedData';
/**
* The recorded hash of a dependency that does not exist. A rule can depend on a path rather than on
* a symbol - the file named in a require() - and such a dependency has to be watched while it is
* missing, so that creating it re-analyses the file that named it. No real hash is empty.
*/
private const MISSING_FILE_HASH = '';
/**
* Prefixes the recorded hash of an analysed file with a parse error, whose result is not cached.
* The prefixed hash matches no real hash, so the file is re-analysed on every run.
*/
private const REANALYSE_FILE_HASH_PREFIX = 'reanalyse:';
private const SCANNED_FILE_APPEARED = 'appeared';
private const SCANNED_FILE_EDITED = 'edited';
private const SCANNED_FILE_GONE = 'gone';
/**
* Metadata keys whose change does not invalidate the whole result cache: the analysed files they
* affect can be pinpointed and re-analysed on their own. See restore().
*
* The scannedFiles entry covers scanFiles, scanDirectories and the files excluded from the
* analysis but living in an analysed directory - see getScannedFiles(). The bootstrapFiles are
* deliberately not here: they are executed, not just read, so a change in one of them can affect
* anything about the analysis. They stay in the fully invalidating executedFilesHashes entry.
*/
private const PARTIALLY_INVALIDATING_META_KEYS = ['composerLocks', 'composerInstalled', 'extensionVersions', 'scannedFiles'];
/**
* The cache file is serialize() output, but an older PHPStan reading it would
* include it as PHP and echo the whole multi-megabyte content to stdout as
* inline text before discarding it. This prefix makes such an include return
* null immediately (the text after ?> is never reached), so a downgrade
* degrades to a silent full analysis instead.
*/
private const SERIALIZED_FILE_PREFIX = '<?php return; ?>';
/**
* Sections restore() hands back as callbacks instead of arrays, so a run that never asks for them
* never pays for decoding them. Each is a whole array frame in the file, so the reader only has to
* remember where it starts and walk past its entries.
*
* Nothing closure-shaped is written: a frame holds the plain serialized payload
* (`s:8:"file.php";` followed by `a:1:{i:0;O:22:"PHPStan\Analyser\Error"...`), and
* readCacheFile() builds the callback in PHP around the open handle and that offset, which is only
* the shape restore() expects. The var_export format writes a real `static function (): array`
* into the file instead, and PHP still compiles the array literal inside it.
*/
private const LAZY_SECTIONS = ['errors', 'locallyIgnoredErrors', 'exportedNodes'];
/** @var array<string, string> */
private array $fileHashes = [];
/**
* The stat signatures of the analysed files whose hash in $fileHashes can be trusted to still
* describe them next time the signature matches - see hashAnalysedFiles().
*
* @var array<string, string>
*/
private array $recordedFileStats = [];
private ?ResultCachePathTransformer $pathTransformer = null;
/** @var array<string, true> */
private array $alreadyProcessed = [];
private bool $restoredCacheUnchanged = false;
/** @var array<string, string> */
private array $restoredStubFiles = [];
/**
* @param string[] $analysedPaths
* @param string[] $analysedPathsFromConfig
* @param string[] $composerAutoloaderProjectPaths
* @param string[] $bootstrapFiles
* @param string[] $scanFiles
* @param string[] $scanDirectories
* @param string[] $configStubFiles
* @param list<string|non-empty-list<string>> $parametersNotInvalidatingCache
* @param array<string, string> $fileReplacements
* @param ExtensionsCollection<ResultCacheMetaExtension> $resultCacheMetaExtensions
*/
public function __construct(
#[AutowiredExtensions(of: ResultCacheMetaExtension::class)]
private ExtensionsCollection $resultCacheMetaExtensions,
private ExportedNodeFetcher $exportedNodeFetcher,
#[AutowiredParameter(ref: '@fileFinderScan')]
private FileFinder $scanFileFinder,
private StubFilesProvider $stubFilesProvider,
private FileHelper $fileHelper,
private PackageDependencyResolver $packageDependencyResolver,
private ExtensionVersionProvider $extensionVersionProvider,
#[AutowiredParameter(ref: '%resultCachePath%')]
private string $cacheFilePath,
#[AutowiredParameter]
private array $analysedPaths,
#[AutowiredParameter]
private array $analysedPathsFromConfig,
#[AutowiredParameter]
private array $composerAutoloaderProjectPaths,
#[AutowiredParameter]
private string $usedLevel,
#[AutowiredParameter]
private ?string $cliAutoloadFile,
#[AutowiredParameter]
private array $bootstrapFiles,
#[AutowiredParameter]
private array $scanFiles,
#[AutowiredParameter]
private array $scanDirectories,
#[AutowiredParameter(ref: '%stubFiles%')]
private array $configStubFiles,
private array $fileReplacements,
#[AutowiredParameter(ref: '%resultCacheChecksProjectExtensionFilesDependencies%')]
private bool $checkDependenciesOfProjectExtensionFiles,
#[AutowiredParameter]
private array $parametersNotInvalidatingCache,
#[AutowiredParameter(ref: '%resultCacheSkipIfOlderThanDays%')]
private int $skipResultCacheIfOlderThanDays,
#[AutowiredParameter(ref: '%rootDir%')]
private string $anchorDirectory,
private PhpVersion $phpVersion,
private ComposerPhpVersionFactory $composerPhpVersionFactory,
private FileStatSignatures $fileStatSignatures,
private ValueDependencyCollector $valueDependencyCollector,
)
{
}
private function getPathTransformer(): ResultCachePathTransformer
{
return $this->pathTransformer ??= new ResultCachePathTransformer($this->anchorDirectory);
}
/**
* Whether the result cache file was present on disk when PHPStan started.
* Distinguishes "the cache never existed" from "the cache existed but was invalid".
*/
public function resultCacheExists(): bool
{
return is_file($this->cacheFilePath);
}
/**
* Builds the "the whole project has to be reanalysed" result, recording the reason
* why the cache could not be used. The reason is both printed in very verbose mode
* and kept on the ResultCache so the result-cache-info command can report it.
*
* @param string[] $allAnalysedFiles
* @param mixed[] $meta
* @param array<string, string> $currentFileHashes
*/
private function fullAnalysis(
string $reason,
array $allAnalysedFiles,
array $meta,
array $currentFileHashes,
Output $output,
): ResultCache
{
if ($output->isVeryVerbose()) {
$output->writeLineFormatted($reason);
}
return new ResultCache(
filesToAnalyse: $allAnalysedFiles,
fullAnalysis: true,
fullAnalysisReason: $reason,
lastFullAnalysisTime: time(),
meta: $meta,
errors: [],
locallyIgnoredErrors: [],
linesToIgnore: [],
unmatchedLineIgnores: [],
collectedData: LazyCollectedData::fromArray([]),
dependencies: [],
usedTraitDependencies: [],
valueDependencies: [],
packageDependencies: [],
exportedNodes: [],
cachedExportedNodes: CachedExportedNodes::createEmpty(),
projectExtensionFiles: [],
currentFileHashes: $currentFileHashes,
);
}
/**
* @param string[] $allAnalysedFiles
* @param mixed[]|null $projectConfigArray
*/
public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ?array $projectConfigArray, Output $output): ResultCache
{
$this->restoredCacheUnchanged = false;
$this->restoredStubFiles = [];
$this->recordedFileStats = [];
$startTime = microtime(true);
$analysedFileStats = $this->statAnalysedFiles($allAnalysedFiles);
if ($debug) {
return $this->fullAnalysis(
'Result cache not used because of debug mode.',
$allAnalysedFiles,
$this->getMeta($allAnalysedFiles, $projectConfigArray),
$this->hashAnalysedFiles($analysedFileStats, null),
$output,
);
}
if ($onlyFiles) {
return $this->fullAnalysis(
'Result cache not used because only files were passed as analysed paths.',
$allAnalysedFiles,
$this->getMeta($allAnalysedFiles, $projectConfigArray),
$this->hashAnalysedFiles($analysedFileStats, null),
$output,
);
}
$cacheFilePath = $this->cacheFilePath;
if (!is_file($cacheFilePath)) {
return $this->fullAnalysis(
'Result cache not used because the cache file does not exist.',
$allAnalysedFiles,
$this->getMeta($allAnalysedFiles, $projectConfigArray),
$this->hashAnalysedFiles($analysedFileStats, null),
$output,
);
}
try {
// The cache used to be a var_export'd PHP file loaded via include. Including a
// multi-megabyte PHP source retains its compiled op_arrays and interned strings
// for the process lifetime; unserialize() produces only the values. A cache file
// in the old PHP format fails to unserialize and is discarded below like any
// other corrupted file, so no cache version bump is needed for the transition.
$data = $this->readCacheFile($cacheFilePath);
} 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),
$this->hashAnalysedFiles($analysedFileStats, null),
$output,
);
}
if (!is_array($data)) {
@unlink($cacheFilePath);
return $this->fullAnalysis(
'Result cache not used because the cache file is corrupted.',
$allAnalysedFiles,
$this->getMeta($allAnalysedFiles, $projectConfigArray),
$this->hashAnalysedFiles($analysedFileStats, null),
$output,
);
}
// The cache stores paths relative to the anchor directory. Re-absolutize them against the current
// anchor before anything reads them, so a moved project (a fresh CI checkout dir, a git worktree)
// resolves to its new location. projectConfig stays a relative Neon string here;
// isMetaDifferent()/getMetaKeyDifferences() relativize the current side to compare. Absolutizing an
// already-absolute path is a no-op, so a cache from an older format is left untouched (and then
// discarded by the cacheVersion check below).
$transformer = $this->getPathTransformer();
$data['meta'] = $transformer->absolutizeMeta($data['meta']);
$data['projectExtensionFiles'] = $transformer->absolutizeFileKeyed($data['projectExtensionFiles']);
$data['linesToIgnore'] = $transformer->absolutizeCompoundKeyed($data['linesToIgnore']);
$data['unmatchedLineIgnores'] = $transformer->absolutizeCompoundKeyed($data['unmatchedLineIgnores']);
if (array_key_exists('dependencyGraph', $data)) {
try {
$data['dependencies'] = $this->decodeDependencyGraph($data['dependencyGraph'], $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),
$this->hashAnalysedFiles($analysedFileStats, null),
$output,
);
}
unset($data['dependencyGraph']);
} else {
// a cache written by an older version, which the cacheVersion check below discards
$data['dependencies'] = $transformer->absolutizeDependencies($data['dependencies'] ?? []);
}
$currentFileHashes = $this->hashAnalysedFiles($analysedFileStats, $data['dependencies']);
$fileStatSignaturesChanged = $this->fileStatSignaturesDiffer($data['dependencies']);
$data['packageDependencies'] = $transformer->absolutizeFileKeyed($data['packageDependencies'] ?? []);
try {
$cachedValueDependencies = $this->decodeValueDependencies($data['valueDependencies'] ?? ['paths' => [], 'entries' => []], $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,
);
}
$data['collectedDataIndex'] = $transformer->absolutizeFileKeyed($data['collectedDataIndex']);
$errorsCallback = $data['errorsCallback'];
$data['errorsCallback'] = static fn (): array => $transformer->absolutizeErrors($errorsCallback());
$locallyIgnoredErrorsCallback = $data['locallyIgnoredErrorsCallback'];
$data['locallyIgnoredErrorsCallback'] = static fn (): array => $transformer->absolutizeErrors($locallyIgnoredErrorsCallback());
$exportedNodesCallback = $data['exportedNodesCallback'];
$data['exportedNodesCallback'] = static fn (): array => $transformer->absolutizeFileKeyed($exportedNodesCallback());
$cachedExportedNodes = CachedExportedNodes::createEmpty();
if (array_key_exists('exportedNodesLocations', $data) && is_array($data['exportedNodesLocations'])) {
$exportedNodesLocations = [];
foreach ($data['exportedNodesLocations'] as $exportedNodesFile => $exportedNodesLocation) {
$exportedNodesLocations[$transformer->absolutizePath($exportedNodesFile)] = $exportedNodesLocation;
}
$cachedExportedNodes = CachedExportedNodes::createFromFile($data['cacheFileHandle'], $exportedNodesLocations);
}
// The stub file hashes get into the meta only at save time, after the analysis - the
// only point where the StubFilesExtensions may run, because they can rely on
// bootstrapFiles having been executed. Restoring must not run them, so the entry is
// taken out of the cached meta here; the freshly computed meta below never contains it,
// and isMetaDifferent() never sees it on either side. Instead, the recorded hashes are
// verified after the meta comparison. A different list coming from an extension is not
// detected - projectExtensionFiles cover that.
$cachedStubFiles = [];
if (array_key_exists('stubFiles', $data['meta']) && is_array($data['meta']['stubFiles'])) {
/** @var array<string, string> $cachedStubFiles */
$cachedStubFiles = $data['meta']['stubFiles'];
unset($data['meta']['stubFiles']);
}
$meta = $this->getMeta($allAnalysedFiles, $projectConfigArray);
// absolutized above, so it is always present here
$packageDependencies = $data['packageDependencies'];
$packageSeededFiles = [];
$notAnalysedFileSymbolsChanged = false;
// Scanned files whose exported nodes differ from the ones the cache holds - the only ones that
// make anything be re-analysed. A scanned file can change without any of its symbols changing,
// and then nothing here has to happen at all.
$scannedFilesWithChangedSymbols = [];
$metaDifferent = $this->isMetaDifferent($data['meta'], $meta);
if ($metaDifferent) {
$diffs = $this->getMetaKeyDifferences($data['meta'], $meta);
// Some metadata differences do not invalidate the whole analysis, because the code they
// affect can be pinpointed: a Composer lock change only affects the files depending on a
// package whose version changed, a different selected version of a PHP extension only the
// files using that extension, and a scanned file change only affects the files depending
// on that file. Any other difference falls back to a full re-analysis.
if (array_diff($diffs, self::PARTIALLY_INVALIDATING_META_KEYS) !== []) {
return $this->fullAnalysis(
'Result cache not used because the metadata do not match: ' . implode(', ', $diffs),
$allAnalysedFiles,
$meta,
$currentFileHashes,
$output,
);
}
if (in_array('scannedFiles', $diffs, true)) {
// Files that are scanned but not analysed are recorded as regular file dependencies
// (DependencyResolver::resolveFileDependencies()) along with their exported nodes, so the
// loop over the not-analysed files below treats an edited one the way the loop above
// treats an analysed file: the files depending on it are re-analysed only when its
// exported nodes changed, and it is that loop which counts it as changed. What is left
// for the metadata to notice is a file the dependency graph does not know: one that
// appeared, or an edited one nothing depends on. Either may define a symbol that was
// reported as unknown somewhere - if it declares any symbol at all - so the files with
// errors are re-analysed the same way a new analysed file makes them re-analysed. The
// previous nodes of a file nothing depends on are not kept, so for it "declares a symbol"
// stands in for "declares a new symbol".
$changedScannedFiles = $this->getChangedScannedFiles($data['meta'], $meta);
foreach ($changedScannedFiles as $changedScannedFile => $change) {
// The scanned files getFileAndPackageDependencies() leaves out: nothing would
// re-analyse the files depending on them, so the whole cache has to go.
$notTrackedReason = null;
if (str_starts_with($changedScannedFile, 'phar://')) {
$notTrackedReason = 'is inside a PHAR';
} elseif ($this->packageDependencyResolver->resolvePackage($changedScannedFile) !== null) {
$notTrackedReason = 'belongs to a Composer package';
}
if ($notTrackedReason !== null) {
return $this->fullAnalysis(
sprintf('Result cache not used because scanned file %s changed and %s.', $changedScannedFile, $notTrackedReason),
$allAnalysedFiles,
$meta,
$currentFileHashes,
$output,
);
}
if ($change === self::SCANNED_FILE_GONE) {
continue;
}
if ($change === self::SCANNED_FILE_EDITED && array_key_exists($changedScannedFile, $data['dependencies'])) {
continue;
}
if ($this->exportedNodeFetcher->fetchNodes($changedScannedFile) === []) {
continue;
}
$scannedFilesWithChangedSymbols[$changedScannedFile] = true;
$notAnalysedFileSymbolsChanged = true;
}
}
// The generated container and the analysis are unchanged except for code coming from packages
// whose version actually changed. Re-analyse just the files depending on a changed package
// instead of everything; the existing incremental loop below then propagates to their
// dependents on signature change. An undetermined change set (installed.php cannot be parsed)
// falls back to a full re-analysis.
$composerMetaChanged = array_intersect($diffs, ['composerLocks', 'composerInstalled']) !== [];
$changedPackages = $composerMetaChanged
? $this->packageDependencyResolver->getChangedComposerPackages($data['meta'], $meta)
: [];
// The same goes for a PHP extension whose stubs differ between its major versions: the files
// using its symbols recorded a dependency on its platform package (ext-<name>), so switching
// the version composer.json selects re-analyses just those.
$changedExtensionPackages = in_array('extensionVersions', $diffs, true)
? $this->packageDependencyResolver->getChangedExtensionPackages($data['meta'], $meta)
: [];
if ($changedPackages === null || $changedExtensionPackages === null) {
return $this->fullAnalysis(
'Result cache not used because the metadata do not match: ' . implode(', ', $diffs),
$allAnalysedFiles,
$meta,
$currentFileHashes,
$output,
);
}
if ($composerMetaChanged && $changedPackages === []) {
// The Composer lock/installed metadata changed but no installed package's version or
// reference did (e.g. a composer.lock regenerated with different formatting or dist/time
// metadata, common in CI where composer.lock is not committed). Nothing analysis-relevant
// changed, so keep the restored cache and fall through to the normal incremental analysis
// instead of re-analysing everything.
if ($output->isVeryVerbose()) {
$output->writeLineFormatted('Composer metadata changed but no package versions changed; keeping the result cache.');
}
} elseif ($changedPackages !== []) {
$changedPackagesLookup = array_fill_keys($changedPackages, true);
if ($this->changedPackagesProvideContainerClass($projectConfigArray, $changedPackagesLookup)) {
// One of the changed packages registers a class in the PHPStan container (a rule,
// extension, and so on). Such code can affect the analysis of every file, not just the
// files that reference it, so the file-granular re-seed below is not enough - re-analyse
// everything.
return $this->fullAnalysis(
sprintf(
'Composer packages changed (%s) and register a class in the container; re-analysing everything.',
implode(', ', $changedPackages),
),
$allAnalysedFiles,
$meta,
$currentFileHashes,
$output,
);
}
if ($output->isVeryVerbose()) {
$output->writeLineFormatted(sprintf(
'Composer packages changed (%s); re-analysing the files depending on them and the files with errors.',
implode(', ', $changedPackages),
));
}
}
if ($changedExtensionPackages !== [] && $output->isVeryVerbose()) {
$output->writeLineFormatted(sprintf(
'PHP extension versions changed (%s); re-analysing the files depending on them and the files with errors.',
implode(', ', $changedExtensionPackages),
));
}
if ($changedPackages !== [] || $changedExtensionPackages !== []) {
// The files depending on a changed package are seeded below, but a file whose error is
// that a class does not exist depends on nothing: there was no file to record an edge to.
// An installed package is exactly where such a class tends to arrive from - Composer
// unpacking a new version, or a plugin writing into vendor/ while it does - and so is a
// newly selected extension version, so the files with errors are re-analysed too, the
// same way a new analysed file makes them.
$notAnalysedFileSymbolsChanged = true;
$changedPackagesLookup = array_fill_keys(array_merge($changedPackages, $changedExtensionPackages), true);
foreach ($packageDependencies as $packageDependentFile => $filePackages) {
foreach ($filePackages as $filePackage) {
if (isset($changedPackagesLookup[$filePackage])) {
$packageSeededFiles[] = $packageDependentFile;
break;
}
}
}
}
}
$daysOldForSkip = $this->skipResultCacheIfOlderThanDays;
if (time() - $data['lastFullAnalysisTime'] >= 60 * 60 * 24 * $daysOldForSkip) {
return $this->fullAnalysis(
sprintf("Result cache not used because it's more than %d days since last full analysis.", $daysOldForSkip),
$allAnalysedFiles,
$meta,
$currentFileHashes,
$output,
);
}
/**
* 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
* used to only earn a "the result cache will get stale" warning, which was true and left the user
* to do something about it.
*
* @var string $fileHash
*/
foreach ($data['projectExtensionFiles'] as $extensionFile => [$fileHash]) {
if (!is_file($extensionFile)) {
return $this->fullAnalysis(
sprintf('Result cache not used because extension file %s was not found.', $extensionFile),
$allAnalysedFiles,
$meta,
$currentFileHashes,
$output,
);
}
if ($this->getFileHash($extensionFile) === $fileHash) {
continue;
}
return $this->fullAnalysis(
sprintf('Result cache not used because extension file %s hash does not match.', $extensionFile),
$allAnalysedFiles,
$meta,
$currentFileHashes,
$output,
);
}
// A stub file the analysed files depend on (DependencyResolver records one for each symbol it
// declares) is handled by the loop over the not-analysed files below: a changed PHPDoc in it
// re-analyses the files using its symbols. A stub file nothing depends on gives nothing to go
// by, and a stub file that is analysed as well is never handled by that loop, so a change in
// either still invalidates the whole cache.
$changedStubFiles = [];
foreach ($cachedStubFiles as $stubFile => $stubFileHash) {
if (!is_file($stubFile)) {
return $this->fullAnalysis(
sprintf('Result cache not used because stub file %s was not found.', $stubFile),
$allAnalysedFiles,
$meta,
$currentFileHashes,
$output,
);
}
if ($this->getFileHash($stubFile) === $stubFileHash) {
continue;
}
if (array_key_exists($stubFile, $data['dependencies']) && !array_key_exists($stubFile, $currentFileHashes)) {
$changedStubFiles[$stubFile] = true;
continue;
}
return $this->fullAnalysis(
sprintf('Result cache not used because stub file %s hash does not match.', $stubFile),
$allAnalysedFiles,
$meta,
$currentFileHashes,
$output,
);
}
$invertedDependencies = $data['dependencies'];
// Every file the cached graph knows about; the loop over the analysed files below takes out the
// ones that are still analysed, leaving the deleted files and the files that are depended on
// without being analysed themselves (scanned files, other project files).
$notAnalysedFiles = array_fill_keys(array_keys($invertedDependencies), true);
$filesToAnalyse = [];
$invertedDependenciesToReturn = [];
$invertedUsedTraitDependenciesToReturn = [];
$linesToIgnore = $data['linesToIgnore'];
$unmatchedLineIgnores = $data['unmatchedLineIgnores'];
$collectedDataIndex = $data['collectedDataIndex'];
try {
// The cached objects are reconstructed here, and a cache written by a PHPStan whose classes
// have since changed can fail at it: a property the payload does not carry stays
// uninitialized, and reading it throws. The cacheVersion and phpstanVersion in the metadata
// keep a released version away from another release's objects, but a source checkout keeps
// one phpstanVersion across every edit of these classes, so this is reachable there. A cache
// that cannot be reconstructed is discarded like any other unusable one.
$errors = $data['errorsCallback']();
$locallyIgnoredErrors = $data['locallyIgnoredErrorsCallback']();
$exportedNodes = $data['exportedNodesCallback']();
$collectedDataRuleResults = $data['collectedDataRuleResults'] ?? [];
if (!is_array($collectedDataRuleResults)) {
throw new RuntimeException('The cached results of the rules on collected data are not an array.');
}
foreach ($collectedDataRuleResults as $collectedDataRuleResult) {
if (!$collectedDataRuleResult instanceof CollectedDataRuleResult) {
throw new RuntimeException('A cached result of a rule on collected data is not a CollectedDataRuleResult.');
}
}
$collectedDataRuleResults = $transformer->absolutizeCollectedDataRuleResults($collectedDataRuleResults);
$storedFilesWithCollectedDataRuleErrors = $data['filesWithCollectedDataRuleErrors'] ?? [];
if (!is_array($storedFilesWithCollectedDataRuleErrors)) {
throw new RuntimeException('The cached files with errors of the rules on collected data are not an array.');
}
$filesWithCollectedDataRuleErrors = [];
foreach ($storedFilesWithCollectedDataRuleErrors as $fileWithCollectedDataRuleError) {
if (!is_string($fileWithCollectedDataRuleError)) {
throw new RuntimeException('A cached file with errors of the rules on collected data is not a string.');
}
$filesWithCollectedDataRuleErrors[] = $transformer->absolutizePath($fileWithCollectedDataRuleError);
}
} catch (Throwable $e) {
@unlink($cacheFilePath);
return $this->fullAnalysis(
sprintf('Result cache not used because the cached results could not be read back: %s', $e->getMessage()),
$allAnalysedFiles,
$meta,
$currentFileHashes,
$output,
);
}
$filteredErrors = [];
$filteredLocallyIgnoredErrors = [];
$filteredLinesToIgnore = [];
$filteredUnmatchedLineIgnores = [];
$filteredCollectedDataIndex = [];
$filteredExportedNodes = [];
// the analysed and scanned files whose exported nodes stay in the old cache file until save()
$keptCachedExportedNodes = [];
$newFileAppeared = false;
$dependencyFilesChanged = false;
$cachedExportedNodesError = null;
$decodeCachedExportedNodes = static function (string $file) use ($cachedExportedNodes, &$cachedExportedNodesError): array {
try {
return $cachedExportedNodes->decode($file);
} catch (CachedExportedNodesUnreadableException $e) {
$cachedExportedNodesError ??= $e->getMessage();
return [];
}
};
foreach (array_keys($cachedStubFiles) as $stubFile) {
if (!array_key_exists($stubFile, $errors)) {
continue;
}
$filteredErrors[$stubFile] = $errors[$stubFile];
}
// Whether a changed file's exported nodes changed decides what else has to be re-analysed with
// it: its dependents, the classes using a trait it declares, and - when a symbol appeared or
// disappeared - the files with errors. Finding out means parsing the file, serially here in
// the main process. When all of those files are re-analysed anyway, because they changed
// themselves, the answer cannot add anything and the file is not parsed. After a branch switch
// or a formatting run that touched most of the project, that is most of the changed files -
// on Drupal core with every file edited, 9 seconds of parsing, and workers forked from a main
// process bloated by the parsed files.
$changedAnalysedFiles = [];
foreach ($allAnalysedFiles as $analysedFile) {
if (
array_key_exists($analysedFile, $invertedDependencies)
&& $invertedDependencies[$analysedFile]['fileHash'] === $currentFileHashes[$analysedFile]
) {
continue;
}
$changedAnalysedFiles[$analysedFile] = true;
}
// The errors of the rules on CollectedDataNode are not in $errors: those rules run on the merged
// collected data on every run, a cached copy would be reported twice. But such an error waits
// for a symbol to appear just like any other, and re-analysing its file renews the file's share
// of the collected data.
$cachedFilesWithCollectedDataRuleErrors = array_fill_keys($filesWithCollectedDataRuleErrors, true);
$filteredFilesWithCollectedDataRuleErrors = [];
// only the stub files with errors so far, which are never analysed
$filesWithErrorsAllChanged = $filteredErrors === [];
foreach ($allAnalysedFiles as $analysedFile) {
if (
(!array_key_exists($analysedFile, $errors) && !array_key_exists($analysedFile, $cachedFilesWithCollectedDataRuleErrors))
|| array_key_exists($analysedFile, $changedAnalysedFiles)
) {
continue;
}
$filesWithErrorsAllChanged = false;
break;
}
foreach ($allAnalysedFiles as $analysedFile) {
if (array_key_exists($analysedFile, $errors)) {
$filteredErrors[$analysedFile] = $errors[$analysedFile];
}
if (array_key_exists($analysedFile, $cachedFilesWithCollectedDataRuleErrors)) {
$filteredFilesWithCollectedDataRuleErrors[] = $analysedFile;
}
if (array_key_exists($analysedFile, $locallyIgnoredErrors)) {
$filteredLocallyIgnoredErrors[$analysedFile] = $locallyIgnoredErrors[$analysedFile];
}
if (array_key_exists($analysedFile, $linesToIgnore)) {
$filteredLinesToIgnore[$analysedFile] = $linesToIgnore[$analysedFile];
}
if (array_key_exists($analysedFile, $unmatchedLineIgnores)) {
$filteredUnmatchedLineIgnores[$analysedFile] = $unmatchedLineIgnores[$analysedFile];
}
if (array_key_exists($analysedFile, $collectedDataIndex)) {
$filteredCollectedDataIndex[$analysedFile] = $collectedDataIndex[$analysedFile];
}
if (array_key_exists($analysedFile, $exportedNodes)) {
$filteredExportedNodes[$analysedFile] = $exportedNodes[$analysedFile];
} elseif ($cachedExportedNodes->has($analysedFile)) {
$keptCachedExportedNodes[$analysedFile] = true;
}
if (!array_key_exists($analysedFile, $invertedDependencies)) {
// new file
$filesToAnalyse[] = $analysedFile;
$newFileAppeared = true;
continue;
}
unset($notAnalysedFiles[$analysedFile]);
$analysedFileData = $invertedDependencies[$analysedFile];
$cachedFileHash = $analysedFileData['fileHash'];
$dependentFiles = $analysedFileData['dependentFiles'];
$invertedDependenciesToReturn[$analysedFile] = $dependentFiles;
$usedTraitDependentFiles = $analysedFileData['usedTraitDependentFiles'] ?? [];
if (count($usedTraitDependentFiles) > 0) {
$invertedUsedTraitDependenciesToReturn[$analysedFile] = $usedTraitDependentFiles;
}
$currentFileHash = $currentFileHashes[$analysedFile];
if ($cachedFileHash === $currentFileHash) {
continue;
}
$filesToAnalyse[] = $analysedFile;
if (
($filesWithErrorsAllChanged || $newFileAppeared)
&& $this->areAllChanged($dependentFiles, $changedAnalysedFiles)
&& $this->areAllChanged($usedTraitDependentFiles, $changedAnalysedFiles)
) {
continue;
}
// A file that declared nothing has no entry at all - save() only writes one for a file with
// at least one exported node - and a missing entry is not the same as nothing to propagate:
// the file may have gained its first symbol, which is exactly what the files with errors are
// waiting for. Comparing against an empty list says so, and says nothing changed when the
// file still declares nothing.
$cachedFileExportedNodes = $filteredExportedNodes[$analysedFile]
?? ($cachedExportedNodes->has($analysedFile) ? $decodeCachedExportedNodes($analysedFile) : []);
$exportedNodesChanged = $this->exportedNodesChanged($analysedFile, $cachedFileExportedNodes);
if ($exportedNodesChanged === null) {
if (count($cachedFileExportedNodes) === 0) {
continue;
}
if (!$this->hasTraitNode($cachedFileExportedNodes)) {
continue;
}
// if the file changed but no exported nodes changed and the file contains a trait
// reanalyse files with classes using that trait
// but not other dependent files (a body-only change of a non-trait symbol
// in the same file does not affect its dependents)
foreach ($usedTraitDependentFiles as $usedTraitDependentFile) {
if (!is_file($usedTraitDependentFile)) {
continue;
}
$filesToAnalyse[] = $usedTraitDependentFile;
}
continue;
}
if ($exportedNodesChanged) {
$newFileAppeared = true;
}
foreach ($dependentFiles as $dependentFile) {
if (!is_file($dependentFile)) {
continue;
}
$filesToAnalyse[] = $dependentFile;
}
}
// in the order process() compares them in
sort($filteredFilesWithCollectedDataRuleErrors);
// the freshly computed metadata, so a file that stopped being scanned is not counted
$scannedFiles = is_array($meta['scannedFiles'] ?? null) ? $meta['scannedFiles'] : [];
$stubFileWithChangedSymbols = null;
foreach (array_keys($notAnalysedFiles) as $notAnalysedFile) {
if (!array_key_exists($notAnalysedFile, $invertedDependencies)) {
continue;
}
$notAnalysedFileData = $invertedDependencies[$notAnalysedFile];
$dependentFiles = $notAnalysedFileData['dependentFiles'];
$usedTraitDependentFiles = $notAnalysedFileData['usedTraitDependentFiles'] ?? [];
$wasMissing = $notAnalysedFileData['fileHash'] === self::MISSING_FILE_HASH;
if (!is_file($notAnalysedFile) && $wasMissing) {
// A path that was already missing when the cache was written, and still is: nothing
// changed, but it stays watched so that creating it re-analyses the files naming it.
$invertedDependenciesToReturn[$notAnalysedFile] = $dependentFiles;
if (count($usedTraitDependentFiles) > 0) {
$invertedUsedTraitDependenciesToReturn[$notAnalysedFile] = $usedTraitDependentFiles;
}
continue;
}
if (is_file($notAnalysedFile) && $wasMissing) {
$dependencyFilesChanged = true;
// It exists now. Whether it holds any symbol is beside the point - a file that was named
// and was not there is now there, and that alone changes what the analysis says.
$invertedDependenciesToReturn[$notAnalysedFile] = $dependentFiles;
if (count($usedTraitDependentFiles) > 0) {
$invertedUsedTraitDependenciesToReturn[$notAnalysedFile] = $usedTraitDependentFiles;
}
$dependentFiles = array_merge($dependentFiles, $usedTraitDependentFiles);
} elseif (is_file($notAnalysedFile)) {
// Not analysed but still on disk: a scanned file, or another project file the analysed
// code depends on. Its edges and exported nodes are not carried over by the loop above
// (that one only walks the analysed files), so they are preserved here.
$invertedDependenciesToReturn[$notAnalysedFile] = $dependentFiles;
if (count($usedTraitDependentFiles) > 0) {
$invertedUsedTraitDependenciesToReturn[$notAnalysedFile] = $usedTraitDependentFiles;
}
if ($this->getFileHash($notAnalysedFile) === $notAnalysedFileData['fileHash']) {
if (array_key_exists($notAnalysedFile, $exportedNodes)) {
$filteredExportedNodes[$notAnalysedFile] = $exportedNodes[$notAnalysedFile];
} elseif ($cachedExportedNodes->has($notAnalysedFile)) {
$keptCachedExportedNodes[$notAnalysedFile] = true;
}
continue;
}
$cachedFileExportedNodes = $exportedNodes[$notAnalysedFile]
?? ($cachedExportedNodes->has($notAnalysedFile) ? $decodeCachedExportedNodes($notAnalysedFile) : null);
$dependencyFilesChanged = true;
// Edited: the same rule as for an analysed file. Nothing the files depending on it can
// see changed unless its exported nodes did, so a body-only edit re-analyses nothing -
// except the classes using a trait declared here, whose body is analysed in their
// context. The fresh nodes replace the cached ones: the file is never analysed, so this is
// the only place they come from once the file is in the graph.
$fileExportedNodes = $this->exportedNodeFetcher->fetchNodes($notAnalysedFile);
$filteredExportedNodes[$notAnalysedFile] = $fileExportedNodes;
$exportedNodesChanged = $cachedFileExportedNodes !== null
? $this->compareExportedNodes($cachedFileExportedNodes, $fileExportedNodes)
: true;
if ($exportedNodesChanged === null) {
if (!$this->hasTraitNode($fileExportedNodes)) {
continue;
}
foreach ($usedTraitDependentFiles as $usedTraitDependentFile) {
if (!is_file($usedTraitDependentFile)) {
continue;
}