diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 5175c27..992b8cc 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -148,7 +148,7 @@ public function analyse( $classNodes = $this->withRecursiveParents($classNodes); if ($hasExtendedClassAwareRule) { - $this->markExtendedClasses($classNodes); + $this->markExtendedClasses($classNodes, $extractionResult); } $fileAnalysisProvider = new FileAnalysisProvider($extractionResult->fileAnalyses); @@ -663,7 +663,7 @@ private function dependenciesForInheritanceDependency( * * @param list $classNodes */ - private function markExtendedClasses(array $classNodes): void + private function markExtendedClasses(array $classNodes, ExtractionResult $extractionResult): void { $extended = []; @@ -673,6 +673,14 @@ private function markExtendedClasses(array $classNodes): void } } + // Anonymous classes (`new class extends Foo {}`) have no ClassNode of + // their own, so the classes they extend are tracked separately. + foreach ($extractionResult->anonymousClassNodes as $anonymousClassNode) { + if ($anonymousClassNode->extends !== null) { + $extended[$anonymousClassNode->extends] = true; + } + } + foreach ($classNodes as $classNode) { // Only classes appear in parentClasses; traits, interfaces, and enums // never do, so they are left with the default (not extended). @@ -836,9 +844,10 @@ private function collectClassNodes( ?AnalyserOptions $analyserOptions = null, bool $withFileAnalysis = true, ): ExtractionResult { - $classNodes = []; - $fileAnalyses = []; - $filesToParse = []; + $classNodes = []; + $fileAnalyses = []; + $anonymousClassNodes = []; + $filesToParse = []; foreach ($files as $file) { if ($withFileAnalysis) { @@ -856,24 +865,32 @@ private function collectClassNodes( $classNodes[] = $cachedClassNode; } + foreach ($cachedResult['anonymousClassNodes'] as $cachedAnonymousClassNode) { + $anonymousClassNodes[] = $cachedAnonymousClassNode; + } + $fileAnalyses[$file] = $cachedResult['fileAnalysis']; continue; } - $cachedClassNodes = $this->analysisResultCache?->loadClassNodes( + $cachedResult = $this->analysisResultCache?->loadClassNodes( $file, $this->classNodeCacheNamespace, ); - if ($cachedClassNodes === null) { + if ($cachedResult === null) { $filesToParse[] = $file; continue; } - foreach ($cachedClassNodes as $cachedClassNode) { + foreach ($cachedResult['classNodes'] as $cachedClassNode) { $classNodes[] = $cachedClassNode; } + + foreach ($cachedResult['anonymousClassNodes'] as $cachedAnonymousClassNode) { + $anonymousClassNodes[] = $cachedAnonymousClassNode; + } } $progressHandler?->start(count($filesToParse)); @@ -881,7 +898,7 @@ private function collectClassNodes( if ($filesToParse === []) { $progressHandler?->finish(); - return new ExtractionResult($classNodes, $fileAnalyses); + return new ExtractionResult($classNodes, $fileAnalyses, $anonymousClassNodes); } $options = $analyserOptions ?? AnalyserOptions::parallel(); @@ -911,6 +928,15 @@ private function collectClassNodes( } } + $anonymousClassNodesByFile = array_fill_keys($filesToParse, []); + foreach ($parsedResult->anonymousClassNodes as $parsedAnonymousClassNode) { + $anonymousClassNodes[] = $parsedAnonymousClassNode; + + if (isset($anonymousClassNodesByFile[$parsedAnonymousClassNode->file])) { + $anonymousClassNodesByFile[$parsedAnonymousClassNode->file][] = $parsedAnonymousClassNode; + } + } + foreach ($parsedResult->fileAnalyses as $file => $fileAnalysis) { $fileAnalyses[$file] = $fileAnalysis; } @@ -921,12 +947,13 @@ private function collectClassNodes( $this->classNodeCacheNamespace, $fileClassNodes, $fileAnalyses[$fileToParse] ?? null, + $anonymousClassNodesByFile[$fileToParse] ?? [], ); } $progressHandler?->finish(); - return new ExtractionResult($classNodes, $fileAnalyses); + return new ExtractionResult($classNodes, $fileAnalyses, $anonymousClassNodes); } /** diff --git a/src/Analyser/AnonymousClassNode.php b/src/Analyser/AnonymousClassNode.php new file mode 100644 index 0000000..dcdac5a --- /dev/null +++ b/src/Analyser/AnonymousClassNode.php @@ -0,0 +1,26 @@ + */ private array $nodes = []; + /** @var list */ + private array $anonymousClassNodes = []; + private string $currentFile = ''; /** @var list */ @@ -126,6 +129,12 @@ public function getNodes(): array return $this->nodes; } + /** @return list */ + public function getAnonymousClassNodes(): array + { + return $this->anonymousClassNodes; + } + public function enterNode(Node $node): null { if ($node instanceof Namespace_) { @@ -174,6 +183,16 @@ public function leaveNode(Node $node): null } if (! $node->name instanceof Identifier) { + // Anonymous classes never become ClassNodes, but the class they + // extend is still extended within the scanned paths. + if ($node instanceof Class_) { + $this->anonymousClassNodes[] = new AnonymousClassNode( + file: $this->currentFile, + line: $node->getStartLine(), + extends: $node->extends instanceof Name ? $node->extends->toString() : null, + ); + } + return null; } diff --git a/src/Analyser/ClassNodeExtractor.php b/src/Analyser/ClassNodeExtractor.php index 672ef4a..ca5c08f 100644 --- a/src/Analyser/ClassNodeExtractor.php +++ b/src/Analyser/ClassNodeExtractor.php @@ -53,6 +53,10 @@ public function extract( } } - return new ExtractionResult($classCollector->getNodes(), $fileAnalyses); + return new ExtractionResult( + $classCollector->getNodes(), + $fileAnalyses, + $classCollector->getAnonymousClassNodes(), + ); } } diff --git a/src/Analyser/ExtractionResult.php b/src/Analyser/ExtractionResult.php index 61947f2..fb51c71 100644 --- a/src/Analyser/ExtractionResult.php +++ b/src/Analyser/ExtractionResult.php @@ -9,10 +9,12 @@ /** * @param list $classNodes * @param array $fileAnalyses + * @param list $anonymousClassNodes */ public function __construct( public array $classNodes, public array $fileAnalyses, + public array $anonymousClassNodes = [], ) { } } diff --git a/src/Analyser/Parallel/ClassNodeWorker.php b/src/Analyser/Parallel/ClassNodeWorker.php index a9ab1fd..c4231f4 100644 --- a/src/Analyser/Parallel/ClassNodeWorker.php +++ b/src/Analyser/Parallel/ClassNodeWorker.php @@ -60,17 +60,19 @@ public static function run(string $inputFile, string $outputFile, mixed $outputS ); file_put_contents($outputFile, serialize([ - 'nodes' => $result->classNodes, - 'fileAnalyses' => $result->fileAnalyses, - 'error' => null, + 'nodes' => $result->classNodes, + 'fileAnalyses' => $result->fileAnalyses, + 'anonymousClassNodes' => $result->anonymousClassNodes, + 'error' => null, ])); return 0; } catch (Throwable $throwable) { file_put_contents($outputFile, serialize([ - 'nodes' => [], - 'fileAnalyses' => [], - 'error' => sprintf('%s: %s', $throwable::class, $throwable->getMessage()), + 'nodes' => [], + 'fileAnalyses' => [], + 'anonymousClassNodes' => [], + 'error' => sprintf('%s: %s', $throwable::class, $throwable->getMessage()), ])); return 1; diff --git a/src/Analyser/Parallel/ParallelClassNodeExtractor.php b/src/Analyser/Parallel/ParallelClassNodeExtractor.php index 9d4dda2..60615be 100644 --- a/src/Analyser/Parallel/ParallelClassNodeExtractor.php +++ b/src/Analyser/Parallel/ParallelClassNodeExtractor.php @@ -4,6 +4,7 @@ namespace Boundwize\StructArmed\Analyser\Parallel; +use Boundwize\StructArmed\Analyser\AnonymousClassNode; use Boundwize\StructArmed\Analyser\ClassNode; use Boundwize\StructArmed\Analyser\ExtractionResult; use Boundwize\StructArmed\Analyser\FileAnalysis; @@ -128,10 +129,11 @@ public function extract( ]; } - $nodes = []; - $fileAnalyses = []; - $failure = null; - $pending = $processes; + $nodes = []; + $fileAnalyses = []; + $anonymousClassNodes = []; + $failure = null; + $pending = $processes; while ($pending !== []) { $anyActivity = false; @@ -220,6 +222,24 @@ public function extract( $fileAnalyses[$file] = $fileAnalysis; } + + $workerAnonClassNodes = $result['anonymousClassNodes'] ?? []; + + if (! is_array($workerAnonClassNodes)) { + throw new RuntimeException( + 'Parallel analysis worker returned invalid anonymous class nodes.' + ); + } + + foreach ($workerAnonClassNodes as $workerAnonClassNode) { + if (! $workerAnonClassNode instanceof AnonymousClassNode) { + throw new RuntimeException( + 'Parallel analysis worker returned invalid anonymous class nodes.' + ); + } + + $anonymousClassNodes[] = $workerAnonClassNode; + } } catch (RuntimeException $runtimeException) { $failure ??= $runtimeException->getMessage(); } finally { @@ -243,7 +263,7 @@ public function extract( throw new RuntimeException($failure); } - return new ExtractionResult($nodes, $fileAnalyses); + return new ExtractionResult($nodes, $fileAnalyses, $anonymousClassNodes); } /** diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 511a5d6..189c677 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -4,6 +4,7 @@ namespace Boundwize\StructArmed\Cache; +use Boundwize\StructArmed\Analyser\AnonymousClassNode; use Boundwize\StructArmed\Analyser\ClassNode; use Boundwize\StructArmed\Analyser\ConstantNode; use Boundwize\StructArmed\Analyser\FileAnalysis; @@ -198,7 +199,7 @@ public function hasDifferentComposerGeneratedVersion(string $composerGeneratedVe } /** - * @return list|null + * @return array{classNodes: list, anonymousClassNodes: list}|null */ public function loadClassNodes(string $file, string $namespace): ?array { @@ -208,11 +209,25 @@ public function loadClassNodes(string $file, string $namespace): ?array return null; } - return $this->classNodesFromPayload($payload); + $classNodes = $this->classNodesFromPayload($payload); + $anonymousClassNodes = $this->anonymousClassNodesFromPayload($payload); + + if ($classNodes === null || $anonymousClassNodes === null) { + return null; + } + + return [ + 'classNodes' => $classNodes, + 'anonymousClassNodes' => $anonymousClassNodes, + ]; } /** - * @return array{classNodes: list, fileAnalysis: FileAnalysis}|null + * @return array{ + * classNodes: list, + * anonymousClassNodes: list, + * fileAnalysis: FileAnalysis + * }|null */ public function loadClassNodesWithFileAnalysis(string $file, string $namespace): ?array { @@ -222,18 +237,20 @@ public function loadClassNodesWithFileAnalysis(string $file, string $namespace): return null; } - $classNodes = $this->classNodesFromPayload($payload); - $fileAnalysis = is_array($payload['fileAnalysis'] ?? null) + $classNodes = $this->classNodesFromPayload($payload); + $anonymousClassNodes = $this->anonymousClassNodesFromPayload($payload); + $fileAnalysis = is_array($payload['fileAnalysis'] ?? null) ? $this->fileAnalysisFromArray($payload['fileAnalysis']) : null; - if ($classNodes === null || ! $fileAnalysis instanceof FileAnalysis) { + if ($classNodes === null || $anonymousClassNodes === null || ! $fileAnalysis instanceof FileAnalysis) { return null; } return [ - 'classNodes' => $classNodes, - 'fileAnalysis' => $fileAnalysis, + 'classNodes' => $classNodes, + 'anonymousClassNodes' => $anonymousClassNodes, + 'fileAnalysis' => $fileAnalysis, ]; } @@ -279,21 +296,24 @@ private function classNodesFromPayload(array $payload): ?array } /** - * @param list $classNodes + * @param list $classNodes + * @param list $anonymousClassNodes */ public function storeClassNodes( string $file, string $namespace, array $classNodes, ?FileAnalysis $fileAnalysis = null, + array $anonymousClassNodes = [], ): void { if (! is_dir($this->cacheDirectory)) { mkdir($this->cacheDirectory, 0777, true); } $payload = [ - 'metadata' => $this->fileMetadata($file, $namespace), - 'nodes' => array_map($this->classNodeToArray(...), $classNodes), + 'metadata' => $this->fileMetadata($file, $namespace), + 'nodes' => array_map($this->classNodeToArray(...), $classNodes), + 'anonymousClassNodes' => array_map($this->anonymousClassNodeToArray(...), $anonymousClassNodes), ]; if ($fileAnalysis instanceof FileAnalysis) { @@ -377,6 +397,55 @@ className: $className, ); } + /** + * @return array + */ + private function anonymousClassNodeToArray(AnonymousClassNode $anonymousClassNode): array + { + return [ + 'file' => $anonymousClassNode->file, + 'line' => $anonymousClassNode->line, + 'extends' => $anonymousClassNode->extends, + ]; + } + + /** + * @param array $payload + * @return list|null + */ + private function anonymousClassNodesFromPayload(array $payload): ?array + { + $rawNodes = $payload['anonymousClassNodes'] ?? []; + + if (! is_array($rawNodes)) { + return null; + } + + $anonymousClassNodes = []; + + foreach ($rawNodes as $rawNode) { + if (! is_array($rawNode) || ! $this->hasOnlyStringKeys($rawNode)) { + return null; + } + + $file = $rawNode['file'] ?? null; + $line = $rawNode['line'] ?? null; + $extends = $rawNode['extends'] ?? null; + + if (! is_string($file) || ! is_int($line) || ($extends !== null && ! is_string($extends))) { + return null; + } + + $anonymousClassNodes[] = new AnonymousClassNode( + file: $file, + line: $line, + extends: $extends, + ); + } + + return $anonymousClassNodes; + } + /** * @return array */ diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index 0a141de..f8a5294 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -117,6 +117,138 @@ public function testMustBeFinalRuleDoesNotFlagClassExtendedByAnotherScannedClass $this->assertSame('App\PaymentHandler', $violations[0]->className); } + public function testMustBeFinalRuleDoesNotFlagClassExtendedByAnonymousClass(): void + { + $factory = 'makeTempProject([ + 'src/BaseHandler.php' => ' $factory, + 'src/PaymentHandler.php' => 'layer('Source', 'src/') + ->rule('source.must_be_final', new MustBeFinalRule('Source')); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.must_be_final'); + + // BaseHandler is extended by an anonymous class (must stay non-final); + // PaymentHandler is the only genuinely non-final leaf class. + $this->assertCount(1, $violations); + $this->assertSame('App\PaymentHandler', $violations[0]->className); + } + + public function testMustBeFinalRuleDoesNotFlagClassExtendedByTopLevelAnonymousClass(): void + { + // Migration-style file: no named class at all, only a returned anonymous class. + $registration = 'makeTempProject([ + 'src/BaseHandler.php' => ' $registration, + ]); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('source.must_be_final', new MustBeFinalRule('Source')); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.must_be_final'); + + $this->assertCount(0, $violations); + } + + public function testMustBeFinalRuleDoesNotFlagClassExtendedByAnonymousClassWithParallelRunner(): void + { + $factory = 'makeTempProject([ + 'src/BaseHandler.php' => ' $factory, + ]); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('source.must_be_final', new MustBeFinalRule('Source')); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::parallel()) + ->forRule('source.must_be_final'); + + $this->assertCount(0, $violations); + } + + public function testMustBeFinalRuleDoesNotFlagClassExtendedByAnonymousClassOnCachedRun(): void + { + $factory = 'makeTempProject([ + 'src/BaseHandler.php' => ' $factory, + ]); + $analysisResultCache = new AnalysisResultCache($basePath, 'cache'); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('source.must_be_final', new MustBeFinalRule('Source')); + + $coldViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.must_be_final'); + $warmViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.must_be_final'); + + // The anonymous-class parent must survive the class-node cache round-trip. + $this->assertCount(0, $coldViolations); + $this->assertCount(0, $warmViolations); + } + + public function testMustBeFinalRuleDoesNotFlagClassExtendedByAnonymousClassOnCachedRunWithFileAnalysis(): void + { + $factory = 'makeTempProject([ + 'src/BaseHandler.php' => ' $factory, + ]); + $analysisResultCache = new AnalysisResultCache($basePath, 'cache'); + + // A file-analysis rule makes the warm run load class nodes through the + // file-analysis cache path, which must also restore anonymous class nodes. + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('source.must_be_final', new MustBeFinalRule('Source')) + ->rule('psr1.php_tags', new Psr1PhpTagsRule(['src/'])); + + $coldViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.must_be_final'); + $warmViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.must_be_final'); + + $this->assertCount(0, $coldViolations); + $this->assertCount(0, $warmViolations); + } + public function testMustBeFinalRuleFlagsExtendedClassWhenChildIsOutsideScannedPaths(): void { $order = 'makeCollector($code)->getNodes(); + } + + /** @return list */ + private function collectAnonymousClassNodes(string $code): array + { + return $this->makeCollector($code)->getAnonymousClassNodes(); + } + + private function makeCollector(string $code): ClassCollector { $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); $classCollector = new ClassCollector($namespaceLayerResolver); @@ -46,7 +59,7 @@ private function collectNodes(string $code): array $nodeTraverser = new NodeTraverser(new NameResolver(), $classCollector); $nodeTraverser->traverse($ast ?? []); - return $classCollector->getNodes(); + return $classCollector; } public function testCollectsFinalClass(): void @@ -116,6 +129,43 @@ public function testIgnoresAnonymousClasses(): void $this->assertSame([], $nodes); } + public function testCollectsAnonymousClassNodeDeclaredInsideMethod(): void + { + $anonymousClassNodes = $this->collectAnonymousClassNodes('assertCount(1, $anonymousClassNodes); + $this->assertSame('App\BaseHandler', $anonymousClassNodes[0]->extends); + $this->assertSame('/fake/path/Foo.php', $anonymousClassNodes[0]->file); + } + + public function testCollectsTopLevelAnonymousClassNodeInFileWithoutNamedClasses(): void + { + $anonymousClassNodes = $this->collectAnonymousClassNodes('assertCount(1, $anonymousClassNodes); + $this->assertSame('App\BaseHandler', $anonymousClassNodes[0]->extends); + } + + public function testCollectsAnonymousClassNodeWithoutExtends(): void + { + $anonymousClassNodes = $this->collectAnonymousClassNodes('assertCount(1, $anonymousClassNodes); + $this->assertNull($anonymousClassNodes[0]->extends); + } + public function testCollectsExtendedClassAndImplementedInterfaces(): void { $classNode = $this->collect(' [], + 'fileAnalyses' => [], + 'anonymousClassNodes' => 'invalid', + 'error' => null, + ]; + + $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); + $file = $dir . '/Foo.php'; + file_put_contents($file, 'expectException(RuntimeException::class); + $this->expectExceptionMessage('Parallel analysis worker returned invalid anonymous class nodes.'); + + try { + $parallelClassNodeExtractor->extract([$file]); + } finally { + $GLOBALS['mock_file_get_contents_payload'] = null; + $GLOBALS['mock_tracked_tempnam_files'] = []; + } + } + + public function testExtractThrowsWhenAnonymousClassNodeEntryIsInvalid(): void + { + $GLOBALS['mock_file_get_contents_payload'] = [ + 'nodes' => [], + 'fileAnalyses' => [], + 'anonymousClassNodes' => ['invalid'], + 'error' => null, + ]; + + $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); + $file = $dir . '/Foo.php'; + file_put_contents($file, 'expectException(RuntimeException::class); + $this->expectExceptionMessage('Parallel analysis worker returned invalid anonymous class nodes.'); + + try { + $parallelClassNodeExtractor->extract([$file]); + } finally { + $GLOBALS['mock_file_get_contents_payload'] = null; + $GLOBALS['mock_tracked_tempnam_files'] = []; + } + } } diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index 1189145..9a818df 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -5,6 +5,7 @@ namespace Boundwize\StructArmed\Tests\Cache; use App\Foo; +use Boundwize\StructArmed\Analyser\AnonymousClassNode; use Boundwize\StructArmed\Analyser\ClassNode; use Boundwize\StructArmed\Analyser\ConstantNode; use Boundwize\StructArmed\Analyser\FileAnalysis; @@ -15,6 +16,7 @@ use Boundwize\StructArmed\Rule\RuleViolation; use Boundwize\StructArmed\Rule\RuleViolationCollection; use Composer\InstalledVersions; +use Iterator; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -525,7 +527,7 @@ public function testStoresAndLoadsClassNodes(): void try { $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config'); + $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertStringNotContainsString( "\n", @@ -541,6 +543,116 @@ public function testStoresAndLoadsClassNodes(): void } } + public function testStoresAndLoadsAnonymousClassNodes(): void + { + $cacheDirectory = $this->createTempDirectory(); + $sourceFile = $cacheDirectory . '/Foo.php'; + $analysisResultCache = new AnalysisResultCache(__DIR__, $cacheDirectory); + $classNodes = [$this->makeClassNode($sourceFile)]; + $anonymousClassNodes = [ + new AnonymousClassNode( + file: $sourceFile, + line: 7, + extends: 'App\BaseHandler', + ), + ]; + + file_put_contents($sourceFile, 'storeClassNodes( + $sourceFile, + 'config', + $classNodes, + null, + $anonymousClassNodes, + ); + + $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config'); + + $this->assertIsArray($loaded); + $this->assertEquals($classNodes, $loaded['classNodes']); + $this->assertEquals($anonymousClassNodes, $loaded['anonymousClassNodes']); + } finally { + if (file_exists($sourceFile)) { + unlink($sourceFile); + } + + $this->removeTempDirectory($cacheDirectory); + } + } + + public function testClassNodesLoadOldCachePayloadWithoutAnonymousClassNodes(): void + { + $cacheDirectory = $this->createTempDirectory(); + $sourceFile = $cacheDirectory . '/Foo.php'; + $analysisResultCache = new AnalysisResultCache(__DIR__, $cacheDirectory); + $classNodes = [$this->makeClassNode($sourceFile)]; + + file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', $classNodes); + + // Simulate a payload written before anonymous class nodes existed. + $cacheFile = $this->firstJsonFile($cacheDirectory); + $payload = json_decode((string) file_get_contents($cacheFile), true); + $this->assertIsArray($payload); + unset($payload['anonymousClassNodes']); + file_put_contents($cacheFile, json_encode($payload, JSON_THROW_ON_ERROR)); + + $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config'); + + $this->assertIsArray($loaded); + $this->assertEquals($classNodes, $loaded['classNodes']); + $this->assertSame([], $loaded['anonymousClassNodes']); + } finally { + if (file_exists($sourceFile)) { + unlink($sourceFile); + } + + $this->removeTempDirectory($cacheDirectory); + } + } + + /** + * @return Iterator + */ + public static function corruptedAnonymousClassNodesProvider(): Iterator + { + yield 'not an array' => ['invalid']; + yield 'entry not an array' => [['invalid']]; + yield 'entry with invalid field types' => [[['file' => 1, 'line' => 'x', 'extends' => null]]]; + } + + #[DataProvider('corruptedAnonymousClassNodesProvider')] + public function testLoadClassNodesRejectsCorruptedAnonymousClassNodesPayload(mixed $corrupted): void + { + $cacheDirectory = $this->createTempDirectory(); + $sourceFile = $cacheDirectory . '/Foo.php'; + $analysisResultCache = new AnalysisResultCache(__DIR__, $cacheDirectory); + + file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + + $cacheFile = $this->firstJsonFile($cacheDirectory); + $payload = json_decode((string) file_get_contents($cacheFile), true); + $this->assertIsArray($payload); + $payload['anonymousClassNodes'] = $corrupted; + file_put_contents($cacheFile, json_encode($payload, JSON_THROW_ON_ERROR)); + + $this->assertNull($analysisResultCache->loadClassNodes($sourceFile, 'config')); + } finally { + if (file_exists($sourceFile)) { + unlink($sourceFile); + } + + $this->removeTempDirectory($cacheDirectory); + } + } + public function testStoresClassNodesWithInvalidUtf8Text(): void { $cacheDirectory = $this->createTempDirectory(); @@ -564,7 +676,7 @@ className: "App\\Invalid\xB1Name", try { $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config'); + $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertStringContainsString("\xEF\xBF\xBD", $loaded[0]->className); @@ -602,7 +714,7 @@ className: 'App\FooTrait', try { $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config'); + $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertTrue($loaded[0]->isTrait); @@ -640,7 +752,7 @@ className: 'App\Status', try { $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config'); + $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertTrue($loaded[0]->isEnum); @@ -679,7 +791,7 @@ interfaceExtends: ['App\BaseMiddleware'], try { $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config'); + $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertSame(['App\BaseMiddleware'], $loaded[0]->interfaceExtends); @@ -735,7 +847,7 @@ public function testClassNodesLoadOldCachePayloadWithoutInterfaceExtends(): void ], ], 'class-nodes-' . hash('xxh128', "config\0" . $sourceFile) . '.json'); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config'); + $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertSame([], $loaded[0]->interfaceExtends); @@ -785,7 +897,7 @@ className: Foo::class, try { $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config'); + $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertEquals($classNodes, $loaded); @@ -827,7 +939,7 @@ className: Foo::class, try { $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config'); + $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertEquals($classNodes, $loaded); @@ -870,7 +982,7 @@ className: Foo::class, try { $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config'); + $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertEquals($classNodes, $loaded); @@ -899,7 +1011,7 @@ public function testStoreClassNodesCreatesMissingCacheDirectory(): void $this->assertInstanceOf( ClassNode::class, - $analysisResultCache->loadClassNodes($sourceFile, 'config')[0] ?? null + $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'][0] ?? null ); } finally { $analysisResultCache->clear(); @@ -1077,7 +1189,10 @@ public function testClassNodesHitWhenOnlyFileMtimeChanges(): void $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); touch($sourceFile, 1234567890); - $this->assertEquals($classNodes, $analysisResultCache->loadClassNodes($sourceFile, 'config')); + $this->assertEquals( + $classNodes, + $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null + ); } finally { unlink($sourceFile); $this->removeTempDirectory($cacheDirectory);