Skip to content

Commit 35e4c77

Browse files
committed
Test the collected data section through save() and restore()
A cache written by a full analysis is restored with the index of every file, and the index points at the entries in the file. A warm save that copies every entry writes a byte-identical file. A partial save writes the same bytes a full analysis of the merged data would. A value damaged in place passes restore() and is discarded with the file when read. A truncated section and the previous entry framing are discarded on restore(). LazyCollectedDataTest does not need the container.
1 parent e902c07 commit 35e4c77

2 files changed

Lines changed: 294 additions & 2 deletions

File tree

‎tests/PHPStan/Analyser/ResultCache/LazyCollectedDataTest.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44

55
use PHPStan\Rules\DeadCode\MethodWithoutImpurePointsCollector;
66
use PHPStan\ShouldNotHappenException;
7-
use PHPStan\Testing\PHPStanTestCase;
7+
use PHPUnit\Framework\TestCase;
88
use function array_keys;
99

10-
class LazyCollectedDataTest extends PHPStanTestCase
10+
class LazyCollectedDataTest extends TestCase
1111
{
1212

1313
private int $reads = 0;
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser\ResultCache;
4+
5+
use Override;
6+
use PHPStan\Analyser\AnalyserResult;
7+
use PHPStan\Command\Output;
8+
use PHPStan\Command\Symfony\SymfonyOutput;
9+
use PHPStan\Command\Symfony\SymfonyStyle;
10+
use PHPStan\Dependency\ExportedNodeFetcher;
11+
use PHPStan\Dependency\PackageDependencyResolver;
12+
use PHPStan\File\FileHelper;
13+
use PHPStan\Php\ComposerPhpVersionFactory;
14+
use PHPStan\Php\PhpVersion;
15+
use PHPStan\PhpDoc\StubFilesProvider;
16+
use PHPStan\Rules\DeadCode\MethodWithoutImpurePointsCollector;
17+
use PHPStan\Testing\PHPStanTestCase;
18+
use RuntimeException;
19+
use Symfony\Component\Console\Input\StringInput;
20+
use Symfony\Component\Console\Output\NullOutput;
21+
use Symfony\Component\Console\Style\SymfonyStyle as SymfonyConsoleStyle;
22+
use function array_keys;
23+
use function count;
24+
use function file_get_contents;
25+
use function file_put_contents;
26+
use function is_file;
27+
use function md5_file;
28+
use function mkdir;
29+
use function rmdir;
30+
use function str_replace;
31+
use function strlen;
32+
use function strpos;
33+
use function substr;
34+
use function sys_get_temp_dir;
35+
use function uniqid;
36+
use function unlink;
37+
38+
class ResultCacheManagerCollectedDataTest extends PHPStanTestCase
39+
{
40+
41+
private string $directory;
42+
43+
/** @var list<string> */
44+
private array $files = [];
45+
46+
#[Override]
47+
protected function setUp(): void
48+
{
49+
parent::setUp();
50+
$this->directory = sys_get_temp_dir() . '/phpstan-collected-data-' . uniqid();
51+
// the cache file must not be in the analysed directory, or it counts as a scanned file
52+
mkdir($this->directory . '/src', 0777, true);
53+
foreach (['a', 'b', 'c'] as $name) {
54+
$file = $this->directory . '/src/' . $name . '.php';
55+
file_put_contents($file, '<?php class Cache' . $name . " {}\n");
56+
$this->files[] = $file;
57+
}
58+
}
59+
60+
#[Override]
61+
protected function tearDown(): void
62+
{
63+
parent::tearDown();
64+
foreach ($this->files as $file) {
65+
@unlink($file);
66+
}
67+
@unlink($this->cacheFilePath());
68+
@rmdir($this->directory . '/src');
69+
@rmdir($this->directory);
70+
}
71+
72+
public function testCachedEntriesAreIndexedAndReadBack(): void
73+
{
74+
$data = $this->collectedData(['a', 'b', 'c']);
75+
$this->saveFullAnalysis($data);
76+
77+
$resultCache = $this->createManager()->restore($this->files, false, false, null, $this->nullOutput());
78+
$this->assertFalse($resultCache->isFullAnalysis());
79+
$this->assertSame([], $resultCache->getFilesToAnalyse());
80+
81+
$lazy = $resultCache->getCollectedData();
82+
$this->assertSame($this->files, array_keys($lazy->getCachedIndex()));
83+
$this->assertSame([], $lazy->getFresh());
84+
$this->assertSame($data, $lazy->toArray());
85+
86+
$contents = file_get_contents($this->cacheFilePath());
87+
$this->assertNotFalse($contents);
88+
foreach ($lazy->getCachedIndex() as $file => [$offset, $length]) {
89+
$entry = substr($contents, $offset, $length);
90+
$this->assertStringContainsString('"' . str_replace($this->directory . '/', '', $file) . '"', $entry);
91+
$this->assertStringEndsWith('}', $entry);
92+
}
93+
}
94+
95+
public function testWarmSaveCopiesEveryEntryByteForByte(): void
96+
{
97+
$this->saveFullAnalysis($this->collectedData(['a', 'b', 'c']));
98+
$before = md5_file($this->cacheFilePath());
99+
100+
$manager = $this->createManager();
101+
$resultCache = $manager->restore($this->files, false, false, null, $this->nullOutput());
102+
$processed = $manager->process($this->analyserResult([]), $resultCache, $this->nullOutput(), false, true);
103+
$this->assertTrue($processed->isSaved());
104+
$this->assertSame($before, md5_file($this->cacheFilePath()));
105+
$this->assertSame($this->collectedData(['a', 'b', 'c']), $processed->getAnalyserResult()->getCollectedData());
106+
}
107+
108+
public function testPartialSaveEqualsFreshSaveOfMergedData(): void
109+
{
110+
$this->saveFullAnalysis($this->collectedData(['a', 'b', 'c']));
111+
112+
file_put_contents($this->files[1], "<?php class Cacheb { public function m(): void {} }\n");
113+
$manager = $this->createManager();
114+
$resultCache = $manager->restore($this->files, false, false, null, $this->nullOutput());
115+
$this->assertSame([$this->files[1]], $resultCache->getFilesToAnalyse());
116+
117+
$freshB = [$this->files[1] => [MethodWithoutImpurePointsCollector::class => ['fresh b']]];
118+
$processed = $manager->process($this->analyserResult($freshB), $resultCache, $this->nullOutput(), false, true);
119+
$this->assertTrue($processed->isSaved());
120+
121+
// cached entries come first, the re-analysed file after them
122+
$expected = $this->collectedData(['a', 'c', 'b']);
123+
$expected[$this->files[1]] = $freshB[$this->files[1]];
124+
$this->assertSame($expected, $processed->getAnalyserResult()->getCollectedData());
125+
126+
$partial = md5_file($this->cacheFilePath());
127+
$this->saveFullAnalysis($expected);
128+
$this->assertSame($partial, md5_file($this->cacheFilePath()));
129+
}
130+
131+
public function testDamagedValueDiscardsTheCacheWhenRead(): void
132+
{
133+
$this->saveFullAnalysis($this->collectedData(['a', 'b', 'c']));
134+
$this->breakFirstCollectedDataValue();
135+
136+
$resultCache = $this->createManager()->restore($this->files, false, false, null, $this->nullOutput());
137+
$this->assertFalse($resultCache->isFullAnalysis(), 'the framing is intact, only a value is damaged');
138+
139+
try {
140+
$resultCache->getCollectedData()->toArray();
141+
$this->fail('The damaged value should not decode.');
142+
} catch (RuntimeException $e) {
143+
$this->assertStringContainsString('could not be read back and was discarded', $e->getMessage());
144+
}
145+
146+
$this->assertFalse(is_file($this->cacheFilePath()));
147+
}
148+
149+
public function testTruncatedSectionIsDiscardedOnRestore(): void
150+
{
151+
$this->saveFullAnalysis($this->collectedData(['a', 'b', 'c']));
152+
$contents = file_get_contents($this->cacheFilePath());
153+
$this->assertNotFalse($contents);
154+
$position = strpos($contents, "collectedData* 3\n");
155+
$this->assertNotFalse($position);
156+
file_put_contents($this->cacheFilePath(), substr($contents, 0, $position + strlen("collectedData* 3\n") + 20));
157+
158+
$resultCache = $this->createManager()->restore($this->files, false, false, null, $this->nullOutput());
159+
$this->assertTrue($resultCache->isFullAnalysis());
160+
$this->assertFalse(is_file($this->cacheFilePath()));
161+
}
162+
163+
public function testPreviousEntryFramingIsDiscarded(): void
164+
{
165+
$this->saveFullAnalysis($this->collectedData(['a']));
166+
$contents = file_get_contents($this->cacheFilePath());
167+
$this->assertNotFalse($contents);
168+
169+
// the 2.2.13 framing: one length per entry, the key inside the payload
170+
$position = strpos($contents, "collectedData* 1\n");
171+
$this->assertNotFalse($position);
172+
$entryStart = $position + strlen("collectedData* 1\n");
173+
$headerEnd = strpos($contents, "\n", $entryStart);
174+
$this->assertNotFalse($headerEnd);
175+
file_put_contents($this->cacheFilePath(), substr($contents, 0, $entryStart) . '42' . substr($contents, $headerEnd));
176+
177+
$resultCache = $this->createManager()->restore($this->files, false, false, null, $this->nullOutput());
178+
$this->assertTrue($resultCache->isFullAnalysis());
179+
}
180+
181+
/**
182+
* @param list<string> $names
183+
* @return array<string, array<class-string<MethodWithoutImpurePointsCollector>, list<string>>>
184+
*/
185+
private function collectedData(array $names): array
186+
{
187+
$data = [];
188+
foreach ($names as $name) {
189+
$data[$this->directory . '/src/' . $name . '.php'] = [MethodWithoutImpurePointsCollector::class => ['usage in ' . $name]];
190+
}
191+
192+
return $data;
193+
}
194+
195+
/**
196+
* @param array<string, array<class-string<MethodWithoutImpurePointsCollector>, list<string>>> $collectedData
197+
*/
198+
private function saveFullAnalysis(array $collectedData): void
199+
{
200+
@unlink($this->cacheFilePath());
201+
$manager = $this->createManager();
202+
$resultCache = $this->createManager()->restore($this->files, false, false, null, $this->nullOutput());
203+
$this->assertTrue($resultCache->isFullAnalysis());
204+
205+
$processed = $manager->process($this->analyserResult($collectedData), $resultCache, $this->nullOutput(), false, true);
206+
$this->assertTrue($processed->isSaved());
207+
$this->assertCount(count($collectedData), $processed->getAnalyserResult()->getCollectedData());
208+
}
209+
210+
/**
211+
* @param array<string, array<class-string<MethodWithoutImpurePointsCollector>, list<string>>> $collectedData
212+
*/
213+
private function analyserResult(array $collectedData): AnalyserResult
214+
{
215+
$dependencies = [];
216+
foreach ($this->files as $file) {
217+
$dependencies[$file] = [];
218+
}
219+
220+
return new AnalyserResult(
221+
unorderedErrors: [],
222+
filteredPhpErrors: [],
223+
allPhpErrors: [],
224+
locallyIgnoredErrors: [],
225+
linesToIgnore: [],
226+
unmatchedLineIgnores: [],
227+
internalErrors: [],
228+
collectedData: LazyCollectedData::fromArray($collectedData),
229+
dependencies: $dependencies,
230+
usedTraitDependencies: [],
231+
packageDependencies: [],
232+
exportedNodes: [],
233+
reachedInternalErrorsCountLimit: false,
234+
peakMemoryUsageBytes: 0,
235+
processedFiles: $this->files,
236+
);
237+
}
238+
239+
private function breakFirstCollectedDataValue(): void
240+
{
241+
$contents = file_get_contents($this->cacheFilePath());
242+
$this->assertNotFalse($contents);
243+
$position = strpos($contents, 's:10:"usage in a"');
244+
$this->assertNotFalse($position);
245+
// the declared length no longer matches the body, the frame keeps its size
246+
file_put_contents($this->cacheFilePath(), substr($contents, 0, $position) . 's:11:' . substr($contents, $position + 5));
247+
}
248+
249+
private function createManager(): ResultCacheManager
250+
{
251+
$container = self::getContainer();
252+
253+
return new ResultCacheManager(
254+
resultCacheMetaExtensions: $container->getExtensionsCollection(ResultCacheMetaExtension::class),
255+
exportedNodeFetcher: $container->getByType(ExportedNodeFetcher::class),
256+
scanFileFinder: $container->getService('fileFinderScan'),
257+
stubFilesProvider: $container->getByType(StubFilesProvider::class),
258+
fileHelper: $container->getByType(FileHelper::class),
259+
packageDependencyResolver: $container->getByType(PackageDependencyResolver::class),
260+
cacheFilePath: $this->cacheFilePath(),
261+
analysedPaths: [$this->directory . '/src'],
262+
analysedPathsFromConfig: [],
263+
composerAutoloaderProjectPaths: [],
264+
usedLevel: '8',
265+
cliAutoloadFile: null,
266+
bootstrapFiles: [],
267+
scanFiles: [],
268+
scanDirectories: [],
269+
configStubFiles: [],
270+
fileReplacements: [],
271+
checkDependenciesOfProjectExtensionFiles: false,
272+
parametersNotInvalidatingCache: [],
273+
skipResultCacheIfOlderThanDays: 7,
274+
anchorDirectory: $this->directory,
275+
phpVersion: $container->getByType(PhpVersion::class),
276+
composerPhpVersionFactory: $container->getByType(ComposerPhpVersionFactory::class),
277+
);
278+
}
279+
280+
private function cacheFilePath(): string
281+
{
282+
return $this->directory . '/resultCache.php';
283+
}
284+
285+
private function nullOutput(): Output
286+
{
287+
$symfonyOutput = new NullOutput();
288+
289+
return new SymfonyOutput($symfonyOutput, new SymfonyStyle(new SymfonyConsoleStyle(new StringInput(''), $symfonyOutput)));
290+
}
291+
292+
}

0 commit comments

Comments
 (0)