From 09678dbf66746e3ac0f29ad431cc5ae165f0ff70 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 29 Aug 2026 17:49:19 +0700 Subject: [PATCH 001/104] refactor: Rename ClassNode extractor/worker classes to AnalysisNode* to prepare for non-class node support --- src/Analyser/Analyser.php | 8 +- ...xtractor.php => AnalysisNodeExtractor.php} | 5 +- ...sNodeWorker.php => AnalysisNodeWorker.php} | 9 +- ....php => ParallelAnalysisNodeExtractor.php} | 5 +- src/Cli/StructArmedApplication.php | 4 +- structarmed.php | 2 +- tests/Analyser/AnalyserTest.php | 4 +- ...Test.php => AnalysisNodeExtractorTest.php} | 30 +++--- ...kerTest.php => AnalysisNodeWorkerTest.php} | 12 +-- ... => ParallelAnalysisNodeExtractorTest.php} | 98 +++++++++---------- tests/Cli/StructArmedApplicationTest.php | 2 +- .../TemporaryDirectoryCleanupTrait.php | 2 +- 12 files changed, 95 insertions(+), 86 deletions(-) rename src/Analyser/{ClassNodeExtractor.php => AnalysisNodeExtractor.php} (96%) rename src/Analyser/Parallel/{ClassNodeWorker.php => AnalysisNodeWorker.php} (93%) rename src/Analyser/Parallel/{ParallelClassNodeExtractor.php => ParallelAnalysisNodeExtractor.php} (99%) rename tests/Analyser/{ClassNodeExtractorTest.php => AnalysisNodeExtractorTest.php} (80%) rename tests/Analyser/Parallel/{ClassNodeWorkerTest.php => AnalysisNodeWorkerTest.php} (89%) rename tests/Analyser/Parallel/{ParallelClassNodeExtractorTest.php => ParallelAnalysisNodeExtractorTest.php} (82%) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index c3103365..60ac3900 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -4,8 +4,8 @@ namespace Boundwize\StructArmed\Analyser; -use Boundwize\StructArmed\Analyser\ClassNodeExtractor; -use Boundwize\StructArmed\Analyser\Parallel\ParallelClassNodeExtractor; +use Boundwize\StructArmed\Analyser\AnalysisNodeExtractor; +use Boundwize\StructArmed\Analyser\Parallel\ParallelAnalysisNodeExtractor; use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Cache\AnalysisResultCache; use Boundwize\StructArmed\Composer\Psr4PathResolver; @@ -1221,7 +1221,7 @@ private function collectClassNodes( $options = $analyserOptions ?? AnalyserOptions::parallel(); if ($options->isParallel()) { - $parsedResult = (new ParallelClassNodeExtractor( + $parsedResult = (new ParallelAnalysisNodeExtractor( $this->basePath, $layers, $layerPatterns, @@ -1229,7 +1229,7 @@ private function collectClassNodes( $this->analysisResultCache?->getCacheDirectory(), ))->extract($filesToParse, $progressHandler, $withFileAnalysis); } else { - $parsedResult = (new ClassNodeExtractor($chainLayerResolver))->extract( + $parsedResult = (new AnalysisNodeExtractor($chainLayerResolver))->extract( $filesToParse, $progressHandler, $withFileAnalysis, diff --git a/src/Analyser/ClassNodeExtractor.php b/src/Analyser/AnalysisNodeExtractor.php similarity index 96% rename from src/Analyser/ClassNodeExtractor.php rename to src/Analyser/AnalysisNodeExtractor.php index 721d30c3..658ead1b 100644 --- a/src/Analyser/ClassNodeExtractor.php +++ b/src/Analyser/AnalysisNodeExtractor.php @@ -9,7 +9,10 @@ use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; -final readonly class ClassNodeExtractor +/** + * @internal + */ +final readonly class AnalysisNodeExtractor { private FileAnalysisProvider $fileAnalysisProvider; diff --git a/src/Analyser/Parallel/ClassNodeWorker.php b/src/Analyser/Parallel/AnalysisNodeWorker.php similarity index 93% rename from src/Analyser/Parallel/ClassNodeWorker.php rename to src/Analyser/Parallel/AnalysisNodeWorker.php index d2c75c8f..324c5f08 100644 --- a/src/Analyser/Parallel/ClassNodeWorker.php +++ b/src/Analyser/Parallel/AnalysisNodeWorker.php @@ -4,7 +4,7 @@ namespace Boundwize\StructArmed\Analyser\Parallel; -use Boundwize\StructArmed\Analyser\ClassNodeExtractor; +use Boundwize\StructArmed\Analyser\AnalysisNodeExtractor; use Boundwize\StructArmed\LayerResolver\ChainLayerResolver; use Throwable; @@ -17,7 +17,10 @@ use const STDOUT; -final readonly class ClassNodeWorker +/** + * @internal + */ +final readonly class AnalysisNodeWorker { /** @param resource|null $outputStream */ public static function run(string $inputFile, string $outputFile, mixed $outputStream = null): int @@ -53,7 +56,7 @@ public static function run(string $inputFile, string $outputFile, mixed $outputS $progressHandler = $emitProgress ? new WorkerProgressHandler($stream) : null; - $result = (new ClassNodeExtractor($layerResolver))->extract( + $result = (new AnalysisNodeExtractor($layerResolver))->extract( $files, $progressHandler, $withFileAnalysis, diff --git a/src/Analyser/Parallel/ParallelClassNodeExtractor.php b/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php similarity index 99% rename from src/Analyser/Parallel/ParallelClassNodeExtractor.php rename to src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php index 975bbddc..118015fb 100644 --- a/src/Analyser/Parallel/ParallelClassNodeExtractor.php +++ b/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php @@ -42,7 +42,10 @@ use const PHP_BINARY; -final readonly class ParallelClassNodeExtractor +/** + * @internal + */ +final readonly class ParallelAnalysisNodeExtractor { /** * @param array> $layers diff --git a/src/Cli/StructArmedApplication.php b/src/Cli/StructArmedApplication.php index 600d5505..90660ea6 100644 --- a/src/Cli/StructArmedApplication.php +++ b/src/Cli/StructArmedApplication.php @@ -4,7 +4,7 @@ namespace Boundwize\StructArmed\Cli; -use Boundwize\StructArmed\Analyser\Parallel\ClassNodeWorker; +use Boundwize\StructArmed\Analyser\Parallel\AnalysisNodeWorker; use Boundwize\StructArmed\Version; use function array_slice; @@ -23,7 +23,7 @@ public function run(array $argv, ?string $basePath = null): int $command = $argv[1] ?? null; if ($command === '--internal-worker') { - return ClassNodeWorker::run($argv[2] ?? '', $argv[3] ?? ''); + return AnalysisNodeWorker::run($argv[2] ?? '', $argv[3] ?? ''); } if (in_array($command, ['--version', '-V'], true)) { diff --git a/structarmed.php b/structarmed.php index baa6adcd..e043d9ad 100644 --- a/structarmed.php +++ b/structarmed.php @@ -49,7 +49,7 @@ __DIR__ . '/src/Preset/Preset.php', ], Psr1Preset::FILES_SHOULD_DECLARE_SYMBOLS_OR_SIDE_EFFECTS => [ - __DIR__ . '/tests/Analyser/Parallel/ParallelClassNodeExtractorTest.php', + __DIR__ . '/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php', __DIR__ . '/tests/Analyser/Parallel/MockFunctions.php', ], ]) diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index c58af1c1..c3693ecb 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -7,7 +7,7 @@ use Boundwize\StructArmed\Analyser\Analyser; use Boundwize\StructArmed\Analyser\AnalyserOptions; use Boundwize\StructArmed\Analyser\FileAnalysisProvider; -use Boundwize\StructArmed\Analyser\Parallel\ParallelClassNodeExtractor; +use Boundwize\StructArmed\Analyser\Parallel\ParallelAnalysisNodeExtractor; use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Cache\AnalysisResultCache; use Boundwize\StructArmed\Cache\FileHashProvider; @@ -54,7 +54,7 @@ use const DIRECTORY_SEPARATOR; #[CoversClass(Analyser::class)] -#[CoversClass(ParallelClassNodeExtractor::class)] +#[CoversClass(ParallelAnalysisNodeExtractor::class)] #[CoversClass(PhpFileCollector::class)] #[CoversClass(SkipPathMatcher::class)] final class AnalyserTest extends TestCase diff --git a/tests/Analyser/ClassNodeExtractorTest.php b/tests/Analyser/AnalysisNodeExtractorTest.php similarity index 80% rename from tests/Analyser/ClassNodeExtractorTest.php rename to tests/Analyser/AnalysisNodeExtractorTest.php index 7acc3376..7448f4b5 100644 --- a/tests/Analyser/ClassNodeExtractorTest.php +++ b/tests/Analyser/AnalysisNodeExtractorTest.php @@ -4,8 +4,8 @@ namespace Boundwize\StructArmed\Tests\Analyser; +use Boundwize\StructArmed\Analyser\AnalysisNodeExtractor; use Boundwize\StructArmed\Analyser\ClassNode; -use Boundwize\StructArmed\Analyser\ClassNodeExtractor; use Boundwize\StructArmed\Analyser\ExtractionResult; use Boundwize\StructArmed\LayerResolver\Resolvers\NamespaceLayerResolver; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; @@ -15,18 +15,18 @@ use function file_put_contents; -#[CoversClass(ClassNodeExtractor::class)] +#[CoversClass(AnalysisNodeExtractor::class)] #[CoversClass(ExtractionResult::class)] -final class ClassNodeExtractorTest extends TestCase +final class AnalysisNodeExtractorTest extends TestCase { use TemporaryDirectoryCleanupTrait; public function testExtractReturnsEmptyArrayForNoFiles(): void { $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'App\\Domain'], '/tmp'); - $classNodeExtractor = new ClassNodeExtractor($namespaceLayerResolver); + $analysisNodeExtractor = new AnalysisNodeExtractor($namespaceLayerResolver); - $extractionResult = $classNodeExtractor->extract([]); + $extractionResult = $analysisNodeExtractor->extract([]); $this->assertSame([], $extractionResult->classNodes); $this->assertSame([], $extractionResult->fileAnalyses); @@ -48,9 +48,9 @@ final class Foo PHP); $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'App\\Domain'], $dir); - $classNodeExtractor = new ClassNodeExtractor($namespaceLayerResolver); + $analysisNodeExtractor = new AnalysisNodeExtractor($namespaceLayerResolver); - $extractionResult = $classNodeExtractor->extract([$file]); + $extractionResult = $analysisNodeExtractor->extract([$file]); $this->assertCount(1, $extractionResult->classNodes); $this->assertInstanceOf(ClassNode::class, $extractionResult->classNodes[0]); @@ -65,9 +65,9 @@ public function testExtractSkipsFilesWithParseErrors(): void file_put_contents($file, ' 'App\\Domain'], $dir); - $classNodeExtractor = new ClassNodeExtractor($namespaceLayerResolver); + $analysisNodeExtractor = new AnalysisNodeExtractor($namespaceLayerResolver); - $extractionResult = $classNodeExtractor->extract([$file]); + $extractionResult = $analysisNodeExtractor->extract([$file]); $this->assertSame([], $extractionResult->classNodes); } @@ -80,9 +80,9 @@ public function testExtractSkipsFilesWithEmptyAst(): void file_put_contents($file, ' 'App\\Domain'], $dir); - $classNodeExtractor = new ClassNodeExtractor($namespaceLayerResolver); + $analysisNodeExtractor = new AnalysisNodeExtractor($namespaceLayerResolver); - $extractionResult = $classNodeExtractor->extract([$file]); + $extractionResult = $analysisNodeExtractor->extract([$file]); $this->assertSame([], $extractionResult->classNodes); } @@ -95,7 +95,7 @@ public function testExtractReturnsFactsFromTheSameParse(): void file_put_contents($file, ' ''], $dir); - $extractionResult = (new ClassNodeExtractor($namespaceLayerResolver)) + $extractionResult = (new AnalysisNodeExtractor($namespaceLayerResolver)) ->extract([$file]); $this->assertCount(1, $extractionResult->classNodes); @@ -112,7 +112,7 @@ public function testExtractSkipsFileAnalysisWhenItIsNotRequested(): void file_put_contents($file, ' ''], $dir); - $extractionResult = (new ClassNodeExtractor($namespaceLayerResolver)) + $extractionResult = (new AnalysisNodeExtractor($namespaceLayerResolver)) ->extract([$file], withFileAnalysis: false); $this->assertCount(1, $extractionResult->classNodes); @@ -135,7 +135,7 @@ final class Bar PHP); $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'App\\Domain'], $dir); - $classNodeExtractor = new ClassNodeExtractor($namespaceLayerResolver); + $analysisNodeExtractor = new AnalysisNodeExtractor($namespaceLayerResolver); $advanced = []; @@ -161,7 +161,7 @@ public function finish(): void } }; - $classNodeExtractor->extract([$file], $progressHandler); + $analysisNodeExtractor->extract([$file], $progressHandler); $this->assertCount(1, $advanced); $this->assertSame($file, $advanced[0]); diff --git a/tests/Analyser/Parallel/ClassNodeWorkerTest.php b/tests/Analyser/Parallel/AnalysisNodeWorkerTest.php similarity index 89% rename from tests/Analyser/Parallel/ClassNodeWorkerTest.php rename to tests/Analyser/Parallel/AnalysisNodeWorkerTest.php index 8173735b..f18e7619 100644 --- a/tests/Analyser/Parallel/ClassNodeWorkerTest.php +++ b/tests/Analyser/Parallel/AnalysisNodeWorkerTest.php @@ -4,7 +4,7 @@ namespace Boundwize\StructArmed\Tests\Analyser\Parallel; -use Boundwize\StructArmed\Analyser\Parallel\ClassNodeWorker; +use Boundwize\StructArmed\Analyser\Parallel\AnalysisNodeWorker; use Boundwize\StructArmed\Analyser\Parallel\WorkerFailedException; use Boundwize\StructArmed\Analyser\Parallel\WorkerProgressHandler; use Boundwize\StructArmed\Tests\Support\TemporaryDirectoryCleanupTrait; @@ -18,10 +18,10 @@ use function serialize; use function unserialize; -#[CoversClass(ClassNodeWorker::class)] +#[CoversClass(AnalysisNodeWorker::class)] #[CoversClass(WorkerProgressHandler::class)] #[CoversClass(WorkerFailedException::class)] -final class ClassNodeWorkerTest extends TestCase +final class AnalysisNodeWorkerTest extends TestCase { use TemporaryDirectoryCleanupTrait; @@ -50,7 +50,7 @@ final class Foo 'files' => [$srcFile], ])); - $exitCode = ClassNodeWorker::run($inputFile, $outputFile, $this->silentStream()); + $exitCode = AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream()); $this->assertSame(0, $exitCode); @@ -70,7 +70,7 @@ public function testRunWithInvalidPayloadReturnsOneAndWritesError(): void file_put_contents($inputFile, serialize('not-an-array')); - $exitCode = ClassNodeWorker::run($inputFile, $outputFile, $this->silentStream()); + $exitCode = AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream()); $this->assertSame(1, $exitCode); @@ -114,7 +114,7 @@ final class FooService 'files' => [$srcFile], ])); - $exitCode = ClassNodeWorker::run($inputFile, $outputFile, $this->silentStream()); + $exitCode = AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream()); $this->assertSame(0, $exitCode); diff --git a/tests/Analyser/Parallel/ParallelClassNodeExtractorTest.php b/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php similarity index 82% rename from tests/Analyser/Parallel/ParallelClassNodeExtractorTest.php rename to tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php index 09147e9d..08ab5f3e 100644 --- a/tests/Analyser/Parallel/ParallelClassNodeExtractorTest.php +++ b/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php @@ -5,7 +5,7 @@ namespace Boundwize\StructArmed\Tests\Analyser\Parallel; use Boundwize\StructArmed\Analyser\ClassNode; -use Boundwize\StructArmed\Analyser\Parallel\ParallelClassNodeExtractor; +use Boundwize\StructArmed\Analyser\Parallel\ParallelAnalysisNodeExtractor; use Boundwize\StructArmed\Tests\Support\TemporaryDirectoryCleanupTrait; use Iterator; use PHPUnit\Framework\Attributes\CoversClass; @@ -24,21 +24,21 @@ use const PHP_BINARY; -#[CoversClass(ParallelClassNodeExtractor::class)] -final class ParallelClassNodeExtractorTest extends TestCase +#[CoversClass(ParallelAnalysisNodeExtractor::class)] +final class ParallelAnalysisNodeExtractorTest extends TestCase { use TemporaryDirectoryCleanupTrait; public function testExtractWithEmptyFilesReturnsEmpty(): void { - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: '/tmp', layers: ['Domain' => 'App\\Domain'], layerPatterns: [], workerCount: 4, ); - $extractionResult = $parallelClassNodeExtractor->extract([]); + $extractionResult = $parallelAnalysisNodeExtractor->extract([]); $this->assertSame([], $extractionResult->classNodes); $this->assertSame([], $extractionResult->fileAnalyses); @@ -59,14 +59,14 @@ final class Foo } PHP); - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: $dir, layers: ['Domain' => 'App\\Domain'], layerPatterns: [], workerCount: 1, ); - $extractionResult = $parallelClassNodeExtractor->extract([$file]); + $extractionResult = $parallelAnalysisNodeExtractor->extract([$file]); $this->assertCount(1, $extractionResult->classNodes); $this->assertInstanceOf(ClassNode::class, $extractionResult->classNodes[0]); @@ -99,14 +99,14 @@ final class Bar } PHP); - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: $dir, layers: ['Domain' => 'App\\Domain'], layerPatterns: [], workerCount: 2, ); - $extractionResult = $parallelClassNodeExtractor->extract([$file1, $file2]); + $extractionResult = $parallelAnalysisNodeExtractor->extract([$file1, $file2]); $this->assertCount(2, $extractionResult->classNodes); $classNames = [$extractionResult->classNodes[0]->className, $extractionResult->classNodes[1]->className]; @@ -121,7 +121,7 @@ public function testExtractReturnsWorkerFacts(): void file_put_contents($file, ' ''], [], 2)) + $extractionResult = (new ParallelAnalysisNodeExtractor($dir, ['Source' => ''], [], 2)) ->extract([$file]); $this->assertCount(1, $extractionResult->classNodes); @@ -146,7 +146,7 @@ final class Baz } PHP); - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: $dir, layers: ['Domain' => 'App\\Domain'], layerPatterns: [], @@ -154,7 +154,7 @@ final class Baz cacheDirectory: $cacheDir, ); - $extractionResult = $parallelClassNodeExtractor->extract([$file]); + $extractionResult = $parallelAnalysisNodeExtractor->extract([$file]); $this->assertCount(1, $extractionResult->classNodes); $this->assertSame('App\\Domain\\Baz', $extractionResult->classNodes[0]->className); @@ -175,14 +175,14 @@ final class FooService } PHP); - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: $dir, layers: ['Domain' => 'App\\Domain'], layerPatterns: ['Domain' => ['pattern' => '/Service$/', 'excludePattern' => null]], workerCount: 2, ); - $extractionResult = $parallelClassNodeExtractor->extract([$file]); + $extractionResult = $parallelAnalysisNodeExtractor->extract([$file]); $this->assertCount(1, $extractionResult->classNodes); } @@ -202,14 +202,14 @@ final class FooService } PHP); - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: $dir, layers: ['Domain' => 'App\\Domain'], layerPatterns: ['Domain' => ['pattern' => '/Service$/', 'excludePattern' => null]], workerCount: 1, ); - $extractionResult = $parallelClassNodeExtractor->extract([$file]); + $extractionResult = $parallelAnalysisNodeExtractor->extract([$file]); $this->assertCount(1, $extractionResult->classNodes); $this->assertSame('App\\Domain\\FooService', $extractionResult->classNodes[0]->className); @@ -219,11 +219,11 @@ public function testExtractThrowsWhenWorkerFailsDueToNullByteInFilePath(): void { $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); // A null byte in a file path causes PHP 8 to throw ValueError in file_get_contents, - // which is NOT caught by ClassNodeExtractor's catch(PhpParser\Error), so it - // propagates to ClassNodeWorker's catch(Throwable) → worker exits with code 1 + // which is NOT caught by AnalysisNodeExtractor's catch(PhpParser\Error), so it + // propagates to AnalysisNodeWorker's catch(Throwable) → worker exits with code 1 $fileWithNullByte = $dir . "/foo\x00.php"; - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: $dir, layers: ['Domain' => 'App\\Domain'], layerPatterns: [], @@ -231,7 +231,7 @@ public function testExtractThrowsWhenWorkerFailsDueToNullByteInFilePath(): void ); $this->expectException(RuntimeException::class); - $parallelClassNodeExtractor->extract([$fileWithNullByte]); + $parallelAnalysisNodeExtractor->extract([$fileWithNullByte]); } public function testExtractWithNonExistentCacheDirectoryCreatesIt(): void @@ -250,7 +250,7 @@ final class Qux } PHP); - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: $dir, layers: ['Domain' => 'App\\Domain'], layerPatterns: [], @@ -259,7 +259,7 @@ final class Qux ); try { - $result = $parallelClassNodeExtractor->extract([$file]); + $result = $parallelAnalysisNodeExtractor->extract([$file]); $this->assertCount(1, $result->classNodes); } finally { if (is_dir($cacheDir)) { @@ -280,13 +280,13 @@ public function testExtractThrowsWhenProcOpenFails(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Unable to start parallel analysis worker.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_proc_open'] = false; } @@ -294,7 +294,7 @@ public function testExtractThrowsWhenProcOpenFails(): void public function testExtractReportsStderrWhenWorkerDiesBeforeWritingPayload(): void { - // Simulates a worker killed by OOM / fatal error before ClassNodeWorker can serialize a result: + // Simulates a worker killed by OOM / fatal error before AnalysisNodeWorker can serialize a result: // non-zero exit code, empty output file, diagnostic on stderr. $GLOBALS['mock_proc_open_command'] = [ PHP_BINARY, @@ -306,10 +306,10 @@ public function testExtractReportsStderrWhenWorkerDiesBeforeWritingPayload(): vo $file = $dir . '/Foo.php'; file_put_contents($file, 'extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); $this->fail('Expected RuntimeException was not thrown.'); } catch (RuntimeException $runtimeException) { $this->assertStringContainsString('Parallel analysis worker failed:', $runtimeException->getMessage()); @@ -329,13 +329,13 @@ public function testExtractThrowsWhenTempnamFails(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Unable to create temporary file for parallel analysis.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_tempnam'] = false; } @@ -349,13 +349,13 @@ public function testExtractThrowsWhenPayloadIsInvalid(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned an invalid payload.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -372,13 +372,13 @@ public function testExtractThrowsWhenExitZeroWorkerReportsErrorInPayload(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker failed: simulated payload error'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -393,13 +393,13 @@ public function testExtractThrowsWhenErrorPayloadIsInvalid(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned an invalid error payload.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -418,13 +418,13 @@ public function testExtractThrowsWhenFileAnalysesPayloadIsNotAnArray(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned invalid file analyses.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -443,13 +443,13 @@ public function testExtractThrowsWhenFileAnalysisEntryIsInvalid(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned invalid file analyses.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -469,13 +469,13 @@ public function testExtractThrowsWhenAnonymousClassNodesPayloadIsNotAnArray(): v $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]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -495,13 +495,13 @@ public function testExtractThrowsWhenAnonymousClassNodeEntryIsInvalid(): void $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]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -522,13 +522,13 @@ public function testExtractThrowsWhenFileReferencesPayloadIsNotAnArray(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned invalid file references.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -570,13 +570,13 @@ public function testExtractThrowsWhenFileInstantiationsPayloadIsInvalid(mixed $i $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned invalid file instantiations.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -598,13 +598,13 @@ public function testExtractThrowsWhenFileReferencesEntryIsInvalid(mixed $invalid $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned invalid file references.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 9292b847..77ba3a44 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -1479,7 +1479,7 @@ public function testAnalyseCommandRejectsNonPhpFileScanPath(): void } } - public function testInternalWorkerRoutesDelegatestoClassNodeWorker(): void + public function testInternalWorkerRoutesDelegatestoAnalysisNodeWorker(): void { $inputFile = (string) tempnam(sys_get_temp_dir(), 'structarmed-worker-input-'); $outputFile = (string) tempnam(sys_get_temp_dir(), 'structarmed-worker-output-'); diff --git a/tests/Support/TemporaryDirectoryCleanupTrait.php b/tests/Support/TemporaryDirectoryCleanupTrait.php index bc8f8463..0f5fbba9 100644 --- a/tests/Support/TemporaryDirectoryCleanupTrait.php +++ b/tests/Support/TemporaryDirectoryCleanupTrait.php @@ -45,7 +45,7 @@ protected function makeTemporaryDirectory(string $prefix): string $this->temporaryPaths[] = $basePath; // also clean up the default cache directory derived from this base path, - // created e.g. by ParallelClassNodeExtractor when no cache directory is configured + // created e.g. by ParallelAnalysisNodeExtractor when no cache directory is configured $this->temporaryPaths[] = CachePathFactory::getPath(null, $basePath); return $basePath; From e11392deee36e7070db2d35ea7ae38e70edab793 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 29 Aug 2026 17:54:06 +0700 Subject: [PATCH 002/104] run ci --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e83aca31..bb69819c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,10 @@ name: ci build on: - push: - branches: [main] - pull_request: - branches: [main] + pull_request: + push: + branches: + - "main" jobs: build: From e66db07ae4d32f5860de7a7e82ab37e90f3af9ab Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 29 Aug 2026 18:51:01 +0700 Subject: [PATCH 003/104] rename collect on analyser --- src/Analyser/Analyser.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 60ac3900..53d45a8a 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -144,7 +144,7 @@ public function analyse( $files ??= $this->filesForAnalysis($architecture, $scanPaths, $layers); $withFileAnalysis = $fileAnalysisRules !== []; - $extractionResult = $this->collectClassNodes( + $extractionResult = $this->collectAnalysisNodes( $files, $progressHandler, $layers, @@ -1128,7 +1128,7 @@ private function recursiveParents( * excludePattern: string|list|null * }> $layerPatterns */ - private function collectClassNodes( + private function collectAnalysisNodes( array $files, ?ProgressHandlerInterface $progressHandler, array $layers, From 49072d52f9a304a02933b575c6f6515fd59b117f Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 29 Aug 2026 19:32:49 +0700 Subject: [PATCH 004/104] feat: add MayNotExtendClassRule --- docs/available-rules.md | 1 + .../Rules/Class_/MayNotExtendClassRule.php | 40 +++++++ .../Rule/Class_/MayNotExtendClassRuleTest.php | 109 ++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 src/Rule/Rules/Class_/MayNotExtendClassRule.php create mode 100644 tests/Rule/Class_/MayNotExtendClassRuleTest.php diff --git a/docs/available-rules.md b/docs/available-rules.md index a2a9cdd1..7d399d6a 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -80,6 +80,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`. | `ClassNameMustNotHavePrefixRule` | `new ClassNameMustNotHavePrefixRule(layer: 'Model', prefix: 'Model')` | Classes in a layer do not use a forbidden prefix. | | `ExtendedClassMustBeAbstractOrInstantiatedRule` | `new ExtendedClassMustBeAbstractOrInstantiatedRule(layer: 'Source')` | Classes another scanned class extends are declared `abstract` unless they are also instantiated (`new X`, a `new self`/`new static`/`new parent` resolving to them, a constant class expression such as `new (X::class)` or `new ('App\X')`, or a chained `(new ReflectionClass(X::class))->newInstance*()`). Type hints, `instanceof`, and `::class` keep working on an abstract class, so they do not count. Runtime-fed construction (`new $class` from a parameter, `unserialize()`, container factories) is outside the scanned-code boundary — exclude such factories' targets with rule-scoped `skip()` or `skipRule()`. Supports `--fix` by adding the `abstract` modifier. | | `MaxDependencyCountRule` | `new MaxDependencyCountRule(layer: 'Controller', maxCount: 5)` | Constructor dependency count stays below the configured limit. | +| `MayNotExtendClassRule` | `new MayNotExtendClassRule(layer: 'Domain', class: 'Illuminate\\Database\\Eloquent\\Model')` | Classes in a layer do not extend a forbidden class, directly or through any parent class. | | `MayNotImplementInterfaceRule` | `new MayNotImplementInterfaceRule(layer: 'Domain', interface: JsonSerializable::class)` | Classes in a layer do not implement a forbidden interface. | | `MustBeFinalRule` | `new MustBeFinalRule(layer: 'Domain', classNamePattern: '/Entity$/')` | Matching classes in a layer are declared `final`. Classes extended by another scanned class are skipped (making them `final` would break the child). Supports `--fix`. | | `MustBeUsedInterfaceRule` | `new MustBeUsedInterfaceRule(layer: 'Source')` | Interfaces are implemented by a scanned class (directly or through inheritance), extended by another scanned interface, or referenced as a dependency (type hint, `instanceof`, `::class`, a class-name string, ...). Supports `--fix` by removing the unused interface (and deleting its file when only boilerplate remains). | diff --git a/src/Rule/Rules/Class_/MayNotExtendClassRule.php b/src/Rule/Rules/Class_/MayNotExtendClassRule.php new file mode 100644 index 00000000..f522b04e --- /dev/null +++ b/src/Rule/Rules/Class_/MayNotExtendClassRule.php @@ -0,0 +1,40 @@ +isClass() && $classNode->isInLayer($this->layer); + } + + public function evaluate(ClassNode $classNode): ?RuleViolation + { + if (! $classNode->extendsClass($this->class)) { + return null; + } + + return new RuleViolation( + message: sprintf('Class [%s] must not extend class [%s]', $classNode->className, $this->class), + file: $classNode->file, + line: $classNode->line, + className: $classNode->className, + layer: $classNode->layer, + ); + } +} diff --git a/tests/Rule/Class_/MayNotExtendClassRuleTest.php b/tests/Rule/Class_/MayNotExtendClassRuleTest.php new file mode 100644 index 00000000..1be23640 --- /dev/null +++ b/tests/Rule/Class_/MayNotExtendClassRuleTest.php @@ -0,0 +1,109 @@ +assertNotInstanceOf(RuleViolation::class, $mayNotExtendClassRule->evaluate($this->makeNode(null))); + } + + public function testPassesWhenAnotherClassIsExtended(): void + { + $mayNotExtendClassRule = new MayNotExtendClassRule(layer: 'Domain', class: self::MODEL); + + $this->assertNotInstanceOf( + RuleViolation::class, + $mayNotExtendClassRule->evaluate($this->makeNode('App\\Domain\\AbstractEntity')) + ); + } + + public function testAppliesOnlyToConfiguredLayer(): void + { + $mayNotExtendClassRule = new MayNotExtendClassRule(layer: 'Domain', class: self::MODEL); + + $this->assertTrue($mayNotExtendClassRule->appliesTo($this->makeNode(null))); + $this->assertFalse($mayNotExtendClassRule->appliesTo($this->makeNode(null, 'Infrastructure'))); + } + + public function testAppliesOnlyToClasses(): void + { + $mayNotExtendClassRule = new MayNotExtendClassRule(layer: 'Domain', class: self::MODEL); + + $this->assertFalse($mayNotExtendClassRule->appliesTo($this->makeNode(null, isInterface: true))); + $this->assertFalse($mayNotExtendClassRule->appliesTo($this->makeNode(null, isTrait: true))); + $this->assertFalse($mayNotExtendClassRule->appliesTo($this->makeNode(null, isEnum: true))); + } + + public function testViolatesWhenClassDirectlyExtendsForbiddenClass(): void + { + $mayNotExtendClassRule = new MayNotExtendClassRule(layer: 'Domain', class: self::MODEL); + + $violation = $mayNotExtendClassRule->evaluate($this->makeNode(self::MODEL)); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame('Class [App\\Domain\\Order] must not extend class [Illuminate\\Database\\Eloquent\\Model]', $violation->message); + } + + public function testMatchesClassNameCaseInsensitively(): void + { + $mayNotExtendClassRule = new MayNotExtendClassRule(layer: 'Domain', class: strtolower(self::MODEL)); + + $this->assertInstanceOf( + RuleViolation::class, + $mayNotExtendClassRule->evaluate($this->makeNode(self::MODEL)) + ); + } + + public function testViolatesWhenClassIndirectlyExtendsForbiddenClass(): void + { + $mayNotExtendClassRule = new MayNotExtendClassRule(layer: 'Domain', class: self::MODEL); + + // App\Domain\Order extends App\Domain\Entity, which extends the ORM model. + $classNode = $this->makeNode('App\\Domain\\Entity'); + $classNode->setRecursiveParents(['App\\Domain\\Entity', self::MODEL], []); + + $violation = $mayNotExtendClassRule->evaluate($classNode); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame('Class [App\\Domain\\Order] must not extend class [Illuminate\\Database\\Eloquent\\Model]', $violation->message); + } + + private function makeNode( + ?string $extends, + string $layer = 'Domain', + bool $isInterface = false, + bool $isTrait = false, + bool $isEnum = false, + ): ClassNode { + return new ClassNode( + className: 'App\\Domain\\Order', + file: '/fake.php', + line: 1, + layer: $layer, + extends: $extends, + isAbstract: false, + isFinal: false, + isInterface: $isInterface, + isReadonly: false, + isTrait: $isTrait, + isEnum: $isEnum, + ); + } +} From 6f6031f95147467720e2a79e6a0e9b5c2384ab5d Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 29 Aug 2026 19:36:00 +0700 Subject: [PATCH 005/104] use sprintf --- tests/Rule/Class_/MayNotExtendClassRuleTest.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/Rule/Class_/MayNotExtendClassRuleTest.php b/tests/Rule/Class_/MayNotExtendClassRuleTest.php index 1be23640..708cd2aa 100644 --- a/tests/Rule/Class_/MayNotExtendClassRuleTest.php +++ b/tests/Rule/Class_/MayNotExtendClassRuleTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use function sprintf; use function strtolower; #[CoversClass(MayNotExtendClassRule::class)] @@ -58,7 +59,10 @@ public function testViolatesWhenClassDirectlyExtendsForbiddenClass(): void $violation = $mayNotExtendClassRule->evaluate($this->makeNode(self::MODEL)); $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertSame('Class [App\\Domain\\Order] must not extend class [Illuminate\\Database\\Eloquent\\Model]', $violation->message); + $this->assertSame( + sprintf('Class [App\\Domain\\Order] must not extend class [%s]', self::MODEL), + $violation->message + ); } public function testMatchesClassNameCaseInsensitively(): void @@ -82,7 +86,10 @@ public function testViolatesWhenClassIndirectlyExtendsForbiddenClass(): void $violation = $mayNotExtendClassRule->evaluate($classNode); $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertSame('Class [App\\Domain\\Order] must not extend class [Illuminate\\Database\\Eloquent\\Model]', $violation->message); + $this->assertSame( + sprintf('Class [App\\Domain\\Order] must not extend class [%s]', self::MODEL), + $violation->message + ); } private function makeNode( From a928b4834dac8364b25f036732ada0f4a4d94af2 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 07:30:08 +0700 Subject: [PATCH 006/104] feat: Add FunctionNode and AnonymousFunctionNode with function-like rule interfaces --- docs/available-rules.md | 9 + docs/custom-rules-and-presets.md | 87 +++- src/Analyser/Analyser.php | 157 ++++++- ...ollector.php => AnalysisNodeCollector.php} | 312 ++++++++++++-- src/Analyser/AnalysisNodeExtractor.php | 18 +- src/Analyser/AnonymousFunctionNode.php | 79 ++++ src/Analyser/ExtractionResult.php | 4 + src/Analyser/FunctionLikeAnalysis.php | 41 ++ src/Analyser/FunctionLikeNodeTrait.php | 74 ++++ src/Analyser/FunctionNode.php | 75 ++++ src/Analyser/Parallel/AnalysisNodeWorker.php | 28 +- .../ParallelAnalysisNodeExtractor.php | 58 ++- src/Architecture.php | 23 +- src/Cache/AnalysisCacheMetadataFactory.php | 4 +- src/Cache/AnalysisResultCache.php | 302 ++++++++++++-- src/Cli/AnalyseCommand.php | 4 +- src/PHPUnit/StructArmedExtension.php | 2 +- src/Rule/AnonymousFunctionRuleInterface.php | 27 ++ .../AddStaticAnonymousFunctionVisitor.php | 38 ++ src/Rule/FunctionRuleInterface.php | 27 ++ src/Rule/RuleViolation.php | 5 + .../MustBeStaticAnonymousFunctionRule.php | 57 +++ tests/Analyser/AnalyserTest.php | 198 +++++++++ ...Test.php => AnalysisNodeCollectorTest.php} | 156 +++---- tests/Analyser/AnonymousFunctionNodeTest.php | 78 ++++ tests/Analyser/FunctionLikeCollectionTest.php | 389 ++++++++++++++++++ tests/Analyser/FunctionNodeTest.php | 95 +++++ .../ParallelAnalysisNodeExtractorTest.php | 61 +++ tests/Cache/AnalysisResultCacheTest.php | 312 +++++++++++--- .../AddStaticAnonymousFunctionVisitorTest.php | 62 +++ ...stBeStaticAnonymousFunctionRuleFixTest.php | 98 +++++ .../MustBeStaticAnonymousFunctionRuleTest.php | 113 +++++ tests/Rule/RuleViolationTest.php | 22 + 33 files changed, 2774 insertions(+), 241 deletions(-) rename src/Analyser/{ClassCollector.php => AnalysisNodeCollector.php} (76%) create mode 100644 src/Analyser/AnonymousFunctionNode.php create mode 100644 src/Analyser/FunctionLikeAnalysis.php create mode 100644 src/Analyser/FunctionLikeNodeTrait.php create mode 100644 src/Analyser/FunctionNode.php create mode 100644 src/Rule/AnonymousFunctionRuleInterface.php create mode 100644 src/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitor.php create mode 100644 src/Rule/FunctionRuleInterface.php create mode 100644 src/Rule/Rules/Function_/MustBeStaticAnonymousFunctionRule.php rename tests/Analyser/{ClassCollectorTest.php => AnalysisNodeCollectorTest.php} (91%) create mode 100644 tests/Analyser/AnonymousFunctionNodeTest.php create mode 100644 tests/Analyser/FunctionLikeCollectionTest.php create mode 100644 tests/Analyser/FunctionNodeTest.php create mode 100644 tests/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitorTest.php create mode 100644 tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleFixTest.php create mode 100644 tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleTest.php diff --git a/docs/available-rules.md b/docs/available-rules.md index 7d399d6a..1d156698 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -98,6 +98,15 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`. `Psr4DirectoryExistsRule`, `Psr1PhpTagsRule`, `Psr1Utf8WithoutBomRule`, `ExtendedClassMustBeAbstractOrInstantiatedRule`, `MustBeFinalRule`, `MustBeUsedInterfaceRule`, `MustBeUsedAbstractClassRule`, `MustBeUsedTraitRule`, `MustDeclareConstantVisibilityRule`, `MustDeclareMethodVisibilityRule`, and `MustDeclarePropertyVisibilityRule` implement `Boundwize\StructArmed\Rule\FixableInterface`, so StructArmed can automatically remove PSR-4 mappings for missing directories, normalize invalid PHP opening tags, remove UTF-8 byte order marks, add the `final` or `abstract` class modifier, remove unused interfaces, abstract classes, and traits (deleting their file when only `declare`/`namespace`/`use` boilerplate remains), and add missing constant, method, or property visibility modifiers when you run `vendor/bin/structarmed analyse --fix`. +## Function Rules + +Namespace: `Boundwize\StructArmed\Rule\Rules\Function_`. + +| Rule | Constructor | Checks | +|---|---|---| +| `MustBeStaticAnonymousFunctionRule` | `new MustBeStaticAnonymousFunctionRule(layer: 'Domain')` | Closures and arrow functions in a layer are declared `static`. Anonymous functions that read `$this` (directly or through a nested closure) are skipped, since a static closure cannot access `$this`. Supports `--fix` by adding the `static` modifier. | +{: .rule-table } + ## Layer Rules Namespace: `Boundwize\StructArmed\Rule\Rules\Layer`. diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index a4a47cb2..9d570b9b 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -177,6 +177,91 @@ The built-in [YAGNI preset](../presets/) rules follow this pattern: `MustBeUsedI Trade-off: only usage within the scanned paths is known. A class-like used solely by a consumer outside the scan — a vendor package, an unscanned directory, runtime-fed dynamic construction — is reported as if unused. Widen the scan, or use `skipRule()` and skip paths where such consumers exist. +## Analysing Functions And Closures + +Named functions, closures, and arrow functions are collected alongside classes: + +| Node | Represents | Identified by | +| --- | --- | --- | +| `Boundwize\StructArmed\Analyser\FunctionNode` | A named function declaration (`function foo() {}`), global or namespaced | `$functionName` (fully qualified) | +| `Boundwize\StructArmed\Analyser\AnonymousFunctionNode` | A closure (`function () {}`) or arrow function (`fn () => ...`) | `$file` and `$line`, plus `$enclosingClassName` / `$enclosingFunctionName` | + +Both carry the body-level facts a `ClassNode` has — `$dependencies`, `$functionCalls`, `$superglobals`, `$languageConstructs`, `$layer` / `$layers` — plus `$paramCount`, `$hasReturnType`, `$cyclomaticComplexity`, and `$lineCount`. The same query helpers are available: `isInLayer()`, `dependsOn()`, `dependsOnNamespace()`, `callsFunction()`, `usesLanguageConstruct()`, and `accessesSuperglobals()`. A `FunctionNode` also has `shortName()`, `nameStartsWith()`, `nameEndsWith()`, and `nameMatches()`; an `AnonymousFunctionNode` has `$isArrowFunction`, `$isStatic`, `getType()`, and `enclosingScopeName()`. + +A closure declared inside a class or a named function is counted on both nodes: the enclosing `ClassNode` (or `FunctionNode`) keeps seeing everything the closure does, exactly as it sees its own method bodies, and the `AnonymousFunctionNode` reports the closure body on its own. + +Rules opt in to these nodes by implementing `Boundwize\StructArmed\Rule\FunctionRuleInterface` and/or `Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface`. Their method names differ from `RuleInterface` (`appliesToFunction()` / `evaluateFunction()` and `appliesToAnonymousFunction()` / `evaluateAnonymousFunction()`), so one rule class can implement all three and check classes, functions, and closures alike. Global skip paths, rule-scoped `skip()` paths, and `skipRule()` apply the same way. Function-likes are not part of the declarative `ruleset()` layer-dependency check. + +```php +isInLayer($this->layer); + } + + public function evaluateFunction(FunctionNode $functionNode): ?RuleViolation + { + if (! $functionNode->accessesSuperglobals()) { + return null; + } + + return new RuleViolation( + message: sprintf('Function [%s()] must not access superglobals', $functionNode->functionName), + file: $functionNode->file, + line: $functionNode->line, + className: $functionNode->functionName, + layer: $functionNode->layer, + functionName: $functionNode->functionName, + ); + } + + public function appliesToAnonymousFunction(AnonymousFunctionNode $anonymousFunctionNode): bool + { + return $anonymousFunctionNode->isInLayer($this->layer); + } + + public function evaluateAnonymousFunction(AnonymousFunctionNode $anonymousFunctionNode): ?RuleViolation + { + if (! $anonymousFunctionNode->accessesSuperglobals()) { + return null; + } + + return new RuleViolation( + message: sprintf( + '%s in [%s] must not access superglobals', + $anonymousFunctionNode->getType(), + $anonymousFunctionNode->enclosingScopeName() + ), + file: $anonymousFunctionNode->file, + line: $anonymousFunctionNode->line, + className: $anonymousFunctionNode->enclosingScopeName(), + layer: $anonymousFunctionNode->layer, + ); + } +} +``` + +`RuleViolation::$className` is required, so a function rule passes the function name there (and, optionally, in the dedicated `functionName` field, which the JSON report emits as `"function"`); an anonymous-function rule passes `enclosingScopeName()`, which is the enclosing class-like or named function, or `AnonymousFunctionNode::FILE_SCOPE` (`'file scope'`) for a closure in top-level procedural code. + ## Making A Custom Rule Fixable Use `Boundwize\StructArmed\Rule\FixableInterface` when a custom rule can safely rewrite the offending source file. @@ -291,6 +376,6 @@ return Architecture::define() Use `rule()` when one project needs one extra check. -Use a custom `RuleInterface` class when the check itself is new behavior. +Use a custom `RuleInterface` class when the check itself is new behavior; add `FunctionRuleInterface` / `AnonymousFunctionRuleInterface` when it must also cover named functions and closures. Use a custom `PresetInterface` class when several layers and rules should be applied together or reused across repositories. diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 53d45a8a..64e06d59 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -13,10 +13,12 @@ use Boundwize\StructArmed\File\SkipPathMatcher; use Boundwize\StructArmed\LayerResolver\ChainLayerResolver; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; +use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface; use Boundwize\StructArmed\Rule\ComposerJsonRuleInterface; use Boundwize\StructArmed\Rule\ExtendedClassAwareRuleInterface; use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface; use Boundwize\StructArmed\Rule\FixableInterface; +use Boundwize\StructArmed\Rule\FunctionRuleInterface; use Boundwize\StructArmed\Rule\LayerAwareRuleInterface; use Boundwize\StructArmed\Rule\MultipleProjectRuleViolationInterface; use Boundwize\StructArmed\Rule\MultipleRuleViolationInterface; @@ -52,7 +54,7 @@ public function __construct( string $basePath = '', private ?AnalysisResultCache $analysisResultCache = null, - private string $classNodeCacheNamespace = '', + private string $analysisNodeCacheNamespace = '', private PhpFileCollector $phpFileCollector = new PhpFileCollector(), ) { $this->basePath = $basePath !== '' ? $basePath : (string) getcwd(); @@ -80,6 +82,8 @@ public function analyse( $projectRuleViolations = []; $fileAnalysisRules = []; $classRules = []; + $functionRules = []; + $anonymousFunctionRules = []; $layerAwareRules = []; $hasExtendedClassAwareRule = false; $hasUsedInterfaceAwareRule = false; @@ -94,6 +98,14 @@ public function analyse( $classRules[$key] = $rule; } + if ($rule instanceof FunctionRuleInterface) { + $functionRules[$key] = $rule; + } + + if ($rule instanceof AnonymousFunctionRuleInterface) { + $anonymousFunctionRules[$key] = $rule; + } + if ($rule instanceof LayerAwareRuleInterface) { $layerAwareRules[] = $rule; } @@ -193,6 +205,7 @@ className: $violation->className, methodName: $violation->methodName, constantName: $violation->constantName, propertyName: $violation->propertyName, + functionName: $violation->functionName, )); } } @@ -216,7 +229,10 @@ className: $violation->className, } $globalSkipPathMatcher = SkipPathMatcher::compile($this->basePath, $globalSkipPaths); - $ruleSkipMatchers = $this->ruleSkipMatchers($classRules, $ruleSkipPaths); + $ruleSkipMatchers = $this->ruleSkipMatchers( + $classRules + $functionRules + $anonymousFunctionRules, + $ruleSkipPaths + ); $rulesetSkipPaths = $architecture->getRulesetSkipPaths(); $rulesetSkipPathMatcher = SkipPathMatcher::compile($this->basePath, $rulesetSkipPaths); $rulesetViolationCollection = new RuleViolationCollection(); @@ -275,6 +291,7 @@ className: $violation->className, methodName: $violation->methodName, constantName: $violation->constantName, propertyName: $violation->propertyName, + functionName: $violation->functionName, )); } } @@ -367,11 +384,77 @@ className: $classNode->className, } } + // Function-likes are not part of the class hierarchy, so they take no + // part in the declarative ruleset; only rules that opt in see them. + foreach ($extractionResult->functionNodes as $functionNode) { + if ($globalSkipPathMatcher->isSkipped($functionNode->file)) { + continue; + } + + foreach ($functionRules as $key => $rule) { + if (isset($ruleSkipMatchers[$key]) && $ruleSkipMatchers[$key]->isSkipped($functionNode->file)) { + continue; + } + + if (! $rule->appliesToFunction($functionNode)) { + continue; + } + + $violation = $rule->evaluateFunction($functionNode); + + if ($violation instanceof RuleViolation) { + $ruleViolationCollection->add($this->withRuleKey($violation, $key, $rule)); + } + } + } + + foreach ($extractionResult->anonymousFunctionNodes as $anonymousFunctionNode) { + if ($globalSkipPathMatcher->isSkipped($anonymousFunctionNode->file)) { + continue; + } + + foreach ($anonymousFunctionRules as $key => $rule) { + if ( + isset($ruleSkipMatchers[$key]) + && $ruleSkipMatchers[$key]->isSkipped($anonymousFunctionNode->file) + ) { + continue; + } + + if (! $rule->appliesToAnonymousFunction($anonymousFunctionNode)) { + continue; + } + + $violation = $rule->evaluateAnonymousFunction($anonymousFunctionNode); + + if ($violation instanceof RuleViolation) { + $ruleViolationCollection->add($this->withRuleKey($violation, $key, $rule)); + } + } + } + $ruleViolationCollection->merge($rulesetViolationCollection); return $ruleViolationCollection; } + private function withRuleKey(RuleViolation $ruleViolation, string $key, object $rule): RuleViolation + { + return new RuleViolation( + message: $ruleViolation->message, + file: $ruleViolation->file, + line: $ruleViolation->line, + className: $ruleViolation->className, + layer: $ruleViolation->layer, + ruleKey: $key, + fixable: $rule instanceof FixableInterface, + methodName: $ruleViolation->methodName, + constantName: $ruleViolation->constantName, + propertyName: $ruleViolation->propertyName, + functionName: $ruleViolation->functionName, + ); + } + /** * Expand `+LayerName` references in a ruleset into their concrete allowed layers. * @@ -479,7 +562,7 @@ private function isSourceSynthesised(Architecture $architecture): bool } /** - * @param array $classRules + * @param array $classRules * @param array> $ruleSkipPaths * @return array */ @@ -782,7 +865,7 @@ private function markClassLikeUsage( foreach ($extractionResult->fileInstantiations as $instantiations) { foreach ($instantiations as $instantiation) { - $deferredMarker = ClassCollector::parseDeferredInstantiationMarker($instantiation); + $deferredMarker = AnalysisNodeCollector::parseDeferredInstantiationMarker($instantiation); if ($deferredMarker === null) { $instantiated[strtolower($instantiation)] = true; @@ -1137,18 +1220,20 @@ private function collectAnalysisNodes( ?AnalyserOptions $analyserOptions = null, bool $withFileAnalysis = true, ): ExtractionResult { - $classNodes = []; - $fileAnalyses = []; - $anonymousClassNodes = []; - $fileReferences = []; - $fileInstantiations = []; - $filesToParse = []; + $classNodes = []; + $fileAnalyses = []; + $anonymousClassNodes = []; + $fileReferences = []; + $fileInstantiations = []; + $functionNodes = []; + $anonymousFunctionNodes = []; + $filesToParse = []; foreach ($files as $file) { if ($withFileAnalysis) { - $cachedResult = $this->analysisResultCache?->loadClassNodesWithFileAnalysis( + $cachedResult = $this->analysisResultCache?->loadAnalysisNodesWithFileAnalysis( $file, - $this->classNodeCacheNamespace + $this->analysisNodeCacheNamespace ); if ($cachedResult === null) { @@ -1172,14 +1257,22 @@ private function collectAnalysisNodes( $fileInstantiations[$file] = $cachedResult['fileInstantiations']; } + foreach ($cachedResult['functionNodes'] as $cachedFunctionNode) { + $functionNodes[] = $cachedFunctionNode; + } + + foreach ($cachedResult['anonymousFunctionNodes'] as $cachedAnonymousFunctionNode) { + $anonymousFunctionNodes[] = $cachedAnonymousFunctionNode; + } + $fileAnalyses[$file] = $cachedResult['fileAnalysis']; continue; } - $cachedResult = $this->analysisResultCache?->loadClassNodes( + $cachedResult = $this->analysisResultCache?->loadAnalysisNodes( $file, - $this->classNodeCacheNamespace, + $this->analysisNodeCacheNamespace, ); if ($cachedResult === null) { @@ -1202,6 +1295,14 @@ private function collectAnalysisNodes( if ($cachedResult['fileInstantiations'] !== []) { $fileInstantiations[$file] = $cachedResult['fileInstantiations']; } + + foreach ($cachedResult['functionNodes'] as $cachedFunctionNode) { + $functionNodes[] = $cachedFunctionNode; + } + + foreach ($cachedResult['anonymousFunctionNodes'] as $cachedAnonymousFunctionNode) { + $anonymousFunctionNodes[] = $cachedAnonymousFunctionNode; + } } $progressHandler?->start(count($filesToParse)); @@ -1215,6 +1316,8 @@ private function collectAnalysisNodes( $anonymousClassNodes, $fileReferences, $fileInstantiations, + $functionNodes, + $anonymousFunctionNodes, ); } @@ -1266,15 +1369,35 @@ private function collectAnalysisNodes( $fileInstantiations[$file] = $parsedFileInstantiations; } + $functionNodesByFile = array_fill_keys($filesToParse, []); + foreach ($parsedResult->functionNodes as $parsedFunctionNode) { + $functionNodes[] = $parsedFunctionNode; + + if (isset($functionNodesByFile[$parsedFunctionNode->file])) { + $functionNodesByFile[$parsedFunctionNode->file][] = $parsedFunctionNode; + } + } + + $anonymousFunctionNodesByFile = array_fill_keys($filesToParse, []); + foreach ($parsedResult->anonymousFunctionNodes as $parsedAnonymousFunctionNode) { + $anonymousFunctionNodes[] = $parsedAnonymousFunctionNode; + + if (isset($anonymousFunctionNodesByFile[$parsedAnonymousFunctionNode->file])) { + $anonymousFunctionNodesByFile[$parsedAnonymousFunctionNode->file][] = $parsedAnonymousFunctionNode; + } + } + foreach ($classNodesByFile as $fileToParse => $fileClassNodes) { - $this->analysisResultCache?->storeClassNodes( + $this->analysisResultCache?->storeAnalysisNodes( $fileToParse, - $this->classNodeCacheNamespace, + $this->analysisNodeCacheNamespace, $fileClassNodes, $fileAnalyses[$fileToParse] ?? null, $anonymousClassNodesByFile[$fileToParse] ?? [], $fileReferences[$fileToParse] ?? [], $fileInstantiations[$fileToParse] ?? [], + $functionNodesByFile[$fileToParse] ?? [], + $anonymousFunctionNodesByFile[$fileToParse] ?? [], ); } @@ -1286,6 +1409,8 @@ private function collectAnalysisNodes( $anonymousClassNodes, $fileReferences, $fileInstantiations, + $functionNodes, + $anonymousFunctionNodes, ); } diff --git a/src/Analyser/ClassCollector.php b/src/Analyser/AnalysisNodeCollector.php similarity index 76% rename from src/Analyser/ClassCollector.php rename to src/Analyser/AnalysisNodeCollector.php index 578463fb..e55a9ba9 100644 --- a/src/Analyser/ClassCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -11,6 +11,7 @@ use PhpParser\Node; use PhpParser\Node\Arg; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\AssignOp\Coalesce as AssignCoalesce; use PhpParser\Node\Expr\BinaryOp\BooleanAnd; use PhpParser\Node\Expr\BinaryOp\BooleanOr; @@ -19,6 +20,7 @@ use PhpParser\Node\Expr\BinaryOp\LogicalAnd; use PhpParser\Node\Expr\BinaryOp\LogicalOr; use PhpParser\Node\Expr\ClassConstFetch; +use PhpParser\Node\Expr\Closure; use PhpParser\Node\Expr\Empty_; use PhpParser\Node\Expr\Eval_; use PhpParser\Node\Expr\Exit_; @@ -33,6 +35,7 @@ use PhpParser\Node\Expr\Print_; use PhpParser\Node\Expr\Ternary; use PhpParser\Node\Expr\Variable; +use PhpParser\Node\FunctionLike; use PhpParser\Node\Identifier; use PhpParser\Node\MatchArm; use PhpParser\Node\Name; @@ -82,7 +85,14 @@ use function strtolower; use function substr; -final class ClassCollector extends NodeVisitorAbstract +/** + * Collects every analysis node from a file's AST: ClassNodes (with their + * anonymous-class, file-reference, and instantiation side data), FunctionNodes, + * and AnonymousFunctionNodes. + * + * @internal + */ +final class AnalysisNodeCollector extends NodeVisitorAbstract { private const SUPERGLOBALS = [ '_GET' => true, @@ -169,6 +179,12 @@ final class ClassCollector extends NodeVisitorAbstract /** @var list */ private array $anonymousClassNodes = []; + /** @var list */ + private array $functionNodes = []; + + /** @var list */ + private array $anonymousFunctionNodes = []; + /** @var array> */ private array $fileReferences = []; @@ -229,6 +245,39 @@ final class ClassCollector extends NodeVisitorAbstract /** @var array */ private array $methodClassLikeAnalyses = []; + /** + * Names of the class-likes currently being entered, innermost last; an + * anonymous class contributes null. + * + * @var list + */ + private array $activeClassLikeNames = []; + + /** @var list */ + private array $activeFunctionNames = []; + + /** @var list */ + private array $activeFunctionLikeAnalyses = []; + + /** + * For each class-like currently being entered, how many function-likes + * were active at that point. `$this` inside a class-like body binds to + * that class-like, so only closures entered after it (deeper in the + * stack) are the ones reading it. + * + * @var list + */ + private array $functionLikeDepthAtClassLikeEntry = []; + + /** + * Every function-like entered in the current file, in source order. Their + * nodes are built in afterTraverse(), once every function declared in the + * file is known and unqualified function calls can be resolved. + * + * @var list + */ + private array $fileFunctionLikeAnalyses = []; + public function __construct( private readonly LayerResolverInterface $layerResolver ) { @@ -252,16 +301,21 @@ public function __construct( public function setCurrentFile(string $file): void { - $this->currentFile = $file; - $this->currentFileReferences = []; - $this->currentFileInstantiations = []; - $this->currentNamespaceUses = []; - $this->fileClassLikes = []; - $this->fileFunctions = []; - $this->classLikeAnalysis = []; - $this->activeClassLikeAnalyses = []; - $this->activeMethodIds = []; - $this->methodClassLikeAnalyses = []; + $this->currentFile = $file; + $this->currentFileReferences = []; + $this->currentFileInstantiations = []; + $this->currentNamespaceUses = []; + $this->fileClassLikes = []; + $this->fileFunctions = []; + $this->classLikeAnalysis = []; + $this->activeClassLikeAnalyses = []; + $this->activeMethodIds = []; + $this->methodClassLikeAnalyses = []; + $this->activeClassLikeNames = []; + $this->activeFunctionNames = []; + $this->activeFunctionLikeAnalyses = []; + $this->fileFunctionLikeAnalyses = []; + $this->functionLikeDepthAtClassLikeEntry = []; } /** @return list */ @@ -270,6 +324,18 @@ public function getNodes(): array return $this->nodes; } + /** @return list */ + public function getFunctionNodes(): array + { + return $this->functionNodes; + } + + /** @return list */ + public function getAnonymousFunctionNodes(): array + { + return $this->anonymousFunctionNodes; + } + /** @return list */ public function getAnonymousClassNodes(): array { @@ -370,15 +436,21 @@ public function enterNode(Node $node): null } if ($node instanceof Function_) { - if (isset($node->namespacedName)) { - $this->fileFunctions[$node->namespacedName->toString()] = true; - } + $functionName = $this->resolveFunctionDeclarationName($node); + + $this->fileFunctions[$functionName] = true; + $this->activeFunctionNames[] = $functionName; + $this->startFunctionLikeAnalysis($node, $this->currentNamespaceUses); return null; } if ($node instanceof ClassLike) { - $this->activeClassLikeScopes[] = $this->createClassLikeScope($node); + $this->activeClassLikeScopes[] = $this->createClassLikeScope($node); + $this->activeClassLikeNames[] = $node->name instanceof Identifier + ? $this->resolveClassName($node) + : null; + $this->functionLikeDepthAtClassLikeEntry[] = count($this->activeFunctionLikeAnalyses); if ($node->name instanceof Identifier) { $this->startClassLikeAnalysis($node); @@ -392,6 +464,10 @@ public function enterNode(Node $node): null return null; } + } elseif ($node instanceof Closure || $node instanceof ArrowFunction) { + $this->startFunctionLikeAnalysis($node); + + return null; } $this->collectNodeAnalysis($node); @@ -406,6 +482,12 @@ public function leaveNode(Node $node): null // class expression). They only match expressions, and ClassMethod / // ClassLike are statements, so one instanceof splits the two groups. if ($node instanceof Expr) { + if ($node instanceof Closure || $node instanceof ArrowFunction) { + array_pop($this->activeFunctionLikeAnalyses); + + return null; + } + // Instantiations are tracked separately from plain references: // `new` on an abstract class is fatal, so instantiation is the one // usage that requires an extended class to stay concrete — type @@ -439,11 +521,20 @@ public function leaveNode(Node $node): null return null; } + if ($node instanceof Function_) { + array_pop($this->activeFunctionLikeAnalyses); + array_pop($this->activeFunctionNames); + + return null; + } + if (! $node instanceof ClassLike) { return null; } array_pop($this->activeClassLikeScopes); + array_pop($this->activeClassLikeNames); + array_pop($this->functionLikeDepthAtClassLikeEntry); if (! $node->name instanceof Identifier) { // Anonymous classes never become ClassNodes, but the class they @@ -475,6 +566,10 @@ public function afterTraverse(array $nodes): null $this->collectClassLike($fileClassLike); } + foreach ($this->fileFunctionLikeAnalyses as $fileFunctionLikeAnalysis) { + $this->collectFunctionLike($fileFunctionLikeAnalysis); + } + if ($this->currentFileReferences !== []) { $this->fileReferences[$this->currentFile] = array_values(array_unique($this->currentFileReferences)); $this->currentFileReferences = []; @@ -487,12 +582,17 @@ public function afterTraverse(array $nodes): null $this->currentFileInstantiations = []; } - $this->fileClassLikes = []; - $this->classLikeAnalysis = []; - $this->activeClassLikeAnalyses = []; - $this->activeClassLikeScopes = []; - $this->activeMethodIds = []; - $this->methodClassLikeAnalyses = []; + $this->fileClassLikes = []; + $this->classLikeAnalysis = []; + $this->activeClassLikeAnalyses = []; + $this->activeClassLikeScopes = []; + $this->activeMethodIds = []; + $this->methodClassLikeAnalyses = []; + $this->activeClassLikeNames = []; + $this->activeFunctionNames = []; + $this->activeFunctionLikeAnalyses = []; + $this->fileFunctionLikeAnalyses = []; + $this->functionLikeDepthAtClassLikeEntry = []; return null; } @@ -527,6 +627,38 @@ private function startMethodAnalysis(ClassMethod $classMethod): void $analysis->complexityByMethodId[$methodId] = 1; } + /** + * A named function seeds its dependencies with the namespace imports, as + * a class-like does; a closure or arrow function only records what its + * own body references. + * + * @param list $dependencies + */ + private function startFunctionLikeAnalysis(FunctionLike $functionLike, array $dependencies = []): void + { + $functionLikeAnalysis = new FunctionLikeAnalysis( + $functionLike, + $this->innermostActiveClassLikeName(), + $this->activeFunctionNames === [] ? null : end($this->activeFunctionNames), + ); + + $functionLikeAnalysis->dependencies = $dependencies; + + $this->activeFunctionLikeAnalyses[] = $functionLikeAnalysis; + $this->fileFunctionLikeAnalyses[] = $functionLikeAnalysis; + } + + private function innermostActiveClassLikeName(): ?string + { + for ($index = count($this->activeClassLikeNames) - 1; $index >= 0; $index--) { + if ($this->activeClassLikeNames[$index] !== null) { + return $this->activeClassLikeNames[$index]; + } + } + + return null; + } + private function finishMethodAnalysis(ClassMethod $classMethod): void { if (! isset($this->methodClassLikeAnalyses[spl_object_id($classMethod)])) { @@ -571,8 +703,6 @@ private function collectNodeAnalysis(Node $node): void // class-like reference still keeps the referenced class-like // alive. $this->currentFileReferences[] = $name; - - return; } $this->addDependency($name); @@ -580,7 +710,7 @@ private function collectNodeAnalysis(Node $node): void return; } - if ($this->activeClassLikeAnalyses === []) { + if ($this->activeClassLikeAnalyses === [] && $this->activeFunctionLikeAnalyses === []) { return; } @@ -592,11 +722,21 @@ private function collectNodeAnalysis(Node $node): void $this->methodClassLikeAnalyses[$activeMethodId]->complexityByMethodId[$activeMethodId]++; } + foreach ($this->activeFunctionLikeAnalyses as $activeFunctionLikeAnalysis) { + $activeFunctionLikeAnalysis->cyclomaticComplexity++; + } + return; } if ($node instanceof Variable) { - if (is_string($node->name) && isset(self::SUPERGLOBALS[$node->name])) { + if (! is_string($node->name)) { + return; + } + + if ($node->name === 'this') { + $this->markThisUsage(); + } elseif (isset(self::SUPERGLOBALS[$node->name])) { $this->addSuperglobal('$' . $node->name); } @@ -840,6 +980,10 @@ private function addDependency(string $dependency): void foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { $activeClassLikeAnalysis->dependencies[] = $dependency; } + + foreach ($this->activeFunctionLikeAnalyses as $activeFunctionLikeAnalysis) { + $activeFunctionLikeAnalysis->dependencies[] = $dependency; + } } private function addFunctionCallName(Name $functionCallName): void @@ -847,6 +991,10 @@ private function addFunctionCallName(Name $functionCallName): void foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { $activeClassLikeAnalysis->functionCallNames[] = $functionCallName; } + + foreach ($this->activeFunctionLikeAnalyses as $activeFunctionLikeAnalysis) { + $activeFunctionLikeAnalysis->functionCallNames[] = $functionCallName; + } } private function addSuperglobal(string $superglobal): void @@ -854,6 +1002,26 @@ private function addSuperglobal(string $superglobal): void foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { $activeClassLikeAnalysis->superglobals[] = $superglobal; } + + foreach ($this->activeFunctionLikeAnalyses as $activeFunctionLikeAnalysis) { + $activeFunctionLikeAnalysis->superglobals[] = $superglobal; + } + } + + /** + * `$this` belongs to every closure entered since the innermost class-like, + * as a non-static closure captures it from its enclosing scope through + * any number of nested non-static closures. + */ + private function markThisUsage(): void + { + $depth = $this->functionLikeDepthAtClassLikeEntry === [] + ? 0 + : end($this->functionLikeDepthAtClassLikeEntry); + + for ($index = count($this->activeFunctionLikeAnalyses) - 1; $index >= $depth; $index--) { + $this->activeFunctionLikeAnalyses[$index]->usesThis = true; + } } private function addLanguageConstruct(string $languageConstruct): void @@ -861,6 +1029,10 @@ private function addLanguageConstruct(string $languageConstruct): void foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { $activeClassLikeAnalysis->languageConstructs[] = $languageConstruct; } + + foreach ($this->activeFunctionLikeAnalyses as $activeFunctionLikeAnalysis) { + $activeFunctionLikeAnalysis->languageConstructs[] = $languageConstruct; + } } private function collectClassLike(ClassLike $classLike): void @@ -910,6 +1082,80 @@ enumBackingType: $classLike instanceof Enum_ && $classLike->scalarType instan ); } + private function collectFunctionLike(FunctionLikeAnalysis $functionLikeAnalysis): void + { + $functionLike = $functionLikeAnalysis->functionLike; + $functionCalls = []; + + foreach ($functionLikeAnalysis->functionCallNames as $functionCallName) { + $functionCalls[] = $this->resolveFunctionName($functionCallName); + } + + $dependencies = array_values(array_unique($functionLikeAnalysis->dependencies)); + $functionCalls = array_values(array_unique($functionCalls)); + $superglobals = array_values(array_unique($functionLikeAnalysis->superglobals)); + $languageConstructs = array_values(array_unique($functionLikeAnalysis->languageConstructs)); + $hasReturnType = $functionLike->getReturnType() instanceof Node; + $paramCount = count($functionLike->getParams()); + $lineCount = $this->calculateLineCount($functionLike); + + if ($functionLike instanceof Function_) { + $functionName = $this->resolveFunctionDeclarationName($functionLike); + + $this->functionNodes[] = new FunctionNode( + functionName: $functionName, + file: $this->currentFile, + line: $functionLike->getStartLine(), + layer: $this->layerResolver->resolve($functionName, $this->currentFile), + hasReturnType: $hasReturnType, + paramCount: $paramCount, + cyclomaticComplexity: $functionLikeAnalysis->cyclomaticComplexity, + lineCount: $lineCount, + dependencies: $dependencies, + functionCalls: $functionCalls, + superglobals: $superglobals, + languageConstructs: $languageConstructs, + layers: $this->layerResolver->resolveAll($functionName, $this->currentFile), + ); + + return; + } + + // The layer of an anonymous function is resolved by its file and, for + // class-name pattern layers, by the named scope declaring it. + $scopeName = $functionLikeAnalysis->enclosingClassName + ?? $functionLikeAnalysis->enclosingFunctionName + ?? ''; + + $this->anonymousFunctionNodes[] = new AnonymousFunctionNode( + file: $this->currentFile, + line: $functionLike->getStartLine(), + layer: $this->layerResolver->resolve($scopeName, $this->currentFile), + isArrowFunction: $functionLike instanceof ArrowFunction, + isStatic: ($functionLike instanceof Closure || $functionLike instanceof ArrowFunction) + && $functionLike->static, + enclosingClassName: $functionLikeAnalysis->enclosingClassName, + enclosingFunctionName: $functionLikeAnalysis->enclosingFunctionName, + usesThis: $functionLikeAnalysis->usesThis, + hasReturnType: $hasReturnType, + paramCount: $paramCount, + cyclomaticComplexity: $functionLikeAnalysis->cyclomaticComplexity, + lineCount: $lineCount, + dependencies: $dependencies, + functionCalls: $functionCalls, + superglobals: $superglobals, + languageConstructs: $languageConstructs, + layers: $this->layerResolver->resolveAll($scopeName, $this->currentFile), + ); + } + + private function resolveFunctionDeclarationName(Function_ $function): string + { + return isset($function->namespacedName) + ? $function->namespacedName->toString() + : (string) $function->name; + } + /** * Collect traits, constants, properties, methods, and enum cases in a single * pass over the class-like statements instead of one loop per member kind. @@ -989,7 +1235,7 @@ private function collectMembers(ClassLike $classLike, array $complexityByMethodI isStatic: $stmt->isStatic(), paramCount: count($stmt->params), cyclomaticComplexity: $complexityByMethodId[spl_object_id($stmt)] ?? 1, - lineCount: $this->calculateMethodLineCount($stmt), + lineCount: $this->calculateLineCount($stmt), hasExplicitVisibility: VisibilityFlagChecker::hasExplicitVisibilityFlag($stmt->flags), line: $stmt->getStartLine(), isMagic: $stmt->isMagic(), @@ -1146,13 +1392,19 @@ private function resolveVisibilityName(ClassMethod|ClassConst|Property|Param $no return 'public'; } - private function calculateMethodLineCount(ClassMethod $classMethod): int + /** + * Lines spanned by the body statements. An arrow function's body is its + * single expression, which php-parser exposes as one return statement. + */ + private function calculateLineCount(FunctionLike $functionLike): int { - if ($classMethod->stmts === null || $classMethod->stmts === []) { + $stmts = $functionLike->getStmts(); + + if ($stmts === null || $stmts === []) { return 0; } - $lastIndex = count($classMethod->stmts) - 1; - return $classMethod->stmts[$lastIndex]->getEndLine() - $classMethod->stmts[0]->getStartLine() + 1; + $lastIndex = count($stmts) - 1; + return $stmts[$lastIndex]->getEndLine() - $stmts[0]->getStartLine() + 1; } } diff --git a/src/Analyser/AnalysisNodeExtractor.php b/src/Analyser/AnalysisNodeExtractor.php index 658ead1b..4053269e 100644 --- a/src/Analyser/AnalysisNodeExtractor.php +++ b/src/Analyser/AnalysisNodeExtractor.php @@ -29,9 +29,9 @@ public function extract( ?ProgressHandlerInterface $progressHandler = null, bool $withFileAnalysis = true, ): ExtractionResult { - $classCollector = new ClassCollector($this->layerResolver); - $nodeTraverser = new NodeTraverser(new NameResolver(), $classCollector); - $fileAnalyses = []; + $analysisNodeCollector = new AnalysisNodeCollector($this->layerResolver); + $nodeTraverser = new NodeTraverser(new NameResolver(), $analysisNodeCollector); + $fileAnalyses = []; foreach ($files as $file) { try { @@ -45,7 +45,7 @@ public function extract( continue; } - $classCollector->setCurrentFile($file); + $analysisNodeCollector->setCurrentFile($file); $nodeTraverser->traverse($ast); } finally { if ($withFileAnalysis) { @@ -57,11 +57,13 @@ public function extract( } return new ExtractionResult( - $classCollector->getNodes(), + $analysisNodeCollector->getNodes(), $fileAnalyses, - $classCollector->getAnonymousClassNodes(), - $classCollector->getFileReferences(), - $classCollector->getFileInstantiations(), + $analysisNodeCollector->getAnonymousClassNodes(), + $analysisNodeCollector->getFileReferences(), + $analysisNodeCollector->getFileInstantiations(), + $analysisNodeCollector->getFunctionNodes(), + $analysisNodeCollector->getAnonymousFunctionNodes(), ); } } diff --git a/src/Analyser/AnonymousFunctionNode.php b/src/Analyser/AnonymousFunctionNode.php new file mode 100644 index 00000000..48f99af9 --- /dev/null +++ b/src/Analyser/AnonymousFunctionNode.php @@ -0,0 +1,79 @@ + ...`). It has no name of its own, so it is identified by its file + * and line, plus the named class-like and/or function it is declared in. + * + * The body-level facts of an anonymous function declared inside a class-like + * or named function are also counted on that enclosing node, exactly as the + * body of a method is counted on its class: a rule that only inspects the + * enclosing node keeps seeing everything the closure does. + */ +final readonly class AnonymousFunctionNode +{ + use FunctionLikeNodeTrait; + + /** + * Scope label reported by {@see enclosingScopeName()} for an anonymous + * function declared outside any class-like or named function. + */ + public const FILE_SCOPE = 'file scope'; + + /** @var list */ + public array $layers; + + /** + * @param string|null $enclosingClassName Innermost named class-like this anonymous function is declared in + * @param string|null $enclosingFunctionName Innermost named function this anonymous function is declared in + * @param bool $usesThis Whether the body (or a nested closure) reads `$this`; such a + * closure cannot be declared static + * @param list $dependencies Fully-qualified class, function, or constant dependencies + * @param string[] $functionCalls Functions called within this anonymous function + * @param string[] $superglobals Superglobals accessed ($_GET, $_POST, etc.) + * @param string[] $languageConstructs Language constructs used (exit, die, etc.) + * @param list $layers Layer names this anonymous function belongs to; defaults to [$layer] + */ + public function __construct( + public string $file, + public int $line, + public ?string $layer, + public bool $isArrowFunction = false, + public bool $isStatic = false, + public ?string $enclosingClassName = null, + public ?string $enclosingFunctionName = null, + public bool $usesThis = false, + public bool $hasReturnType = false, + public int $paramCount = 0, + public int $cyclomaticComplexity = 1, + public int $lineCount = 0, + public array $dependencies = [], + public array $functionCalls = [], + public array $superglobals = [], + public array $languageConstructs = [], + array $layers = [], + ) { + $this->layers = $layers ?: array_filter([$this->layer]); + } + + public function getType(): string + { + return $this->isArrowFunction ? 'Arrow function' : 'Closure'; + } + + /** + * Label of the innermost named scope declaring this anonymous function — + * the enclosing class-like, else the enclosing named function — or + * {@see self::FILE_SCOPE} for one declared in top-level procedural code. + */ + public function enclosingScopeName(): string + { + return $this->enclosingClassName ?? $this->enclosingFunctionName ?? self::FILE_SCOPE; + } +} diff --git a/src/Analyser/ExtractionResult.php b/src/Analyser/ExtractionResult.php index 252641e6..0b8f4141 100644 --- a/src/Analyser/ExtractionResult.php +++ b/src/Analyser/ExtractionResult.php @@ -14,6 +14,8 @@ * named class-like scope, per file * @param array> $fileInstantiations Class-like instantiations (`new X`, * with self/static/parent resolved), per file + * @param list $functionNodes + * @param list $anonymousFunctionNodes */ public function __construct( public array $classNodes, @@ -21,6 +23,8 @@ public function __construct( public array $anonymousClassNodes = [], public array $fileReferences = [], public array $fileInstantiations = [], + public array $functionNodes = [], + public array $anonymousFunctionNodes = [], ) { } } diff --git a/src/Analyser/FunctionLikeAnalysis.php b/src/Analyser/FunctionLikeAnalysis.php new file mode 100644 index 00000000..cd8c72aa --- /dev/null +++ b/src/Analyser/FunctionLikeAnalysis.php @@ -0,0 +1,41 @@ + */ + public array $dependencies = []; + + /** @var list */ + public array $functionCallNames = []; + + /** @var string[] */ + public array $superglobals = []; + + /** @var string[] */ + public array $languageConstructs = []; + + public int $cyclomaticComplexity = 1; + + public bool $usesThis = false; + + public function __construct( + public readonly FunctionLike $functionLike, + public readonly ?string $enclosingClassName, + public readonly ?string $enclosingFunctionName, + ) { + } +} diff --git a/src/Analyser/FunctionLikeNodeTrait.php b/src/Analyser/FunctionLikeNodeTrait.php new file mode 100644 index 00000000..a601b72c --- /dev/null +++ b/src/Analyser/FunctionLikeNodeTrait.php @@ -0,0 +1,74 @@ +layers, true); + } + + public function dependsOn(string $class): bool + { + return in_array($class, $this->dependencies, true); + } + + public function dependsOnNamespace(string $namespace): bool + { + $prefix = rtrim($namespace, '\\') . '\\'; + + foreach ($this->dependencies as $dependency) { + if (str_starts_with($dependency, $prefix)) { + return true; + } + } + + return false; + } + + public function callsFunction(string $function): bool + { + foreach ($this->functionCalls as $functionCall) { + if (strcasecmp($functionCall, $function) === 0) { + return true; + } + } + + return false; + } + + public function usesLanguageConstruct(string $construct): bool + { + if (in_array($construct, $this->languageConstructs, true)) { + return true; + } + + // `die` is a pure alias of `exit`, so banning either spelling catches both. + return match ($construct) { + 'exit' => in_array('die', $this->languageConstructs, true), + 'die' => in_array('exit', $this->languageConstructs, true), + default => false, + }; + } + + public function accessesSuperglobals(): bool + { + return $this->superglobals !== []; + } +} diff --git a/src/Analyser/FunctionNode.php b/src/Analyser/FunctionNode.php new file mode 100644 index 00000000..6c0e1bde --- /dev/null +++ b/src/Analyser/FunctionNode.php @@ -0,0 +1,75 @@ + */ + public array $layers; + + /** + * @param string $functionName Fully-qualified function name + * @param list $dependencies Fully-qualified class, function, or constant dependencies + * @param string[] $functionCalls Functions called within this function + * @param string[] $superglobals Superglobals accessed ($_GET, $_POST, etc.) + * @param string[] $languageConstructs Language constructs used (exit, die, etc.) + * @param list $layers All layer names this function belongs to; defaults to [$layer] + */ + public function __construct( + public string $functionName, + public string $file, + public int $line, + public ?string $layer, + public bool $hasReturnType = false, + public int $paramCount = 0, + public int $cyclomaticComplexity = 1, + public int $lineCount = 0, + public array $dependencies = [], + public array $functionCalls = [], + public array $superglobals = [], + public array $languageConstructs = [], + array $layers = [], + ) { + $this->layers = $layers ?: array_filter([$this->layer]); + } + + public function shortName(): string + { + $position = strrpos($this->functionName, '\\'); + + return $position === false + ? $this->functionName + : substr($this->functionName, $position + 1); + } + + public function nameEndsWith(string $suffix): bool + { + return str_ends_with($this->shortName(), $suffix); + } + + public function nameStartsWith(string $prefix): bool + { + return str_starts_with($this->shortName(), $prefix); + } + + public function nameMatches(string $pattern, bool $isFullName = false): bool + { + return (bool) preg_match($pattern, $isFullName ? $this->functionName : $this->shortName()); + } +} diff --git a/src/Analyser/Parallel/AnalysisNodeWorker.php b/src/Analyser/Parallel/AnalysisNodeWorker.php index 324c5f08..4b20037d 100644 --- a/src/Analyser/Parallel/AnalysisNodeWorker.php +++ b/src/Analyser/Parallel/AnalysisNodeWorker.php @@ -63,23 +63,27 @@ public static function run(string $inputFile, string $outputFile, mixed $outputS ); file_put_contents($outputFile, serialize([ - 'nodes' => $result->classNodes, - 'fileAnalyses' => $result->fileAnalyses, - 'anonymousClassNodes' => $result->anonymousClassNodes, - 'fileReferences' => $result->fileReferences, - 'fileInstantiations' => $result->fileInstantiations, - 'error' => null, + 'nodes' => $result->classNodes, + 'fileAnalyses' => $result->fileAnalyses, + 'anonymousClassNodes' => $result->anonymousClassNodes, + 'fileReferences' => $result->fileReferences, + 'fileInstantiations' => $result->fileInstantiations, + 'functionNodes' => $result->functionNodes, + 'anonymousFunctionNodes' => $result->anonymousFunctionNodes, + 'error' => null, ])); return 0; } catch (Throwable $throwable) { file_put_contents($outputFile, serialize([ - 'nodes' => [], - 'fileAnalyses' => [], - 'anonymousClassNodes' => [], - 'fileReferences' => [], - 'fileInstantiations' => [], - 'error' => sprintf('%s: %s', $throwable::class, $throwable->getMessage()), + 'nodes' => [], + 'fileAnalyses' => [], + 'anonymousClassNodes' => [], + 'fileReferences' => [], + 'fileInstantiations' => [], + 'functionNodes' => [], + 'anonymousFunctionNodes' => [], + 'error' => sprintf('%s: %s', $throwable::class, $throwable->getMessage()), ])); return 1; diff --git a/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php b/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php index 118015fb..b4daddc5 100644 --- a/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php +++ b/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php @@ -5,9 +5,11 @@ namespace Boundwize\StructArmed\Analyser\Parallel; use Boundwize\StructArmed\Analyser\AnonymousClassNode; +use Boundwize\StructArmed\Analyser\AnonymousFunctionNode; use Boundwize\StructArmed\Analyser\ClassNode; use Boundwize\StructArmed\Analyser\ExtractionResult; use Boundwize\StructArmed\Analyser\FileAnalysis; +use Boundwize\StructArmed\Analyser\FunctionNode; use Boundwize\StructArmed\Cache\CachePathFactory; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; use RuntimeException; @@ -137,12 +139,14 @@ public function extract( ]; } - $nodes = []; - $fileAnalyses = []; - $anonymousClassNodes = []; - $fileReferences = []; - $fileInstantiations = []; - $failure = null; + $nodes = []; + $fileAnalyses = []; + $anonymousClassNodes = []; + $fileReferences = []; + $fileInstantiations = []; + $functionNodes = []; + $anonymousFunctionNodes = []; + $failure = null; while ($pending !== []) { $anyActivity = false; @@ -330,6 +334,38 @@ public function extract( $fileInstantiations[$file] = $validInstantiations; } + + $workerFunctionNodes = $result['functionNodes'] ?? []; + + if (! is_array($workerFunctionNodes)) { + throw new RuntimeException('Parallel analysis worker returned invalid function nodes.'); + } + + foreach ($workerFunctionNodes as $workerFunctionNode) { + if (! $workerFunctionNode instanceof FunctionNode) { + throw new RuntimeException('Parallel analysis worker returned invalid function nodes.'); + } + + $functionNodes[] = $workerFunctionNode; + } + + $workerAnonymousFunctionNodes = $result['anonymousFunctionNodes'] ?? []; + + if (! is_array($workerAnonymousFunctionNodes)) { + throw new RuntimeException( + 'Parallel analysis worker returned invalid anonymous function nodes.' + ); + } + + foreach ($workerAnonymousFunctionNodes as $workerAnonymousFunctionNode) { + if (! $workerAnonymousFunctionNode instanceof AnonymousFunctionNode) { + throw new RuntimeException( + 'Parallel analysis worker returned invalid anonymous function nodes.' + ); + } + + $anonymousFunctionNodes[] = $workerAnonymousFunctionNode; + } } catch (RuntimeException $runtimeException) { $failure ??= $runtimeException->getMessage(); } finally { @@ -353,7 +389,15 @@ public function extract( throw new RuntimeException($failure); } - return new ExtractionResult($nodes, $fileAnalyses, $anonymousClassNodes, $fileReferences, $fileInstantiations); + return new ExtractionResult( + $nodes, + $fileAnalyses, + $anonymousClassNodes, + $fileReferences, + $fileInstantiations, + $functionNodes, + $anonymousFunctionNodes, + ); } /** diff --git a/src/Architecture.php b/src/Architecture.php index 0ac1c454..92a5b77e 100644 --- a/src/Architecture.php +++ b/src/Architecture.php @@ -6,6 +6,8 @@ use Boundwize\StructArmed\Exception\RuleNotFoundException; use Boundwize\StructArmed\Preset\PresetInterface; +use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface; +use Boundwize\StructArmed\Rule\FunctionRuleInterface; use Boundwize\StructArmed\Rule\ProjectRuleInterface; use Boundwize\StructArmed\Rule\RuleInterface; use InvalidArgumentException; @@ -48,7 +50,10 @@ final class Architecture /** @var array> name → path prefixes */ private array $layers = []; - /** @var array key → rule */ + /** + * @var array + * key → rule + */ private array $rules = []; /** @var array, list|null> */ @@ -350,8 +355,10 @@ public function registerPresetSourcePaths(string $preset, ?array $sourcePaths): * Add a new custom rule. * If a rule with this key already exists it will be replaced. */ - public function rule(string $key, RuleInterface|ProjectRuleInterface $rule): self - { + public function rule( + string $key, + RuleInterface|ProjectRuleInterface|FunctionRuleInterface|AnonymousFunctionRuleInterface $rule, + ): self { $this->rules[$key] = $rule; $this->resolvePendingRuleSkip($key); @@ -365,8 +372,10 @@ public function rule(string $key, RuleInterface|ProjectRuleInterface $rule): sel * * @throws RuleNotFoundException */ - public function replaceRule(string $key, RuleInterface|ProjectRuleInterface $rule): self - { + public function replaceRule( + string $key, + RuleInterface|ProjectRuleInterface|FunctionRuleInterface|AnonymousFunctionRuleInterface $rule, + ): self { if (! isset($this->rules[$key])) { throw new RuleNotFoundException(sprintf( 'Cannot replace rule [%s] — rule not found. ' @@ -425,7 +434,9 @@ public function getRulesetSkipPaths(): array return $this->rulesetSkipPaths; } - /** @return array */ + /** + * @return array + */ public function getRules(): array { return $this->rules; diff --git a/src/Cache/AnalysisCacheMetadataFactory.php b/src/Cache/AnalysisCacheMetadataFactory.php index c60c138a..727d50e9 100644 --- a/src/Cache/AnalysisCacheMetadataFactory.php +++ b/src/Cache/AnalysisCacheMetadataFactory.php @@ -67,11 +67,11 @@ public function fileHash(string $path): string } /** - * Cached ClassNodes store resolved layer assignments, which depend on the + * Cached analysis nodes store resolved layer assignments, which depend on the * composer.json PSR-4 mappings as well as the config, so both hashes must * key the namespace or a composer.json change would reuse stale layers. */ - public function classNodeCacheNamespace(string $basePath, string $configHash): string + public function analysisNodeCacheNamespace(string $basePath, string $configHash): string { return hash('xxh128', $configHash . "\0" . $this->composerHash($basePath)); } diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 2d5d79fb..2963fb55 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -5,10 +5,12 @@ namespace Boundwize\StructArmed\Cache; use Boundwize\StructArmed\Analyser\AnonymousClassNode; +use Boundwize\StructArmed\Analyser\AnonymousFunctionNode; use Boundwize\StructArmed\Analyser\ClassNode; use Boundwize\StructArmed\Analyser\ConstantNode; use Boundwize\StructArmed\Analyser\EnumCaseNode; use Boundwize\StructArmed\Analyser\FileAnalysis; +use Boundwize\StructArmed\Analyser\FunctionNode; use Boundwize\StructArmed\Analyser\MethodNode; use Boundwize\StructArmed\Analyser\PropertyNode; use Boundwize\StructArmed\Rule\RuleViolation; @@ -186,18 +188,20 @@ private function ensureCacheInitialised(): void * classNodes: list, * anonymousClassNodes: list, * fileReferences: list, - * fileInstantiations: list + * fileInstantiations: list, + * functionNodes: list, + * anonymousFunctionNodes: list * }|null */ - public function loadClassNodes(string $file, string $namespace): ?array + public function loadAnalysisNodes(string $file, string $namespace): ?array { - $payload = $this->classNodePayload($file, $namespace); + $payload = $this->analysisNodePayload($file, $namespace); if ($payload === null) { return null; } - return $this->classNodeResultFromPayload($payload); + return $this->analysisNodeResultFromPayload($payload); } /** @@ -206,12 +210,14 @@ public function loadClassNodes(string $file, string $namespace): ?array * anonymousClassNodes: list, * fileReferences: list, * fileInstantiations: list, + * functionNodes: list, + * anonymousFunctionNodes: list, * fileAnalysis: FileAnalysis * }|null */ - public function loadClassNodesWithFileAnalysis(string $file, string $namespace): ?array + public function loadAnalysisNodesWithFileAnalysis(string $file, string $namespace): ?array { - $payload = $this->classNodePayload($file, $namespace); + $payload = $this->analysisNodePayload($file, $namespace); if ($payload === null) { return null; @@ -225,7 +231,7 @@ public function loadClassNodesWithFileAnalysis(string $file, string $namespace): return null; } - $result = $this->classNodeResultFromPayload($payload); + $result = $this->analysisNodeResultFromPayload($payload); if ($result === null) { return null; @@ -242,37 +248,45 @@ public function loadClassNodesWithFileAnalysis(string $file, string $namespace): * classNodes: list, * anonymousClassNodes: list, * fileReferences: list, - * fileInstantiations: list + * fileInstantiations: list, + * functionNodes: list, + * anonymousFunctionNodes: list * }|null */ - private function classNodeResultFromPayload(array $payload): ?array + private function analysisNodeResultFromPayload(array $payload): ?array { - $classNodes = $this->classNodesFromPayload($payload); - $anonymousClassNodes = $this->anonymousClassNodesFromPayload($payload); - $fileReferences = $this->fileReferencesFromPayload($payload); - $fileInstantiations = $this->fileInstantiationsFromPayload($payload); + $classNodes = $this->classNodesFromPayload($payload); + $anonymousClassNodes = $this->anonymousClassNodesFromPayload($payload); + $fileReferences = $this->fileReferencesFromPayload($payload); + $fileInstantiations = $this->fileInstantiationsFromPayload($payload); + $functionNodes = $this->functionNodesFromPayload($payload); + $anonymousFunctionNodes = $this->anonymousFunctionNodesFromPayload($payload); if ( $classNodes === null || $anonymousClassNodes === null || $fileReferences === null || $fileInstantiations === null + || $functionNodes === null + || $anonymousFunctionNodes === null ) { return null; } return [ - 'classNodes' => $classNodes, - 'anonymousClassNodes' => $anonymousClassNodes, - 'fileReferences' => $fileReferences, - 'fileInstantiations' => $fileInstantiations, + 'classNodes' => $classNodes, + 'anonymousClassNodes' => $anonymousClassNodes, + 'fileReferences' => $fileReferences, + 'fileInstantiations' => $fileInstantiations, + 'functionNodes' => $functionNodes, + 'anonymousFunctionNodes' => $anonymousFunctionNodes, ]; } /** @return array|null */ - private function classNodePayload(string $file, string $namespace): ?array + private function analysisNodePayload(string $file, string $namespace): ?array { - $payload = $this->read($this->classNodesKey($file, $namespace)); + $payload = $this->read($this->analysisNodesKey($file, $namespace)); if ($payload === null || ($payload['metadata'] ?? null) !== $this->fileMetadata($file, $namespace)) { return null; @@ -316,8 +330,10 @@ private function classNodesFromPayload(array $payload): ?array * @param list $fileReferences Class-like references made outside any * named class-like scope in this file * @param list $fileInstantiations Class-like instantiations in this file + * @param list $functionNodes + * @param list $anonymousFunctionNodes */ - public function storeClassNodes( + public function storeAnalysisNodes( string $file, string $namespace, array $classNodes, @@ -325,15 +341,22 @@ public function storeClassNodes( array $anonymousClassNodes = [], array $fileReferences = [], array $fileInstantiations = [], + array $functionNodes = [], + array $anonymousFunctionNodes = [], ): void { $this->ensureCacheInitialised(); $payload = [ - 'metadata' => $this->fileMetadata($file, $namespace), - 'nodes' => array_map($this->classNodeToArray(...), $classNodes), - 'anonymousClassNodes' => array_map($this->anonymousClassNodeToArray(...), $anonymousClassNodes), - 'fileReferences' => $fileReferences, - 'fileInstantiations' => $fileInstantiations, + 'metadata' => $this->fileMetadata($file, $namespace), + 'nodes' => array_map($this->classNodeToArray(...), $classNodes), + 'anonymousClassNodes' => array_map($this->anonymousClassNodeToArray(...), $anonymousClassNodes), + 'fileReferences' => $fileReferences, + 'fileInstantiations' => $fileInstantiations, + 'functionNodes' => array_map($this->functionNodeToArray(...), $functionNodes), + 'anonymousFunctionNodes' => array_map( + $this->anonymousFunctionNodeToArray(...), + $anonymousFunctionNodes + ), ]; if ($fileAnalysis instanceof FileAnalysis) { @@ -341,7 +364,7 @@ public function storeClassNodes( } file_put_contents( - $this->path($this->classNodesKey($file, $namespace)), + $this->path($this->analysisNodesKey($file, $namespace)), json_encode($payload, JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR) ); } @@ -382,6 +405,7 @@ private function ruleViolationFromArray(array $violation): ?RuleViolation $method = $violation['method'] ?? null; $constant = $violation['constant'] ?? null; $property = $violation['property'] ?? null; + $function = $violation['function'] ?? null; $fixable = $violation['fixable'] ?? false; if ( @@ -394,6 +418,7 @@ private function ruleViolationFromArray(array $violation): ?RuleViolation || ($method !== null && ! is_string($method)) || ($constant !== null && ! is_string($constant)) || ($property !== null && ! is_string($property)) + || ($function !== null && ! is_string($function)) || ! is_bool($fixable) ) { return null; @@ -410,6 +435,7 @@ className: $className, methodName: $method, constantName: $constant, propertyName: $property, + functionName: $function, ); } @@ -494,6 +520,228 @@ traits: $traits, return $anonymousClassNodes; } + /** + * @return array + */ + private function functionNodeToArray(FunctionNode $functionNode): array + { + return [ + 'functionName' => $functionNode->functionName, + 'file' => $functionNode->file, + 'line' => $functionNode->line, + 'layer' => $functionNode->layer, + 'hasReturnType' => $functionNode->hasReturnType, + 'paramCount' => $functionNode->paramCount, + 'cyclomaticComplexity' => $functionNode->cyclomaticComplexity, + 'lineCount' => $functionNode->lineCount, + 'dependencies' => $functionNode->dependencies, + 'functionCalls' => array_values($functionNode->functionCalls), + 'superglobals' => array_values($functionNode->superglobals), + 'languageConstructs' => array_values($functionNode->languageConstructs), + 'layers' => $functionNode->layers, + ]; + } + + /** + * @param array $payload + * @return list|null + */ + private function functionNodesFromPayload(array $payload): ?array + { + $rawNodes = $payload['functionNodes'] ?? []; + + if (! is_array($rawNodes)) { + return null; + } + + $functionNodes = []; + + foreach ($rawNodes as $rawNode) { + if (! is_array($rawNode)) { + return null; + } + + $functionName = $rawNode['functionName'] ?? null; + $body = $this->functionLikeBodyFromArray($rawNode); + + if (! is_string($functionName) || $body === null) { + return null; + } + + $functionNodes[] = new FunctionNode( + functionName: $functionName, + file: $body['file'], + line: $body['line'], + layer: $body['layer'], + hasReturnType: $body['hasReturnType'], + paramCount: $body['paramCount'], + cyclomaticComplexity: $body['cyclomaticComplexity'], + lineCount: $body['lineCount'], + dependencies: $body['dependencies'], + functionCalls: $body['functionCalls'], + superglobals: $body['superglobals'], + languageConstructs: $body['languageConstructs'], + layers: $body['layers'], + ); + } + + return $functionNodes; + } + + /** + * @return array + */ + private function anonymousFunctionNodeToArray(AnonymousFunctionNode $anonymousFunctionNode): array + { + return [ + 'file' => $anonymousFunctionNode->file, + 'line' => $anonymousFunctionNode->line, + 'layer' => $anonymousFunctionNode->layer, + 'isArrowFunction' => $anonymousFunctionNode->isArrowFunction, + 'isStatic' => $anonymousFunctionNode->isStatic, + 'enclosingClassName' => $anonymousFunctionNode->enclosingClassName, + 'enclosingFunctionName' => $anonymousFunctionNode->enclosingFunctionName, + 'usesThis' => $anonymousFunctionNode->usesThis, + 'hasReturnType' => $anonymousFunctionNode->hasReturnType, + 'paramCount' => $anonymousFunctionNode->paramCount, + 'cyclomaticComplexity' => $anonymousFunctionNode->cyclomaticComplexity, + 'lineCount' => $anonymousFunctionNode->lineCount, + 'dependencies' => $anonymousFunctionNode->dependencies, + 'functionCalls' => array_values($anonymousFunctionNode->functionCalls), + 'superglobals' => array_values($anonymousFunctionNode->superglobals), + 'languageConstructs' => array_values($anonymousFunctionNode->languageConstructs), + 'layers' => $anonymousFunctionNode->layers, + ]; + } + + /** + * @param array $payload + * @return list|null + */ + private function anonymousFunctionNodesFromPayload(array $payload): ?array + { + $rawNodes = $payload['anonymousFunctionNodes'] ?? []; + + if (! is_array($rawNodes)) { + return null; + } + + $anonymousFunctionNodes = []; + + foreach ($rawNodes as $rawNode) { + if (! is_array($rawNode)) { + return null; + } + + $isArrowFunction = $rawNode['isArrowFunction'] ?? null; + $isStatic = $rawNode['isStatic'] ?? null; + $enclosingClassName = $rawNode['enclosingClassName'] ?? null; + $enclosingFunctionName = $rawNode['enclosingFunctionName'] ?? null; + $usesThis = $rawNode['usesThis'] ?? null; + $body = $this->functionLikeBodyFromArray($rawNode); + + if ( + ! is_bool($isArrowFunction) + || ! is_bool($isStatic) + || ! is_bool($usesThis) + || ($enclosingClassName !== null && ! is_string($enclosingClassName)) + || ($enclosingFunctionName !== null && ! is_string($enclosingFunctionName)) + || $body === null + ) { + return null; + } + + $anonymousFunctionNodes[] = new AnonymousFunctionNode( + file: $body['file'], + line: $body['line'], + layer: $body['layer'], + isArrowFunction: $isArrowFunction, + isStatic: $isStatic, + enclosingClassName: $enclosingClassName, + enclosingFunctionName: $enclosingFunctionName, + usesThis: $usesThis, + hasReturnType: $body['hasReturnType'], + paramCount: $body['paramCount'], + cyclomaticComplexity: $body['cyclomaticComplexity'], + lineCount: $body['lineCount'], + dependencies: $body['dependencies'], + functionCalls: $body['functionCalls'], + superglobals: $body['superglobals'], + languageConstructs: $body['languageConstructs'], + layers: $body['layers'], + ); + } + + return $anonymousFunctionNodes; + } + + /** + * The fields FunctionNode and AnonymousFunctionNode share, type-checked. + * + * @param array $node + * @return array{ + * file: string, + * line: int, + * layer: string|null, + * hasReturnType: bool, + * paramCount: int, + * cyclomaticComplexity: int, + * lineCount: int, + * dependencies: list, + * functionCalls: list, + * superglobals: list, + * languageConstructs: list, + * layers: list + * }|null + */ + private function functionLikeBodyFromArray(array $node): ?array + { + $file = $node['file'] ?? null; + $line = $node['line'] ?? null; + $layer = $node['layer'] ?? null; + $hasReturnType = $node['hasReturnType'] ?? null; + $paramCount = $node['paramCount'] ?? null; + $cyclomaticComplexity = $node['cyclomaticComplexity'] ?? null; + $lineCount = $node['lineCount'] ?? null; + $dependencies = $node['dependencies'] ?? null; + $functionCalls = $node['functionCalls'] ?? null; + $superglobals = $node['superglobals'] ?? null; + $languageConstructs = $node['languageConstructs'] ?? null; + $layers = $node['layers'] ?? null; + + if ( + ! is_string($file) + || ! is_int($line) + || ($layer !== null && ! is_string($layer)) + || ! is_bool($hasReturnType) + || ! is_int($paramCount) + || ! is_int($cyclomaticComplexity) + || ! is_int($lineCount) + || ! $this->isStringArray($dependencies) + || ! $this->isStringArray($functionCalls) + || ! $this->isStringArray($superglobals) + || ! $this->isStringArray($languageConstructs) + || ! $this->isStringArray($layers) + ) { + return null; + } + + return [ + 'file' => $file, + 'line' => $line, + 'layer' => $layer, + 'hasReturnType' => $hasReturnType, + 'paramCount' => $paramCount, + 'cyclomaticComplexity' => $cyclomaticComplexity, + 'lineCount' => $lineCount, + 'dependencies' => array_values($dependencies), + 'functionCalls' => array_values($functionCalls), + 'superglobals' => array_values($superglobals), + 'languageConstructs' => array_values($languageConstructs), + 'layers' => array_values($layers), + ]; + } + /** * @return array */ @@ -927,7 +1175,7 @@ private function path(string $key): string return sprintf('%s/%s.json', $this->cacheDirectory, $key); } - private function classNodesKey(string $file, string $namespace): string + private function analysisNodesKey(string $file, string $namespace): string { return 'class-nodes-' . hash('xxh128', $namespace . "\0" . $file); } diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index 51806e7f..15d4c625 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -136,8 +136,8 @@ public function run(array $arguments, string $basePath): int $configHash, $composerGeneratedVersionHash ); - $classNodeCacheNamespace = $analysisCacheMetadataFactory->classNodeCacheNamespace($basePath, $configHash); - $analyser = new Analyser($basePath, $analysisResultCache, $classNodeCacheNamespace); + $analysisNodeCacheNamespace = $analysisCacheMetadataFactory->analysisNodeCacheNamespace($basePath, $configHash); + $analyser = new Analyser($basePath, $analysisResultCache, $analysisNodeCacheNamespace); if (isset($options['clear-cache']) || $analysisResultCache->shouldInvalidate()) { $analysisResultCache->clear(); diff --git a/src/PHPUnit/StructArmedExtension.php b/src/PHPUnit/StructArmedExtension.php index e3a71802..eb1a43f3 100644 --- a/src/PHPUnit/StructArmedExtension.php +++ b/src/PHPUnit/StructArmedExtension.php @@ -62,7 +62,7 @@ public function bootstrap( $analyser = new Analyser( $basePath, $analysisResultCache, - $analysisCacheMetadataFactory->classNodeCacheNamespace($basePath, $configHash) + $analysisCacheMetadataFactory->analysisNodeCacheNamespace($basePath, $configHash) ); $files = $analyser->filesForAnalysis($architecture); diff --git a/src/Rule/AnonymousFunctionRuleInterface.php b/src/Rule/AnonymousFunctionRuleInterface.php new file mode 100644 index 00000000..d2119fbd --- /dev/null +++ b/src/Rule/AnonymousFunctionRuleInterface.php @@ -0,0 +1,27 @@ +static || $node->getStartLine() !== $this->line) { + return null; + } + + $node->static = true; + + return $node; + } +} diff --git a/src/Rule/FunctionRuleInterface.php b/src/Rule/FunctionRuleInterface.php new file mode 100644 index 00000000..cce24267 --- /dev/null +++ b/src/Rule/FunctionRuleInterface.php @@ -0,0 +1,27 @@ +propertyName; } + if ($this->functionName !== null) { + $data['function'] = $this->functionName; + } + return $data; } } diff --git a/src/Rule/Rules/Function_/MustBeStaticAnonymousFunctionRule.php b/src/Rule/Rules/Function_/MustBeStaticAnonymousFunctionRule.php new file mode 100644 index 00000000..bdadbed2 --- /dev/null +++ b/src/Rule/Rules/Function_/MustBeStaticAnonymousFunctionRule.php @@ -0,0 +1,57 @@ +isInLayer($this->layer); + } + + public function evaluateAnonymousFunction(AnonymousFunctionNode $anonymousFunctionNode): ?RuleViolation + { + // A closure reading `$this` cannot be static: PHP raises an error + // when a static closure accesses `$this`. + if ($anonymousFunctionNode->isStatic || $anonymousFunctionNode->usesThis) { + return null; + } + + return new RuleViolation( + message: sprintf( + '%s in [%s] must be declared static', + $anonymousFunctionNode->getType(), + $anonymousFunctionNode->enclosingScopeName() + ), + file: $anonymousFunctionNode->file, + line: $anonymousFunctionNode->line, + className: $anonymousFunctionNode->enclosingScopeName(), + layer: $anonymousFunctionNode->layer, + ); + } + + protected function createFixerVisitor(RuleViolation $ruleViolation): AddStaticAnonymousFunctionVisitor + { + return new AddStaticAnonymousFunctionVisitor($ruleViolation->line); + } +} diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index c3693ecb..eaaa3ec2 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -6,7 +6,9 @@ use Boundwize\StructArmed\Analyser\Analyser; use Boundwize\StructArmed\Analyser\AnalyserOptions; +use Boundwize\StructArmed\Analyser\AnonymousFunctionNode; use Boundwize\StructArmed\Analyser\FileAnalysisProvider; +use Boundwize\StructArmed\Analyser\FunctionNode; use Boundwize\StructArmed\Analyser\Parallel\ParallelAnalysisNodeExtractor; use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Cache\AnalysisResultCache; @@ -21,7 +23,9 @@ use Boundwize\StructArmed\Preset\Presets\Psr4Preset; use Boundwize\StructArmed\Preset\Presets\YagniPreset; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; +use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface; use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface; +use Boundwize\StructArmed\Rule\FunctionRuleInterface; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule; use Boundwize\StructArmed\Rule\Rules\Composer\Psr4SourcePathsRule; use Boundwize\StructArmed\Rule\Rules\File\Psr1PhpTagsRule; @@ -38,7 +42,9 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use function array_filter; use function array_map; +use function array_values; use function count; use function dirname; use function file_put_contents; @@ -61,6 +67,198 @@ final class AnalyserTest extends TestCase { use TemporaryDirectoryCleanupTrait; + /** + * A rule flagging every named function and every anonymous function that + * accesses a superglobal, implementing both function-like interfaces. + */ + private function makeNoSuperglobalsInFunctionsRule(): FunctionRuleInterface&AnonymousFunctionRuleInterface + { + return new class implements FunctionRuleInterface, AnonymousFunctionRuleInterface { + public function appliesToFunction(FunctionNode $functionNode): bool + { + return $functionNode->isInLayer('Source'); + } + + public function evaluateFunction(FunctionNode $functionNode): ?RuleViolation + { + if (! $functionNode->accessesSuperglobals()) { + return null; + } + + return new RuleViolation( + message: 'Function [' . $functionNode->functionName . '] must not access superglobals', + file: $functionNode->file, + line: $functionNode->line, + className: $functionNode->functionName, + layer: $functionNode->layer, + functionName: $functionNode->functionName, + ); + } + + public function appliesToAnonymousFunction(AnonymousFunctionNode $anonymousFunctionNode): bool + { + return $anonymousFunctionNode->isInLayer('Source'); + } + + public function evaluateAnonymousFunction(AnonymousFunctionNode $anonymousFunctionNode): ?RuleViolation + { + if (! $anonymousFunctionNode->accessesSuperglobals()) { + return null; + } + + return new RuleViolation( + message: $anonymousFunctionNode->getType() . ' in [' + . $anonymousFunctionNode->enclosingScopeName() + . '] must not access superglobals', + file: $anonymousFunctionNode->file, + line: $anonymousFunctionNode->line, + className: $anonymousFunctionNode->enclosingScopeName(), + layer: $anonymousFunctionNode->layer, + ); + } + }; + } + + /** @return array */ + private function functionRuleProjectFiles(): array + { + return [ + 'src/helpers.php' => ' ' $_SERVER["z"]; }' . "\n" + . '}' . "\n", + 'src/Skipped/skip.php' => 'makeTempProject($this->functionRuleProjectFiles()); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('functions.no_superglobals', $this->makeNoSuperglobalsInFunctionsRule()) + ->skip(['functions.no_superglobals' => ['src/Skipped/']]); + + foreach ([AnalyserOptions::sequential(), AnalyserOptions::parallel(2)] as $analyserOptions) { + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, $analyserOptions) + ->forRule('functions.no_superglobals'); + + $messages = array_map( + static fn(RuleViolation $ruleViolation): string => $ruleViolation->message, + $violations + ); + sort($messages); + + $this->assertSame([ + 'Arrow function in [App\\Handler] must not access superglobals', + 'Closure in [file scope] must not access superglobals', + 'Function [App\\dirty] must not access superglobals', + ], $messages); + + foreach ($violations as $violation) { + $this->assertSame('functions.no_superglobals', $violation->ruleKey); + $this->assertSame('Source', $violation->layer); + $this->assertFalse($violation->fixable); + } + + $functionViolation = array_values(array_filter( + $violations, + static fn(RuleViolation $ruleViolation): bool => $ruleViolation->functionName !== null + )); + + $this->assertCount(1, $functionViolation); + $this->assertSame('App\\dirty', $functionViolation[0]->functionName); + $this->assertSame(4, $functionViolation[0]->line); + } + } + + public function testFunctionRulesHonourGlobalSkipPaths(): void + { + $basePath = $this->makeTempProject($this->functionRuleProjectFiles()); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('functions.no_superglobals', $this->makeNoSuperglobalsInFunctionsRule()) + ->skipPaths(['src/helpers.php', 'src/Skipped/']); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('functions.no_superglobals'); + + $this->assertCount(1, $violations); + $this->assertStringEndsWith('/src/Handler.php', $this->normalisePath($violations[0]->file)); + } + + public function testFunctionRuleViolationsSurviveTheAnalysisNodeCache(): void + { + $basePath = $this->makeTempProject($this->functionRuleProjectFiles()); + $analysisResultCache = new AnalysisResultCache($basePath, new FileHashProvider(), 'cache'); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('functions.no_superglobals', $this->makeNoSuperglobalsInFunctionsRule()) + ->skip(['functions.no_superglobals' => ['src/Skipped/']]); + + $coldViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('functions.no_superglobals'); + $warmViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('functions.no_superglobals'); + + $this->assertCount(3, $coldViolations); + $this->assertEquals($coldViolations, $warmViolations); + } + + public function testFunctionRuleViolationsSurviveTheAnalysisNodeCacheWithFileAnalysis(): void + { + $basePath = $this->makeTempProject($this->functionRuleProjectFiles()); + $analysisResultCache = new AnalysisResultCache($basePath, new FileHashProvider(), 'cache'); + + // A file-analysis rule makes the warm run load nodes through the + // file-analysis cache path, which must also restore function-likes. + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('functions.no_superglobals', $this->makeNoSuperglobalsInFunctionsRule()) + ->rule('psr1.php_tags', new Psr1PhpTagsRule(['src/'])) + ->skip(['functions.no_superglobals' => ['src/Skipped/']]); + + $coldViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('functions.no_superglobals'); + $warmViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('functions.no_superglobals'); + + $this->assertCount(3, $coldViolations); + $this->assertEquals($coldViolations, $warmViolations); + } + + public function testSkippedFunctionRuleIsNotEvaluated(): void + { + $basePath = $this->makeTempProject($this->functionRuleProjectFiles()); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('functions.no_superglobals', $this->makeNoSuperglobalsInFunctionsRule()) + ->skipRule('functions.no_superglobals'); + + $ruleViolationCollection = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()); + + $this->assertFalse($ruleViolationCollection->hasViolations()); + } + public function testBuiltInPsr1RulesDoNotRediscoverFilesAfterExtraction(): void { $basePath = $this->makeTempProject([ diff --git a/tests/Analyser/ClassCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php similarity index 91% rename from tests/Analyser/ClassCollectorTest.php rename to tests/Analyser/AnalysisNodeCollectorTest.php index 636219d0..04a97079 100644 --- a/tests/Analyser/ClassCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -4,8 +4,8 @@ namespace Boundwize\StructArmed\Tests\Analyser; +use Boundwize\StructArmed\Analyser\AnalysisNodeCollector; use Boundwize\StructArmed\Analyser\AnonymousClassNode; -use Boundwize\StructArmed\Analyser\ClassCollector; use Boundwize\StructArmed\Analyser\ClassLikeAnalysis; use Boundwize\StructArmed\Analyser\ClassNode; use Boundwize\StructArmed\Analyser\EnumCaseNode; @@ -22,10 +22,10 @@ use function array_column; #[CoversClass(AnonymousClassNode::class)] -#[CoversClass(ClassCollector::class)] +#[CoversClass(AnalysisNodeCollector::class)] #[CoversClass(ClassLikeAnalysis::class)] #[CoversClass(EnumCaseNode::class)] -final class ClassCollectorTest extends TestCase +final class AnalysisNodeCollectorTest extends TestCase { private const BASE_PATH = '/structarmed-test-project'; @@ -49,19 +49,19 @@ private function collectAnonymousClassNodes(string $code): array return $this->makeCollector($code)->getAnonymousClassNodes(); } - private function makeCollector(string $code): ClassCollector + private function makeCollector(string $code): AnalysisNodeCollector { $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); - $classCollector = new ClassCollector($namespaceLayerResolver); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); $parser = (new ParserFactory())->createForNewestSupportedVersion(); $ast = $parser->parse($code); - $classCollector->setCurrentFile('/fake/path/Foo.php'); + $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php'); - $nodeTraverser = new NodeTraverser(new NameResolver(), $classCollector); + $nodeTraverser = new NodeTraverser(new NameResolver(), $analysisNodeCollector); $nodeTraverser->traverse($ast ?? []); - return $classCollector; + return $analysisNodeCollector; } public function testCollectsFileReferencesFromProceduralCode(): void @@ -70,11 +70,11 @@ public function testCollectsFileReferencesFromProceduralCode(): void . 'function handle(Contract $contract): void {}' . "\n" . 'function check(object $value): bool { return $value instanceof Contract; }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['App\Contract']], - $classCollector->getFileReferences() + $analysisNodeCollector->getFileReferences() ); } @@ -84,11 +84,11 @@ public function testDoesNotCollectFileReferencesFromClassBodies(): void . 'final class Checker { public function check(object $value): bool' . ' { return $value instanceof Contract; } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // References inside a named class-like land on its ClassNode // dependencies, not in the file-level references. - $this->assertSame([], $classCollector->getFileReferences()); + $this->assertSame([], $analysisNodeCollector->getFileReferences()); } public function testCollectsClassNameShapedStringValuesAsFileReferences(): void @@ -97,11 +97,11 @@ public function testCollectsClassNameShapedStringValuesAsFileReferences(): void . 'final class Checker { public function check(object $obj): bool {' . ' $contract = \'App\\Contract\'; return $obj instanceof $contract; } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['App\Contract']], - $classCollector->getFileReferences() + $analysisNodeCollector->getFileReferences() ); } @@ -110,13 +110,13 @@ public function testCollectsLeadingBackslashStringValuesAsNormalizedFileReferenc $code = 'makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // '\App\Contract' is a valid fully-qualified spelling; the stored // name drops the leading separator so it matches ClassNode::$className. $this->assertSame( ['/fake/path/Foo.php' => ['App\Contract']], - $classCollector->getFileReferences() + $analysisNodeCollector->getFileReferences() ); } @@ -125,11 +125,11 @@ public function testCollectsLeadingBackslashStringInstantiationsNormalized(): vo $code = 'makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['App\Service']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -139,13 +139,13 @@ public function testResolvesConcatenatedConstantClassExpressionAsInstantiation() . 'final class Factory { public function make(): object { return new (\'App\\Service\' . 1)(); } }' . "\n" . 'final class Other { public function make(): object { return new (1 + 1)(); } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // 'App\Service1' is a class-shaped string; `1 + 1` is not a constant // class expression the collector evaluates at all. $this->assertSame( ['/fake/path/Foo.php' => ['App\Service1']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -155,9 +155,9 @@ public function testDoesNotCollectNonClassNameShapedStringValues(): void . 'final class Greeter { public function greet(): string {' . ' $mode = true ? \'foo-bar\' : \'hello world\'; return $mode . \'123abc\' . \'\'; } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); - $this->assertSame([], $classCollector->getFileReferences()); + $this->assertSame([], $analysisNodeCollector->getFileReferences()); } public function testCollectsInstantiations(): void @@ -165,13 +165,13 @@ public function testCollectsInstantiations(): void $code = 'makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['App\Service']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); - $this->assertSame([], $classCollector->getFileReferences()); + $this->assertSame([], $analysisNodeCollector->getFileReferences()); } public function testResolvesSelfStaticAndParentInstantiations(): void @@ -183,7 +183,7 @@ public function testResolvesSelfStaticAndParentInstantiations(): void . ' public function three(): BaseRepository { return new parent(); }' . "\n" . '}'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // `static` is late-bound, so it is recorded as a marker the analyser // resolves to Repository and its descendants. @@ -191,24 +191,24 @@ public function testResolvesSelfStaticAndParentInstantiations(): void [ '/fake/path/Foo.php' => [ 'App\Repository', - ClassCollector::deferredInstantiationMarker('static', 'App\Repository'), + AnalysisNodeCollector::deferredInstantiationMarker('static', 'App\Repository'), 'App\BaseRepository', ], ], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } public function testDeferredInstantiationMarkerRoundTrips(): void { foreach (['self', 'static', 'parent'] as $keyword) { - $marker = ClassCollector::deferredInstantiationMarker($keyword, 'App\\Factory'); + $marker = AnalysisNodeCollector::deferredInstantiationMarker($keyword, 'App\\Factory'); - $this->assertSame([$keyword, 'App\\Factory'], ClassCollector::parseDeferredInstantiationMarker($marker)); + $this->assertSame([$keyword, 'App\\Factory'], AnalysisNodeCollector::parseDeferredInstantiationMarker($marker)); } - $this->assertNull(ClassCollector::parseDeferredInstantiationMarker('App\\Factory')); - $this->assertNull(ClassCollector::parseDeferredInstantiationMarker('other@App\\Factory')); + $this->assertNull(AnalysisNodeCollector::parseDeferredInstantiationMarker('App\\Factory')); + $this->assertNull(AnalysisNodeCollector::parseDeferredInstantiationMarker('other@App\\Factory')); } public function testDoesNotRecordStringWithMarkerSeparatorAsInstantiation(): void @@ -216,11 +216,11 @@ public function testDoesNotRecordStringWithMarkerSeparatorAsInstantiation(): voi $code = 'makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // `@` never occurs in a class name, and only self/static/parent form // a marker: anything else records nothing. - $this->assertSame([], $classCollector->getFileInstantiations()); + $this->assertSame([], $analysisNodeCollector->getFileInstantiations()); } public function testRecordsTraitSelfStaticAndParentInstantiationsAsMarkers(): void @@ -235,7 +235,7 @@ public function testRecordsTraitSelfStaticAndParentInstantiationsAsMarkers(): vo . ' public static function staticViaClassConstant(): object { return new (static::class)(); }' . "\n" . '}'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // A trait is never instantiated itself: each marker is resolved by the // analyser against every class using the trait. `new (X::class)()` @@ -243,12 +243,12 @@ public function testRecordsTraitSelfStaticAndParentInstantiationsAsMarkers(): vo $this->assertSame( [ '/fake/path/Foo.php' => [ - ClassCollector::deferredInstantiationMarker('parent', 'App\Factory'), - ClassCollector::deferredInstantiationMarker('self', 'App\Factory'), - ClassCollector::deferredInstantiationMarker('static', 'App\Factory'), + AnalysisNodeCollector::deferredInstantiationMarker('parent', 'App\Factory'), + AnalysisNodeCollector::deferredInstantiationMarker('self', 'App\Factory'), + AnalysisNodeCollector::deferredInstantiationMarker('static', 'App\Factory'), ], ], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -261,7 +261,7 @@ public function testRecordsClassSelfAsNameAndStaticAsMarker(): void . ' public static function createParent(): object { return new parent(); }' . "\n" . '}'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // `self` and `parent` are lexically bound; `static` is late-bound, so // its marker lets the analyser include the descendants of Model. @@ -269,11 +269,11 @@ public function testRecordsClassSelfAsNameAndStaticAsMarker(): void [ '/fake/path/Foo.php' => [ 'App\Model', - ClassCollector::deferredInstantiationMarker('static', 'App\Model'), + AnalysisNodeCollector::deferredInstantiationMarker('static', 'App\Model'), 'App\Base', ], ], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -297,7 +297,7 @@ public function testResolvesParentInsideAnonymousClassAgainstTheAnonymousClass() . ' public function own(): object { return new parent(); }' . "\n" . '}'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // `parent` belongs to the innermost class-like: the anonymous class, // not the enclosing trait or class. An anonymous class without a @@ -306,11 +306,11 @@ public function testResolvesParentInsideAnonymousClassAgainstTheAnonymousClass() [ '/fake/path/Foo.php' => [ 'App\Base', - ClassCollector::deferredInstantiationMarker('parent', 'App\Factory'), + AnalysisNodeCollector::deferredInstantiationMarker('parent', 'App\Factory'), 'App\Other', ], ], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -325,11 +325,11 @@ public function testDoesNotRecordParentAccessWithoutNewAsInstantiation(): void . '}' . "\n" . 'class Host extends Base { use Factory; public function __construct() { parent::__construct(); } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // Only `new` instantiates: static calls, constants, ::class, and // static properties on parent never mark it (or a trait marker). - $this->assertSame([], $classCollector->getFileInstantiations()); + $this->assertSame([], $analysisNodeCollector->getFileInstantiations()); } public function testResolvesConstantClassExpressionInstantiations(): void @@ -341,11 +341,11 @@ public function testResolvesConstantClassExpressionInstantiations(): void . ' public function fromConcat(): object { return new (\'App\\\\\' . \'Joined\')(); }' . "\n" . '}'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['App\Base', 'App\StringBase', 'App\Joined']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -354,11 +354,11 @@ public function testResolvesSelfClassConstantInstantiation(): void $code = 'makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['App\Registry']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -371,9 +371,9 @@ public function testIgnoresRuntimeFedDynamicInstantiations(): void . 'final class Holder { public function __construct(private string $class) {}' . ' public function make(): object { return new ($this->class)(); } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); - $this->assertSame([], $classCollector->getFileInstantiations()); + $this->assertSame([], $analysisNodeCollector->getFileInstantiations()); } public function testResolvesChainedReflectionConstruction(): void @@ -382,13 +382,13 @@ public function testResolvesChainedReflectionConstruction(): void . 'final class Booter { public function boot(): object {' . ' return (new \ReflectionClass(Base::class))->newInstance(); } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // ReflectionClass itself is instantiated, and so is the class it // reflects. $this->assertSame( ['/fake/path/Foo.php' => ['ReflectionClass', 'App\Base']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -398,11 +398,11 @@ public function testResolvesNullsafeChainedReflectionConstruction(): void . 'final class Booter { public function boot(): ?object {' . ' return (new \\ReflectionClass(\'App\\Child\'))?->newInstanceWithoutConstructor(); } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['ReflectionClass', 'App\Child']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -414,11 +414,11 @@ public function testIgnoresReflectionConstructionWithUnresolvableTarget(): void . 'final class Booter { public function boot(\\ReflectionClass $r, string $name): object {' . ' $other = new \\ReflectionClass($name); return $r->newInstance() ?? $other->newInstance(); } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['ReflectionClass']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -432,11 +432,11 @@ public function testIgnoresNonReflectionChainedConstructionCalls(): void . ' $a = (new Container())->newInstance(); $b = (new ($x::class))->newInstance();' . ' $c = (new \\ReflectionClass())->newInstance(); return $a ?? $b ?? $c; } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['App\Container', 'ReflectionClass']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -446,9 +446,9 @@ public function testIgnoresOrdinaryMethodCalls(): void . 'final class Caller { public function run(object $service): mixed {' . ' return $service->handle(); } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); - $this->assertSame([], $classCollector->getFileInstantiations()); + $this->assertSame([], $analysisNodeCollector->getFileInstantiations()); } public function testIgnoresUnresolvableClassNameExpressions(): void @@ -460,9 +460,9 @@ public function testIgnoresUnresolvableClassNameExpressions(): void . ' $a = new (\'App\\\\\' . $suffix)(); $b = new ($obj::class)();' . ' $c = new (\'not a class name!\')(); return $a ?? $b ?? $c; } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); - $this->assertSame([], $classCollector->getFileInstantiations()); + $this->assertSame([], $analysisNodeCollector->getFileInstantiations()); } public function testDoesNotCollectAnonymousInstantiations(): void @@ -470,20 +470,20 @@ public function testDoesNotCollectAnonymousInstantiations(): void $code = 'makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // Anonymous classes are tracked as AnonymousClassNodes, and their // known declaration does not make any named class instantiable. - $this->assertSame([], $classCollector->getFileInstantiations()); + $this->assertSame([], $analysisNodeCollector->getFileInstantiations()); } public function testIgnoresRelativeInstantiationOutsideClassScope(): void { // `new self` outside a class parses but cannot be resolved to a name; // PHP itself rejects it at runtime. - $classCollector = $this->makeCollector('makeCollector('assertSame([], $classCollector->getFileInstantiations()); + $this->assertSame([], $analysisNodeCollector->getFileInstantiations()); } public function testCollectsFinalClass(): void @@ -684,7 +684,7 @@ public function testCollectsMagicMethodFlag(): void public function testFiltersClassMethodsOncePerClassLike(): void { $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); - $classCollector = new ClassCollector($namespaceLayerResolver); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); $classLike = new class ('Foo', [ 'stmts' => [new ClassMethod('__construct'), new ClassMethod('bar')], ]) extends Class_ { @@ -698,14 +698,14 @@ public function getMethods(): array } }; - $classCollector->setCurrentFile('/fake/path/Foo.php'); + $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php'); - (new NodeTraverser(new NameResolver(), $classCollector))->traverse([$classLike]); + (new NodeTraverser(new NameResolver(), $analysisNodeCollector))->traverse([$classLike]); $this->assertSame(1, $classLike->getMethodsCallCount); $this->assertSame( ['__construct', 'bar'], - array_column($classCollector->getNodes()[0]->methods, 'name'), + array_column($analysisNodeCollector->getNodes()[0]->methods, 'name'), ); } @@ -1790,14 +1790,14 @@ public function bar(): void { public function testIgnoresClassMethodNodesOutsideTrackedClassLike(): void { $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); - $classCollector = new ClassCollector($namespaceLayerResolver); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); $classMethod = new ClassMethod('orphan'); - $classCollector->setCurrentFile('/fake/path/Foo.php'); + $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php'); - $classCollector->enterNode($classMethod); - $classCollector->leaveNode($classMethod); + $analysisNodeCollector->enterNode($classMethod); + $analysisNodeCollector->leaveNode($classMethod); - $this->assertSame([], $classCollector->getNodes()); + $this->assertSame([], $analysisNodeCollector->getNodes()); } } diff --git a/tests/Analyser/AnonymousFunctionNodeTest.php b/tests/Analyser/AnonymousFunctionNodeTest.php new file mode 100644 index 00000000..52eef685 --- /dev/null +++ b/tests/Analyser/AnonymousFunctionNodeTest.php @@ -0,0 +1,78 @@ +assertSame('Closure', $anonymousFunctionNode->getType()); + $this->assertFalse($anonymousFunctionNode->isArrowFunction); + $this->assertFalse($anonymousFunctionNode->isStatic); + $this->assertSame('file scope', $anonymousFunctionNode->enclosingScopeName()); + $this->assertSame(AnonymousFunctionNode::FILE_SCOPE, $anonymousFunctionNode->enclosingScopeName()); + $this->assertSame(['Support'], $anonymousFunctionNode->layers); + $this->assertTrue($anonymousFunctionNode->isInLayer('Support')); + $this->assertFalse($anonymousFunctionNode->accessesSuperglobals()); + } + + public function testEnclosingClassWinsOverEnclosingFunction(): void + { + $anonymousFunctionNode = new AnonymousFunctionNode( + file: '/src/Handler.php', + line: 9, + layer: null, + isArrowFunction: true, + isStatic: true, + enclosingClassName: 'App\\Handler', + enclosingFunctionName: 'App\\bootstrap', + ); + + $this->assertSame('Arrow function', $anonymousFunctionNode->getType()); + $this->assertSame('App\\Handler', $anonymousFunctionNode->enclosingScopeName()); + $this->assertSame([], $anonymousFunctionNode->layers); + } + + public function testEnclosingFunctionIsUsedWithoutEnclosingClass(): void + { + $anonymousFunctionNode = new AnonymousFunctionNode( + file: '/src/helpers.php', + line: 9, + layer: null, + enclosingFunctionName: 'App\\bootstrap', + ); + + $this->assertSame('App\\bootstrap', $anonymousFunctionNode->enclosingScopeName()); + } + + public function testBodyQueries(): void + { + $anonymousFunctionNode = new AnonymousFunctionNode( + file: '/src/helpers.php', + line: 1, + layer: 'Support', + dependencies: ['App\\View\\Template'], + functionCalls: ['App\\escape'], + superglobals: ['$_POST'], + languageConstructs: ['exit'], + ); + + $this->assertTrue($anonymousFunctionNode->dependsOn('App\\View\\Template')); + $this->assertTrue($anonymousFunctionNode->dependsOnNamespace('App\\View')); + $this->assertFalse($anonymousFunctionNode->dependsOnNamespace('App\\Domain')); + $this->assertTrue($anonymousFunctionNode->callsFunction('app\\escape')); + $this->assertTrue($anonymousFunctionNode->accessesSuperglobals()); + $this->assertTrue($anonymousFunctionNode->usesLanguageConstruct('exit')); + $this->assertTrue($anonymousFunctionNode->usesLanguageConstruct('die')); + $this->assertFalse($anonymousFunctionNode->usesLanguageConstruct('print')); + } +} diff --git a/tests/Analyser/FunctionLikeCollectionTest.php b/tests/Analyser/FunctionLikeCollectionTest.php new file mode 100644 index 00000000..7d9fc6f9 --- /dev/null +++ b/tests/Analyser/FunctionLikeCollectionTest.php @@ -0,0 +1,389 @@ + 'src/Domain/'], self::BASE_PATH); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $ast = $parser->parse($code); + + $analysisNodeCollector->setCurrentFile($file); + + $nodeTraverser = new NodeTraverser(new NameResolver(), $analysisNodeCollector); + $nodeTraverser->traverse($ast ?? []); + + return $analysisNodeCollector; + } + + private function collectFunction(string $code): FunctionNode + { + $functionNodes = $this->makeCollector($code)->getFunctionNodes(); + $this->assertCount(1, $functionNodes, 'Expected exactly one function node'); + + return $functionNodes[0]; + } + + private function collectAnonymousFunction(string $code): AnonymousFunctionNode + { + $anonymousFunctionNodes = $this->makeCollector($code)->getAnonymousFunctionNodes(); + $this->assertCount(1, $anonymousFunctionNodes, 'Expected exactly one anonymous function node'); + + return $anonymousFunctionNodes[0]; + } + + public function testCollectsNamespacedFunctionWithLayerAndSignature(): void + { + $functionNode = $this->collectFunction( + 'assertSame('App\Domain\format', $functionNode->functionName); + $this->assertSame(self::FILE, $functionNode->file); + $this->assertSame(2, $functionNode->line); + $this->assertSame('Domain', $functionNode->layer); + $this->assertSame(['Domain'], $functionNode->layers); + $this->assertTrue($functionNode->hasReturnType); + $this->assertSame(2, $functionNode->paramCount); + $this->assertSame(1, $functionNode->cyclomaticComplexity); + $this->assertSame(1, $functionNode->lineCount); + } + + public function testCollectsGlobalFunctionOutsideAnyLayer(): void + { + $functionNode = $this->makeCollector( + 'getFunctionNodes()[0] ?? null; + + $this->assertInstanceOf(FunctionNode::class, $functionNode); + $this->assertSame('helper', $functionNode->functionName); + $this->assertNull($functionNode->layer); + $this->assertSame([], $functionNode->layers); + $this->assertFalse($functionNode->hasReturnType); + $this->assertSame(0, $functionNode->paramCount); + $this->assertSame(0, $functionNode->lineCount); + } + + public function testCollectsFunctionDependenciesIncludingNamespaceImports(): void + { + $functionNode = $this->collectFunction( + 'assertSame( + ['App\Infrastructure\Mailer', 'Psr\Log\LoggerInterface', 'DateTimeImmutable', 'App\Domain\Order'], + $functionNode->dependencies + ); + } + + public function testFunctionReferencesStillCountAsFileReferences(): void + { + $analysisNodeCollector = $this->makeCollector( + 'assertSame([self::FILE => ['App\Domain\Contract']], $analysisNodeCollector->getFileReferences()); + $this->assertSame(['App\Domain\Contract'], $analysisNodeCollector->getFunctionNodes()[0]->dependencies); + } + + public function testCollectsFunctionCallsSuperglobalsAndLanguageConstructs(): void + { + $functionNode = $this->makeCollector( + 'getFunctionNodes()[1]; + + $this->assertSame('App\Domain\handle', $functionNode->functionName); + $this->assertSame(['App\Domain\local', 'strlen'], $functionNode->functionCalls); + $this->assertSame(['$_GET'], $functionNode->superglobals); + $this->assertSame(['echo', 'exit'], $functionNode->languageConstructs); + $this->assertTrue($functionNode->callsFunction('App\Domain\local')); + $this->assertTrue($functionNode->accessesSuperglobals()); + $this->assertTrue($functionNode->usesLanguageConstruct('die')); + } + + public function testResolvesCallToFunctionDeclaredLaterInFile(): void + { + $functionNode = $this->makeCollector( + 'getFunctionNodes()[0]; + + // Function nodes are built after the whole file is traversed, so a + // call to a function declared further down still resolves. + $this->assertSame('App\Domain\caller', $functionNode->functionName); + $this->assertSame(['App\Domain\callee'], $functionNode->functionCalls); + } + + public function testCalculatesFunctionCyclomaticComplexityAndLineCount(): void + { + $functionNode = $this->collectFunction( + ' 1 && $n < 10) {' . "\n" + . ' return 1;' . "\n" + . ' }' . "\n" + . ' foreach ([1, 2] as $item) {' . "\n" + . ' $n += $item ?? 0;' . "\n" + . ' }' . "\n" + . ' return $n;' . "\n" + . '}' + ); + + // 1 + if + && + foreach + ?? + $this->assertSame(5, $functionNode->cyclomaticComplexity); + $this->assertSame(7, $functionNode->lineCount); + } + + public function testFunctionNodesAreCollectedAfterClassNodesInSourceOrder(): void + { + $analysisNodeCollector = $this->makeCollector( + 'assertSame(['App\Domain\Foo'], [$analysisNodeCollector->getNodes()[0]->className]); + $this->assertSame( + ['App\Domain\first', 'App\Domain\second'], + [ + $analysisNodeCollector->getFunctionNodes()[0]->functionName, + $analysisNodeCollector->getFunctionNodes()[1]->functionName, + ] + ); + } + + public function testCollectsTopLevelClosure(): void + { + $anonymousFunctionNode = $this->collectAnonymousFunction( + 'assertSame(self::FILE, $anonymousFunctionNode->file); + $this->assertSame(2, $anonymousFunctionNode->line); + $this->assertSame('Domain', $anonymousFunctionNode->layer); + $this->assertFalse($anonymousFunctionNode->isArrowFunction); + $this->assertFalse($anonymousFunctionNode->isStatic); + $this->assertNull($anonymousFunctionNode->enclosingClassName); + $this->assertNull($anonymousFunctionNode->enclosingFunctionName); + $this->assertTrue($anonymousFunctionNode->hasReturnType); + $this->assertSame(2, $anonymousFunctionNode->paramCount); + $this->assertSame(1, $anonymousFunctionNode->cyclomaticComplexity); + $this->assertSame(1, $anonymousFunctionNode->lineCount); + } + + public function testCollectsStaticArrowFunction(): void + { + $anonymousFunctionNode = $this->collectAnonymousFunction( + ' $a > 1 ? $a : 0;' + ); + + $this->assertTrue($anonymousFunctionNode->isArrowFunction); + $this->assertTrue($anonymousFunctionNode->isStatic); + $this->assertFalse($anonymousFunctionNode->hasReturnType); + $this->assertSame(1, $anonymousFunctionNode->paramCount); + $this->assertSame(2, $anonymousFunctionNode->cyclomaticComplexity); + $this->assertSame(1, $anonymousFunctionNode->lineCount); + $this->assertSame('Arrow function', $anonymousFunctionNode->getType()); + } + + public function testRecordsEnclosingClassAndCountsClosureBodyOnBothNodes(): void + { + $analysisNodeCollector = $this->makeCollector( + 'getAnonymousFunctionNodes()[0]; + $classNode = $analysisNodeCollector->getNodes()[0]; + + $this->assertSame('App\Domain\Handler', $anonymousFunctionNode->enclosingClassName); + $this->assertNull($anonymousFunctionNode->enclosingFunctionName); + $this->assertSame('App\Domain\Handler', $anonymousFunctionNode->enclosingScopeName()); + $this->assertSame(5, $anonymousFunctionNode->line); + + // The closure does not inherit the namespace imports; the class does. + $this->assertSame(['App\Infrastructure\Mailer'], $anonymousFunctionNode->dependencies); + $this->assertSame(['App\Infrastructure\Mailer'], $classNode->dependencies); + + $this->assertSame(['strlen'], $anonymousFunctionNode->functionCalls); + $this->assertSame(['strlen'], $classNode->functionCalls); + $this->assertSame(['exit'], $anonymousFunctionNode->languageConstructs); + $this->assertSame(['exit'], $classNode->languageConstructs); + } + + public function testRecordsEnclosingFunctionForClosureInsideNamedFunction(): void + { + $analysisNodeCollector = $this->makeCollector( + ' $_POST["x"] ?? null; }' + ); + + $anonymousFunctionNode = $analysisNodeCollector->getAnonymousFunctionNodes()[0]; + $functionNode = $analysisNodeCollector->getFunctionNodes()[0]; + + $this->assertNull($anonymousFunctionNode->enclosingClassName); + $this->assertSame('App\Domain\build', $anonymousFunctionNode->enclosingFunctionName); + $this->assertSame('App\Domain\build', $anonymousFunctionNode->enclosingScopeName()); + $this->assertSame(['$_POST'], $anonymousFunctionNode->superglobals); + $this->assertSame(2, $anonymousFunctionNode->cyclomaticComplexity); + + // The enclosing function sees the closure body too. + $this->assertSame(['$_POST'], $functionNode->superglobals); + $this->assertSame(2, $functionNode->cyclomaticComplexity); + } + + public function testNestedClosuresEachGetTheirOwnNodeAndComplexity(): void + { + $anonymousFunctionNodes = $this->makeCollector( + 'getAnonymousFunctionNodes(); + + $this->assertCount(2, $anonymousFunctionNodes); + // Source order: the outer closure is entered first. + $this->assertSame(2, $anonymousFunctionNodes[0]->line); + $this->assertSame(2, $anonymousFunctionNodes[0]->cyclomaticComplexity); + $this->assertSame(3, $anonymousFunctionNodes[1]->line); + $this->assertSame(2, $anonymousFunctionNodes[1]->cyclomaticComplexity); + } + + public function testClosureInsideAnonymousClassResolvesToTheNamedEnclosingClass(): void + { + $anonymousFunctionNode = $this->collectAnonymousFunction( + ' 1; } };' . "\n" + . ' }' . "\n" + . '}' + ); + + $this->assertSame('App\Domain\Factory', $anonymousFunctionNode->enclosingClassName); + } + + public function testClosureInsideTopLevelAnonymousClassHasNoEnclosingScope(): void + { + $anonymousFunctionNode = $this->collectAnonymousFunction( + ' 1; } };' + ); + + $this->assertNull($anonymousFunctionNode->enclosingClassName); + $this->assertNull($anonymousFunctionNode->enclosingFunctionName); + $this->assertSame('file scope', $anonymousFunctionNode->enclosingScopeName()); + } + + public function testTracksThisUsageThroughNestedClosuresButNotAcrossAnonymousClasses(): void + { + $anonymousFunctionNodes = $this->makeCollector( + ' 1;' . "\n" + . ' $outer = function () { return function () { return $this->x; }; };' . "\n" + . ' $anon = function () { return new class { public function run() { return $this; } }; };' . "\n" + . ' $static = static fn () => 2;' . "\n" + . ' }' . "\n" + . '}' + )->getAnonymousFunctionNodes(); + + $this->assertCount(5, $anonymousFunctionNodes); + $this->assertFalse($anonymousFunctionNodes[0]->usesThis, 'plain arrow function'); + $this->assertTrue($anonymousFunctionNodes[1]->usesThis, 'outer closure captures $this for the inner one'); + $this->assertTrue($anonymousFunctionNodes[2]->usesThis, 'inner closure reads $this'); + $this->assertFalse($anonymousFunctionNodes[3]->usesThis, '$this inside the anonymous class is its own'); + $this->assertFalse($anonymousFunctionNodes[4]->usesThis, 'static arrow function'); + $this->assertTrue($anonymousFunctionNodes[4]->isStatic); + } + + public function testTracksThisUsageInTopLevelClosure(): void + { + $anonymousFunctionNode = $this->collectAnonymousFunction( + 'value; };' + ); + + $this->assertTrue($anonymousFunctionNode->usesThis); + } + + public function testMethodComplexityStillAggregatesNestedClosureBranches(): void + { + $classNode = $this->makeCollector( + 'getNodes()[0]; + + $this->assertSame(3, $classNode->methods[0]->cyclomaticComplexity); + } + + public function testResetsFunctionLikeStateBetweenFiles(): void + { + $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $nodeTraverser = new NodeTraverser(new NameResolver(), $analysisNodeCollector); + + $analysisNodeCollector->setCurrentFile(self::BASE_PATH . '/src/Domain/a.php'); + $nodeTraverser->traverse($parser->parse(' 1; }') ?? []); + + $analysisNodeCollector->setCurrentFile(self::BASE_PATH . '/src/Domain/b.php'); + $nodeTraverser->traverse($parser->parse('getFunctionNodes(); + + $this->assertCount(2, $functionNodes); + $this->assertSame(self::BASE_PATH . '/src/Domain/a.php', $functionNodes[0]->file); + $this->assertSame(self::BASE_PATH . '/src/Domain/b.php', $functionNodes[1]->file); + $this->assertCount(1, $analysisNodeCollector->getAnonymousFunctionNodes()); + } +} diff --git a/tests/Analyser/FunctionNodeTest.php b/tests/Analyser/FunctionNodeTest.php new file mode 100644 index 00000000..ce61b0fa --- /dev/null +++ b/tests/Analyser/FunctionNodeTest.php @@ -0,0 +1,95 @@ +assertSame('format_money', $functionNode->shortName()); + $this->assertSame(['Support'], $functionNode->layers); + $this->assertTrue($functionNode->isInLayer('Support')); + $this->assertFalse($functionNode->isInLayer('Domain')); + $this->assertTrue($functionNode->nameStartsWith('format_')); + $this->assertTrue($functionNode->nameEndsWith('_money')); + $this->assertTrue($functionNode->nameMatches('/^format_/')); + $this->assertFalse($functionNode->nameMatches('/^App\\\\Support\\\\format_money$/')); + $this->assertTrue($functionNode->nameMatches('/^App\\\\Support\\\\format_money$/', isFullName: true)); + } + + public function testGlobalFunctionShortNameIsItsName(): void + { + $functionNode = new FunctionNode(functionName: 'helper', file: '/src/helpers.php', line: 1, layer: null); + + $this->assertSame('helper', $functionNode->shortName()); + $this->assertSame([], $functionNode->layers); + } + + public function testExplicitLayersOverrideSingleLayer(): void + { + $functionNode = new FunctionNode( + functionName: 'helper', + file: '/src/helpers.php', + line: 1, + layer: 'Support', + layers: ['Support', 'Source'], + ); + + $this->assertTrue($functionNode->isInLayer('Source')); + } + + public function testBodyQueries(): void + { + $functionNode = new FunctionNode( + functionName: 'App\\render', + file: '/src/helpers.php', + line: 1, + layer: 'Support', + dependencies: ['App\\View\\Template', 'Psr\\Log\\LoggerInterface'], + functionCalls: ['App\\escape', 'sprintf'], + superglobals: ['$_GET'], + languageConstructs: ['die'], + ); + + $this->assertTrue($functionNode->dependsOn('App\\View\\Template')); + $this->assertFalse($functionNode->dependsOn('App\\View\\Renderer')); + $this->assertTrue($functionNode->dependsOnNamespace('Psr\\Log')); + $this->assertTrue($functionNode->dependsOnNamespace('Psr\\Log\\')); + $this->assertFalse($functionNode->dependsOnNamespace('Psr\\Http')); + $this->assertTrue($functionNode->callsFunction('SPRINTF')); + $this->assertFalse($functionNode->callsFunction('printf')); + $this->assertTrue($functionNode->accessesSuperglobals()); + $this->assertTrue($functionNode->usesLanguageConstruct('die')); + $this->assertTrue($functionNode->usesLanguageConstruct('exit')); + $this->assertFalse($functionNode->usesLanguageConstruct('echo')); + } + + public function testExitAliasesDieAndNothingElse(): void + { + $functionNode = new FunctionNode( + functionName: 'stop', + file: '/src/helpers.php', + line: 1, + layer: null, + languageConstructs: ['exit'], + ); + + $this->assertTrue($functionNode->usesLanguageConstruct('die')); + $this->assertFalse($functionNode->usesLanguageConstruct('eval')); + $this->assertFalse($functionNode->accessesSuperglobals()); + } +} diff --git a/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php b/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php index 08ab5f3e..ee6f75c2 100644 --- a/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php +++ b/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php @@ -583,6 +583,67 @@ public function testExtractThrowsWhenFileInstantiationsPayloadIsInvalid(mixed $i } } + #[DataProvider('invalidFunctionNodesProvider')] + public function testExtractThrowsWhenFunctionNodesPayloadIsInvalid(mixed $invalidFunctionNodes): void + { + $GLOBALS['mock_file_get_contents_payload'] = [ + 'nodes' => [], + 'fileAnalyses' => [], + 'functionNodes' => $invalidFunctionNodes, + '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 function nodes.'); + + try { + $parallelAnalysisNodeExtractor->extract([$file]); + } finally { + $GLOBALS['mock_file_get_contents_payload'] = null; + $GLOBALS['mock_tracked_tempnam_files'] = []; + } + } + + /** @return Iterator */ + public static function invalidFunctionNodesProvider(): Iterator + { + yield 'not an array' => ['invalid']; + yield 'entry is not a node' => [['invalid']]; + } + + #[DataProvider('invalidFunctionNodesProvider')] + public function testExtractThrowsWhenAnonymousFunctionNodesPayloadIsInvalid(mixed $invalidNodes): void + { + $GLOBALS['mock_file_get_contents_payload'] = [ + 'nodes' => [], + 'fileAnalyses' => [], + 'anonymousFunctionNodes' => $invalidNodes, + '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 function nodes.'); + + try { + $parallelAnalysisNodeExtractor->extract([$file]); + } finally { + $GLOBALS['mock_file_get_contents_payload'] = null; + $GLOBALS['mock_tracked_tempnam_files'] = []; + } + } + #[DataProvider('invalidFileReferencesEntryProvider')] public function testExtractThrowsWhenFileReferencesEntryIsInvalid(mixed $invalidFileReferences): void { diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index 5ec96665..1238617d 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -6,10 +6,12 @@ use App\Foo; use Boundwize\StructArmed\Analyser\AnonymousClassNode; +use Boundwize\StructArmed\Analyser\AnonymousFunctionNode; use Boundwize\StructArmed\Analyser\ClassNode; use Boundwize\StructArmed\Analyser\ConstantNode; use Boundwize\StructArmed\Analyser\EnumCaseNode; use Boundwize\StructArmed\Analyser\FileAnalysis; +use Boundwize\StructArmed\Analyser\FunctionNode; use Boundwize\StructArmed\Analyser\MethodNode; use Boundwize\StructArmed\Analyser\PropertyNode; use Boundwize\StructArmed\Cache\AnalysisCacheMetadataFactory; @@ -399,7 +401,7 @@ public function testInvalidationIgnoresStoredPayloadMetadata(): void file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); $analysisResultCache->store('key', ['configHash' => 'other'], new RuleViolationCollection()); $this->assertFalse($analysisResultCache->shouldInvalidate()); @@ -485,19 +487,19 @@ public function testClassNodeCacheNamespaceDependsOnConfigAndComposerJson(): voi $analysisCacheMetadataFactory = new AnalysisCacheMetadataFactory(new FileHashProvider()); try { - $withoutComposer = $analysisCacheMetadataFactory->classNodeCacheNamespace($basePath, 'config-hash'); + $withoutComposer = $analysisCacheMetadataFactory->analysisNodeCacheNamespace($basePath, 'config-hash'); $this->assertSame( $withoutComposer, - $analysisCacheMetadataFactory->classNodeCacheNamespace($basePath, 'config-hash') + $analysisCacheMetadataFactory->analysisNodeCacheNamespace($basePath, 'config-hash') ); $this->assertNotSame( $withoutComposer, - $analysisCacheMetadataFactory->classNodeCacheNamespace($basePath, 'other-config-hash') + $analysisCacheMetadataFactory->analysisNodeCacheNamespace($basePath, 'other-config-hash') ); file_put_contents($basePath . '/composer.json', '{"autoload":{"psr-4":{"App\\\\":"lib/"}}}'); - $withComposer = $analysisCacheMetadataFactory->classNodeCacheNamespace($basePath, 'config-hash'); + $withComposer = $analysisCacheMetadataFactory->analysisNodeCacheNamespace($basePath, 'config-hash'); $this->assertNotSame($withoutComposer, $withComposer); @@ -507,7 +509,7 @@ public function testClassNodeCacheNamespaceDependsOnConfigAndComposerJson(): voi $this->assertNotSame( $withComposer, - $nextRunMetadataFactory->classNodeCacheNamespace($basePath, 'config-hash') + $nextRunMetadataFactory->analysisNodeCacheNamespace($basePath, 'config-hash') ); } finally { $this->removeTempDirectory($basePath); @@ -579,9 +581,9 @@ public function testStoresAndLoadsClassNodes(): void file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', $classNodes); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertStringNotContainsString( "\n", @@ -616,7 +618,7 @@ traits: ['App\Helper'], file_put_contents($sourceFile, 'storeClassNodes( + $analysisResultCache->storeAnalysisNodes( $sourceFile, 'config', $classNodes, @@ -626,7 +628,7 @@ traits: ['App\Helper'], ['App\InstantiatedInFunction'], ); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config'); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config'); $this->assertIsArray($loaded); $this->assertEquals($classNodes, $loaded['classNodes']); @@ -642,6 +644,214 @@ traits: ['App\Helper'], } } + public function testStoresAndLoadsFunctionLikeNodes(): void + { + $cacheDirectory = $this->createTempDirectory(); + $sourceFile = $cacheDirectory . '/helpers.php'; + $analysisResultCache = new AnalysisResultCache(__DIR__, new FileHashProvider(), $cacheDirectory); + $functionNodes = [ + new FunctionNode( + functionName: 'App\\format', + file: $sourceFile, + line: 3, + layer: 'Source', + hasReturnType: true, + paramCount: 2, + cyclomaticComplexity: 4, + lineCount: 9, + dependencies: ['App\\Money'], + functionCalls: ['sprintf'], + superglobals: ['$_GET'], + languageConstructs: ['echo'], + layers: ['Source', 'Support'], + ), + ]; + $anonymousFunctionNodes = [ + new AnonymousFunctionNode( + file: $sourceFile, + line: 5, + layer: null, + isArrowFunction: true, + isStatic: true, + enclosingClassName: 'App\\Handler', + enclosingFunctionName: 'App\\format', + usesThis: true, + hasReturnType: false, + paramCount: 1, + cyclomaticComplexity: 2, + lineCount: 1, + dependencies: ['App\\Money'], + functionCalls: ['App\\helper'], + superglobals: [], + languageConstructs: ['exit'], + ), + ]; + + file_put_contents($sourceFile, 'storeAnalysisNodes( + $sourceFile, + 'config', + [], + null, + [], + [], + [], + $functionNodes, + $anonymousFunctionNodes, + ); + + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config'); + + $this->assertIsArray($loaded); + $this->assertEquals($functionNodes, $loaded['functionNodes']); + $this->assertEquals($anonymousFunctionNodes, $loaded['anonymousFunctionNodes']); + + // Function-likes also survive the file-analysis load path. + $analysisResultCache->storeAnalysisNodes( + $sourceFile, + 'config', + [], + new FileAnalysis($sourceFile, false, true, null, true, true, false, 0), + [], + [], + [], + $functionNodes, + $anonymousFunctionNodes, + ); + + $loadedWithFileAnalysis = $analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config'); + + $this->assertIsArray($loadedWithFileAnalysis); + $this->assertEquals($functionNodes, $loadedWithFileAnalysis['functionNodes']); + $this->assertEquals($anonymousFunctionNodes, $loadedWithFileAnalysis['anonymousFunctionNodes']); + } finally { + if (file_exists($sourceFile)) { + unlink($sourceFile); + } + + $this->removeTempDirectory($cacheDirectory); + } + } + + public function testClassNodesLoadOldCachePayloadWithoutFunctionLikeNodes(): void + { + $cacheDirectory = $this->createTempDirectory(); + $sourceFile = $cacheDirectory . '/Foo.php'; + $fileHashProvider = new FileHashProvider(); + $analysisResultCache = new AnalysisResultCache(__DIR__, $fileHashProvider, $cacheDirectory); + + file_put_contents($sourceFile, 'storeAnalysisNodes($sourceFile, 'config', []); + + $cacheFile = $this->firstJsonFile($cacheDirectory); + $payload = json_decode((string) file_get_contents($cacheFile), true); + + $this->assertIsArray($payload); + unset($payload['functionNodes'], $payload['anonymousFunctionNodes']); + file_put_contents($cacheFile, json_encode($payload, JSON_THROW_ON_ERROR)); + + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config'); + + $this->assertIsArray($loaded); + $this->assertSame([], $loaded['functionNodes']); + $this->assertSame([], $loaded['anonymousFunctionNodes']); + } finally { + if (file_exists($sourceFile)) { + unlink($sourceFile); + } + + $this->removeTempDirectory($cacheDirectory); + } + } + + /** + * @param array $override + */ + #[DataProvider('corruptedFunctionLikePayloadProvider')] + public function testLoadClassNodesRejectsCorruptedFunctionLikePayload(array $override): void + { + $cacheDirectory = $this->createTempDirectory(); + $sourceFile = $cacheDirectory . '/Foo.php'; + $analysisResultCache = new AnalysisResultCache(__DIR__, new FileHashProvider(), $cacheDirectory); + + file_put_contents($sourceFile, 'storeAnalysisNodes($sourceFile, 'config', []); + + $cacheFile = $this->firstJsonFile($cacheDirectory); + $payload = json_decode((string) file_get_contents($cacheFile), true); + + $this->assertIsArray($payload); + file_put_contents($cacheFile, json_encode($override + $payload, JSON_THROW_ON_ERROR)); + + $this->assertNull($analysisResultCache->loadAnalysisNodes($sourceFile, 'config')); + } finally { + if (file_exists($sourceFile)) { + unlink($sourceFile); + } + + $this->removeTempDirectory($cacheDirectory); + } + } + + /** @return Iterator}> */ + public static function corruptedFunctionLikePayloadProvider(): Iterator + { + $validFunction = [ + 'functionName' => 'App\\format', + 'file' => '/src/helpers.php', + 'line' => 1, + 'layer' => null, + 'hasReturnType' => true, + 'paramCount' => 0, + 'cyclomaticComplexity' => 1, + 'lineCount' => 0, + 'dependencies' => [], + 'functionCalls' => [], + 'superglobals' => [], + 'languageConstructs' => [], + 'layers' => [], + ]; + $validClosure = [ + 'isArrowFunction' => false, + 'isStatic' => false, + 'enclosingClassName' => null, + 'enclosingFunctionName' => null, + 'usesThis' => false, + ] + $validFunction; + + yield 'function nodes not an array' => [['functionNodes' => 'invalid']]; + yield 'function node entry not an array' => [['functionNodes' => ['invalid']]]; + yield 'function node without name' => [['functionNodes' => [['functionName' => 1] + $validFunction]]]; + yield 'function node with invalid line' => [['functionNodes' => [['line' => '1'] + $validFunction]]]; + yield 'function node with invalid layer' => [['functionNodes' => [['layer' => 1] + $validFunction]]]; + yield 'function node with invalid dependencies' => [ + ['functionNodes' => [['dependencies' => [1]] + $validFunction]], + ]; + yield 'anonymous function nodes not an array' => [['anonymousFunctionNodes' => 'invalid']]; + yield 'anonymous function node entry not an array' => [['anonymousFunctionNodes' => ['invalid']]]; + yield 'anonymous function node with invalid arrow flag' => [ + ['anonymousFunctionNodes' => [['isArrowFunction' => 'yes'] + $validClosure]], + ]; + yield 'anonymous function node with invalid enclosing class' => [ + ['anonymousFunctionNodes' => [['enclosingClassName' => 1] + $validClosure]], + ]; + yield 'anonymous function node with invalid usesThis flag' => [ + ['anonymousFunctionNodes' => [['usesThis' => 'no'] + $validClosure]], + ]; + yield 'anonymous function node with invalid enclosing function' => [ + ['anonymousFunctionNodes' => [['enclosingFunctionName' => 1] + $validClosure]], + ]; + yield 'anonymous function node with invalid body' => [ + ['anonymousFunctionNodes' => [['file' => 1] + $validClosure]], + ]; + } + public function testClassNodesLoadOldCachePayloadWithoutAnonymousClassNodes(): void { $cacheDirectory = $this->createTempDirectory(); @@ -652,7 +862,7 @@ public function testClassNodesLoadOldCachePayloadWithoutAnonymousClassNodes(): v file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', $classNodes); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); // Simulate a payload written before anonymous class nodes existed. $cacheFile = $this->firstJsonFile($cacheDirectory); @@ -661,7 +871,7 @@ public function testClassNodesLoadOldCachePayloadWithoutAnonymousClassNodes(): v unset($payload['anonymousClassNodes']); file_put_contents($cacheFile, json_encode($payload, JSON_THROW_ON_ERROR)); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config'); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config'); $this->assertIsArray($loaded); $this->assertEquals($classNodes, $loaded['classNodes']); @@ -702,7 +912,7 @@ public function testLoadClassNodesRejectsCorruptedFileReferencesPayload(): void file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); $cacheFile = $this->firstJsonFile($cacheDirectory); $payload = json_decode((string) file_get_contents($cacheFile), true); @@ -710,7 +920,7 @@ public function testLoadClassNodesRejectsCorruptedFileReferencesPayload(): void $payload['fileReferences'] = ['App\Contract', 1]; file_put_contents($cacheFile, json_encode($payload, JSON_THROW_ON_ERROR)); - $this->assertNull($analysisResultCache->loadClassNodes($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodes($sourceFile, 'config')); } finally { if (file_exists($sourceFile)) { unlink($sourceFile); @@ -729,7 +939,7 @@ public function testLoadClassNodesRejectsCorruptedFileInstantiationsPayload(): v file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); $cacheFile = $this->firstJsonFile($cacheDirectory); $payload = json_decode((string) file_get_contents($cacheFile), true); @@ -737,7 +947,7 @@ public function testLoadClassNodesRejectsCorruptedFileInstantiationsPayload(): v $payload['fileInstantiations'] = ['App\Base', 1]; file_put_contents($cacheFile, json_encode($payload, JSON_THROW_ON_ERROR)); - $this->assertNull($analysisResultCache->loadClassNodes($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodes($sourceFile, 'config')); } finally { if (file_exists($sourceFile)) { unlink($sourceFile); @@ -757,7 +967,7 @@ public function testLoadClassNodesRejectsCorruptedAnonymousClassNodesPayload(mix file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); $cacheFile = $this->firstJsonFile($cacheDirectory); $payload = json_decode((string) file_get_contents($cacheFile), true); @@ -765,7 +975,7 @@ public function testLoadClassNodesRejectsCorruptedAnonymousClassNodesPayload(mix $payload['anonymousClassNodes'] = $corrupted; file_put_contents($cacheFile, json_encode($payload, JSON_THROW_ON_ERROR)); - $this->assertNull($analysisResultCache->loadClassNodes($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodes($sourceFile, 'config')); } finally { if (file_exists($sourceFile)) { unlink($sourceFile); @@ -797,8 +1007,8 @@ className: "App\\Invalid\xB1Name", file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertStringContainsString("\xEF\xBF\xBD", $loaded[0]->className); @@ -835,8 +1045,8 @@ className: 'App\FooTrait', ]; try { - $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertTrue($loaded[0]->isTrait); @@ -873,8 +1083,8 @@ className: 'App\Status', ]; try { - $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertTrue($loaded[0]->isEnum); @@ -912,8 +1122,8 @@ interfaceExtends: ['App\BaseMiddleware'], ]; try { - $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertSame(['App\BaseMiddleware'], $loaded[0]->interfaceExtends); @@ -969,7 +1179,7 @@ public function testClassNodesLoadOldCachePayloadWithoutInterfaceExtends(): void ], ], 'class-nodes-' . hash('xxh128', "config\0" . $sourceFile) . '.json'); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertSame([], $loaded[0]->interfaceExtends); @@ -1019,8 +1229,8 @@ className: Foo::class, ]; try { - $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertEquals($classNodes, $loaded); @@ -1062,8 +1272,8 @@ className: Foo::class, ]; try { - $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertEquals($classNodes, $loaded); @@ -1112,8 +1322,8 @@ enumBackingType: 'string', ]; try { - $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertEquals($classNodes, $loaded); @@ -1141,11 +1351,11 @@ public function testStoreClassNodesCreatesMissingCacheDirectory(): void try { $analysisResultCache->clear(); - $analysisResultCache->storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); $this->assertInstanceOf( ClassNode::class, - $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'][0] ?? null + $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'][0] ?? null ); } finally { $analysisResultCache->clear(); @@ -1163,8 +1373,8 @@ public function testClassNodesMissWhenCacheFileDoesNotExist(): void file_put_contents($sourceFile, 'assertNull($analysisResultCache->loadClassNodes($sourceFile, 'config')); - $this->assertNull($analysisResultCache->loadClassNodesWithFileAnalysis($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodes($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config')); } finally { unlink($sourceFile); $this->removeTempDirectory($cacheDirectory); @@ -1191,9 +1401,9 @@ public function testStoresAndLoadsClassNodesWithFileAnalysis(): void try { $classNodes = [$this->makeClassNode($sourceFile)]; - $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes, $fileAnalysis); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes, $fileAnalysis); - $loaded = $analysisResultCache->loadClassNodesWithFileAnalysis($sourceFile, 'config'); + $loaded = $analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config'); $this->assertNotNull($loaded); $this->assertEquals($classNodes, $loaded['classNodes']); @@ -1213,9 +1423,9 @@ public function testClassNodesWithFileAnalysisMissesLegacyEntryWithoutFileFacts( file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); - $this->assertNull($analysisResultCache->loadClassNodesWithFileAnalysis($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config')); } finally { unlink($sourceFile); $this->removeTempDirectory($cacheDirectory); @@ -1271,7 +1481,7 @@ public function testClassNodesWithFileAnalysisMissesMalformedFacts(array $fileAn file_put_contents($sourceFile, 'storeClassNodes( + $analysisResultCache->storeAnalysisNodes( $sourceFile, 'config', [$this->makeClassNode($sourceFile)], @@ -1284,7 +1494,7 @@ public function testClassNodesWithFileAnalysisMissesMalformedFacts(array $fileAn $payload['fileAnalysis'] = $fileAnalysis; $this->writeCachePayload($cacheDirectory, $payload, $cacheFile); - $this->assertNull($analysisResultCache->loadClassNodesWithFileAnalysis($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config')); } finally { unlink($sourceFile); $this->removeTempDirectory($cacheDirectory); @@ -1300,7 +1510,7 @@ public function testClassNodesWithFileAnalysisMissesMalformedNodesWhenFactsAreVa file_put_contents($sourceFile, 'storeClassNodes( + $analysisResultCache->storeAnalysisNodes( $sourceFile, 'config', [$this->makeClassNode($sourceFile)], @@ -1313,7 +1523,7 @@ public function testClassNodesWithFileAnalysisMissesMalformedNodesWhenFactsAreVa $payload['nodes'] = 'invalid'; $this->writeCachePayload($cacheDirectory, $payload, $cacheFile); - $this->assertNull($analysisResultCache->loadClassNodesWithFileAnalysis($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config')); } finally { unlink($sourceFile); $this->removeTempDirectory($cacheDirectory); @@ -1329,12 +1539,12 @@ public function testClassNodesMissWhenFileMetadataChanges(): void file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); file_put_contents($sourceFile, 'assertNull($nextRunCache->loadClassNodes($sourceFile, 'config')); + $this->assertNull($nextRunCache->loadAnalysisNodes($sourceFile, 'config')); } finally { unlink($sourceFile); $this->removeTempDirectory($cacheDirectory); @@ -1378,12 +1588,12 @@ public function testClassNodesHitWhenOnlyFileMtimeChanges(): void file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', $classNodes); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); touch($sourceFile, 1234567890); $this->assertEquals( $classNodes, - $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null + $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null ); } finally { unlink($sourceFile); @@ -2161,7 +2371,7 @@ public function testClassNodesMissWhenPayloadIsMalformed(array $payloadOverride) file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); $cacheFile = $this->firstJsonFile($cacheDirectory); $this->writeCachePayload($cacheDirectory, [ @@ -2173,7 +2383,7 @@ public function testClassNodesMissWhenPayloadIsMalformed(array $payloadOverride) ...$payloadOverride, ], $cacheFile); - $this->assertNull($analysisResultCache->loadClassNodes($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodes($sourceFile, 'config')); } finally { unlink($sourceFile); $this->removeTempDirectory($cacheDirectory); diff --git a/tests/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitorTest.php b/tests/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitorTest.php new file mode 100644 index 00000000..ee5544b1 --- /dev/null +++ b/tests/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitorTest.php @@ -0,0 +1,62 @@ + 12]); + + (new NodeTraverser(new AddStaticAnonymousFunctionVisitor(12)))->traverse([$closure]); + + $this->assertTrue($closure->static); + } + + public function testAddsStaticToArrowFunctionOnMatchingLine(): void + { + $arrowFunction = new ArrowFunction(['expr' => new Int_(1)], ['startLine' => 12]); + + (new NodeTraverser(new AddStaticAnonymousFunctionVisitor(12)))->traverse([$arrowFunction]); + + $this->assertTrue($arrowFunction->static); + } + + public function testDoesNotChangeClosureOnDifferentLine(): void + { + $closure = new Closure([], ['startLine' => 13]); + + (new NodeTraverser(new AddStaticAnonymousFunctionVisitor(12)))->traverse([$closure]); + + $this->assertFalse($closure->static); + } + + public function testDoesNotChangeAlreadyStaticClosure(): void + { + $closure = new Closure(['static' => true], ['startLine' => 12]); + $addStaticAnonymousFunctionVisitor = new AddStaticAnonymousFunctionVisitor(12); + + $this->assertNotInstanceOf(Node::class, $addStaticAnonymousFunctionVisitor->enterNode($closure)); + $this->assertTrue($closure->static); + } + + public function testDoesNotChangeNonAnonymousFunctionNode(): void + { + $addStaticAnonymousFunctionVisitor = new AddStaticAnonymousFunctionVisitor(12); + + $this->assertNotInstanceOf(Node::class, $addStaticAnonymousFunctionVisitor->enterNode(new ClassMethod('save', [], ['startLine' => 12]))); + } +} diff --git a/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleFixTest.php b/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleFixTest.php new file mode 100644 index 00000000..df0b2197 --- /dev/null +++ b/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleFixTest.php @@ -0,0 +1,98 @@ +makeTemporaryDirectory('structarmed-static-closure'); + mkdir($basePath . '/src'); + + $file = $basePath . '/src/Handler.php'; + + file_put_contents( + $file, + " \$this->value,\n" + . " static fn () => 2,\n" + . " fn (int \$x) => \$x * 2,\n" + . " ];\n" + . " }\n" + . "}\n" + ); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('source.static_closures', new MustBeStaticAnonymousFunctionRule(layer: 'Source')); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.static_closures'); + + $this->assertCount(2, $violations); + $this->assertSame([12, 15], [$violations[0]->line, $violations[1]->line]); + $this->assertTrue($violations[0]->fixable); + + $rule = $architecture->getRules()['source.static_closures']; + $this->assertInstanceOf(MustBeStaticAnonymousFunctionRule::class, $rule); + + foreach ($violations as $violation) { + $this->assertTrue($rule->fix($violation)); + } + + $this->assertSame( + " \$this->value,\n" + . " static fn () => 2,\n" + . " static fn (int \$x) => \$x * 2,\n" + . " ];\n" + . " }\n" + . "}\n", + file_get_contents($file) + ); + + // A second analysis of the fixed file is clean. + $this->assertCount( + 0, + (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.static_closures') + ); + } +} diff --git a/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleTest.php b/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleTest.php new file mode 100644 index 00000000..ee10e2af --- /dev/null +++ b/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleTest.php @@ -0,0 +1,113 @@ +assertTrue($mustBeStaticAnonymousFunctionRule->appliesToAnonymousFunction($this->makeNode())); + $this->assertFalse($mustBeStaticAnonymousFunctionRule->appliesToAnonymousFunction($this->makeNode(layer: 'Infrastructure'))); + $this->assertFalse($mustBeStaticAnonymousFunctionRule->appliesToAnonymousFunction($this->makeNode(layer: null))); + } + + public function testPassesWhenAlreadyStatic(): void + { + $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); + + $this->assertNotInstanceOf( + RuleViolation::class, + $mustBeStaticAnonymousFunctionRule->evaluateAnonymousFunction($this->makeNode(isStatic: true)) + ); + } + + public function testPassesWhenClosureUsesThis(): void + { + $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); + + $this->assertNotInstanceOf( + RuleViolation::class, + $mustBeStaticAnonymousFunctionRule->evaluateAnonymousFunction($this->makeNode(usesThis: true)) + ); + } + + public function testViolatesForNonStaticClosure(): void + { + $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); + $violation = $mustBeStaticAnonymousFunctionRule->evaluateAnonymousFunction($this->makeNode()); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame('Closure in [App\\Domain\\Handler] must be declared static', $violation->message); + $this->assertSame('/src/Domain/Handler.php', $violation->file); + $this->assertSame(12, $violation->line); + $this->assertSame('App\\Domain\\Handler', $violation->className); + $this->assertSame('Domain', $violation->layer); + } + + public function testViolatesForNonStaticArrowFunctionAtFileScope(): void + { + $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); + $violation = $mustBeStaticAnonymousFunctionRule->evaluateAnonymousFunction( + $this->makeNode(isArrowFunction: true, enclosingClassName: null) + ); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame('Arrow function in [file scope] must be declared static', $violation->message); + $this->assertSame('file scope', $violation->className); + } + + public function testIsFixable(): void + { + $this->assertInstanceOf(FixableInterface::class, new MustBeStaticAnonymousFunctionRule(layer: 'Domain')); + } + + public function testCreatesStaticAnonymousFunctionFixerVisitor(): void + { + $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); + $reflectionMethod = new ReflectionMethod($mustBeStaticAnonymousFunctionRule, 'createFixerVisitor'); + $visitor = $reflectionMethod->invoke( + $mustBeStaticAnonymousFunctionRule, + new RuleViolation( + message: 'Closure in [App\\Domain\\Handler] must be declared static', + file: '/src/Domain/Handler.php', + line: 12, + className: 'App\\Domain\\Handler', + layer: 'Domain', + ) + ); + + $this->assertInstanceOf(AddStaticAnonymousFunctionVisitor::class, $visitor); + } +} diff --git a/tests/Rule/RuleViolationTest.php b/tests/Rule/RuleViolationTest.php index e42002a3..cc564cfb 100644 --- a/tests/Rule/RuleViolationTest.php +++ b/tests/Rule/RuleViolationTest.php @@ -69,6 +69,28 @@ className: 'App\\Domain\\File', ], $ruleViolation->toArray()); } + public function testViolationSerializesFunctionNameWhenPresent(): void + { + $ruleViolation = new RuleViolation( + message: 'Broken rule', + file: '/src/helpers.php', + line: 7, + className: 'App\\Support\\format', + ruleKey: 'first.rule', + functionName: 'App\\Support\\format', + ); + + $this->assertSame([ + 'rule' => 'first.rule', + 'message' => 'Broken rule', + 'file' => '/src/helpers.php', + 'line' => 7, + 'class' => 'App\\Support\\format', + 'layer' => null, + 'function' => 'App\\Support\\format', + ], $ruleViolation->toArray()); + } + public function testNonFixableViolationDoesNotSerializeFixableFlag(): void { $this->assertArrayNotHasKey('fixable', $this->violation('first.rule', 'Domain')->toArray()); From a392041aabebadc3878a3eb1f38eeae5c59e88a2 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 07:36:40 +0700 Subject: [PATCH 007/104] cs fix --- src/Cli/AnalyseCommand.php | 5 ++++- tests/Analyser/AnalysisNodeCollectorTest.php | 5 ++++- .../AddStaticAnonymousFunctionVisitorTest.php | 7 ++++++- .../MustBeStaticAnonymousFunctionRuleTest.php | 17 +++++++++++++---- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index 15d4c625..26ed2c03 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -136,7 +136,10 @@ public function run(array $arguments, string $basePath): int $configHash, $composerGeneratedVersionHash ); - $analysisNodeCacheNamespace = $analysisCacheMetadataFactory->analysisNodeCacheNamespace($basePath, $configHash); + $analysisNodeCacheNamespace = $analysisCacheMetadataFactory->analysisNodeCacheNamespace( + $basePath, + $configHash + ); $analyser = new Analyser($basePath, $analysisResultCache, $analysisNodeCacheNamespace); if (isset($options['clear-cache']) || $analysisResultCache->shouldInvalidate()) { diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index 04a97079..460747b2 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -204,7 +204,10 @@ public function testDeferredInstantiationMarkerRoundTrips(): void foreach (['self', 'static', 'parent'] as $keyword) { $marker = AnalysisNodeCollector::deferredInstantiationMarker($keyword, 'App\\Factory'); - $this->assertSame([$keyword, 'App\\Factory'], AnalysisNodeCollector::parseDeferredInstantiationMarker($marker)); + $this->assertSame( + [$keyword, 'App\\Factory'], + AnalysisNodeCollector::parseDeferredInstantiationMarker($marker) + ); } $this->assertNull(AnalysisNodeCollector::parseDeferredInstantiationMarker('App\\Factory')); diff --git a/tests/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitorTest.php b/tests/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitorTest.php index ee5544b1..0905eaf7 100644 --- a/tests/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitorTest.php +++ b/tests/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitorTest.php @@ -57,6 +57,11 @@ public function testDoesNotChangeNonAnonymousFunctionNode(): void { $addStaticAnonymousFunctionVisitor = new AddStaticAnonymousFunctionVisitor(12); - $this->assertNotInstanceOf(Node::class, $addStaticAnonymousFunctionVisitor->enterNode(new ClassMethod('save', [], ['startLine' => 12]))); + $this->assertNotInstanceOf( + Node::class, + $addStaticAnonymousFunctionVisitor->enterNode( + new ClassMethod('save', [], ['startLine' => 12]) + ) + ); } } diff --git a/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleTest.php b/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleTest.php index ee10e2af..6dafba97 100644 --- a/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleTest.php +++ b/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleTest.php @@ -39,8 +39,12 @@ public function testAppliesOnlyToConfiguredLayer(): void $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); $this->assertTrue($mustBeStaticAnonymousFunctionRule->appliesToAnonymousFunction($this->makeNode())); - $this->assertFalse($mustBeStaticAnonymousFunctionRule->appliesToAnonymousFunction($this->makeNode(layer: 'Infrastructure'))); - $this->assertFalse($mustBeStaticAnonymousFunctionRule->appliesToAnonymousFunction($this->makeNode(layer: null))); + $this->assertFalse( + $mustBeStaticAnonymousFunctionRule->appliesToAnonymousFunction($this->makeNode(layer: 'Infrastructure')) + ); + $this->assertFalse( + $mustBeStaticAnonymousFunctionRule->appliesToAnonymousFunction($this->makeNode(layer: null)) + ); } public function testPassesWhenAlreadyStatic(): void @@ -66,7 +70,9 @@ public function testPassesWhenClosureUsesThis(): void public function testViolatesForNonStaticClosure(): void { $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); - $violation = $mustBeStaticAnonymousFunctionRule->evaluateAnonymousFunction($this->makeNode()); + $violation = $mustBeStaticAnonymousFunctionRule->evaluateAnonymousFunction( + $this->makeNode() + ); $this->assertInstanceOf(RuleViolation::class, $violation); $this->assertSame('Closure in [App\\Domain\\Handler] must be declared static', $violation->message); @@ -96,7 +102,10 @@ public function testIsFixable(): void public function testCreatesStaticAnonymousFunctionFixerVisitor(): void { $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); - $reflectionMethod = new ReflectionMethod($mustBeStaticAnonymousFunctionRule, 'createFixerVisitor'); + $reflectionMethod = new ReflectionMethod( + $mustBeStaticAnonymousFunctionRule, + 'createFixerVisitor' + ); $visitor = $reflectionMethod->invoke( $mustBeStaticAnonymousFunctionRule, new RuleViolation( From ada45f17a6a792c7e2b932d888aaf190a7eab128 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 07:47:11 +0700 Subject: [PATCH 008/104] rename named cache entries --- src/Cache/AnalysisResultCache.php | 23 ++++++++++---- tests/Cache/AnalysisResultCacheTest.php | 41 ++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 2963fb55..af42ac59 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -47,12 +47,20 @@ final class AnalysisResultCache { /** - * Marker file recording the config and structarmed version hashes the cache - * contents were built with. Never collides with payload files: those are - * named by hex hash keys or a "class-nodes-" prefix. + * Marker file recording the cache format version and the config and + * structarmed version hashes the cache contents were built with. Never + * collides with payload files: those are named by hex hash keys or an + * "analysis-nodes-" prefix. */ private const METADATA_FILE = '_metadata.json'; + /** + * Format version of the analysis-node payload files. Bump it whenever + * their shape or naming changes: it is recorded in the metadata marker, + * so a cache written by an older format is cleared on its next use. + */ + public const FORMAT_VERSION = 1; + private readonly string $cacheDirectory; private bool $isCacheInitialised = false; @@ -147,7 +155,8 @@ public function getCacheDirectory(): string /** * Compares against the single metadata marker instead of scanning every * payload, so the check stays O(1) regardless of cache size. A populated - * cache without a marker predates this format and must be invalidated. + * cache without a marker, or with a marker from an older cache format + * version, must be invalidated. */ public function shouldInvalidate(): bool { @@ -157,7 +166,8 @@ public function shouldInvalidate(): bool $payload = $this->readPath($this->cacheDirectory . '/' . self::METADATA_FILE); - return ($payload['configHash'] ?? null) !== $this->configHash + return ($payload['version'] ?? null) !== self::FORMAT_VERSION + || ($payload['configHash'] ?? null) !== $this->configHash || ($payload['composerGeneratedVersionHash'] ?? null) !== $this->composerGeneratedVersionHash; } @@ -175,6 +185,7 @@ private function ensureCacheInitialised(): void if (! file_exists($metadataFile)) { file_put_contents($metadataFile, json_encode([ + 'version' => self::FORMAT_VERSION, 'configHash' => $this->configHash, 'composerGeneratedVersionHash' => $this->composerGeneratedVersionHash, ], JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR)); @@ -1177,7 +1188,7 @@ private function path(string $key): string private function analysisNodesKey(string $file, string $namespace): string { - return 'class-nodes-' . hash('xxh128', $namespace . "\0" . $file); + return 'analysis-nodes-' . hash('xxh128', $namespace . "\0" . $file); } /** diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index 1238617d..99e8bbe7 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -411,6 +411,45 @@ public function testInvalidationIgnoresStoredPayloadMetadata(): void } } + public function testCacheFromOlderFormatVersionIsInvalidated(): void + { + $cacheDirectory = $this->createTempDirectory(); + $analysisResultCache = new AnalysisResultCache( + __DIR__, + new FileHashProvider(), + $cacheDirectory, + 'same', + 'composer-hash', + ); + + try { + // A marker written by a release before the format version was + // recorded, or by an older format version, with otherwise + // matching hashes. + file_put_contents($cacheDirectory . '/_metadata.json', json_encode([ + 'configHash' => 'same', + 'composerGeneratedVersionHash' => 'composer-hash', + ], JSON_THROW_ON_ERROR)); + + $this->assertTrue($analysisResultCache->shouldInvalidate()); + + file_put_contents($cacheDirectory . '/_metadata.json', json_encode([ + 'version' => AnalysisResultCache::FORMAT_VERSION - 1, + 'configHash' => 'same', + 'composerGeneratedVersionHash' => 'composer-hash', + ], JSON_THROW_ON_ERROR)); + + $this->assertTrue($analysisResultCache->shouldInvalidate()); + + $analysisResultCache->clear(); + $analysisResultCache->store('key', [], new RuleViolationCollection()); + + $this->assertFalse($analysisResultCache->shouldInvalidate()); + } finally { + $this->removeTempDirectory($cacheDirectory); + } + } + public function testPopulatedCacheWithoutMetadataMarkerIsInvalidated(): void { $cacheDirectory = $this->createTempDirectory(); @@ -1177,7 +1216,7 @@ public function testClassNodesLoadOldCachePayloadWithoutInterfaceExtends(): void 'layers' => [], ], ], - ], 'class-nodes-' . hash('xxh128', "config\0" . $sourceFile) . '.json'); + ], 'analysis-nodes-' . hash('xxh128', "config\0" . $sourceFile) . '.json'); $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; From 8feb67e9db1a1a5b98c313b27908f3b9307712c3 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 07:50:56 +0700 Subject: [PATCH 009/104] fix one line static closure --- .../AddStaticAnonymousFunctionVisitor.php | 50 ++++++++++++++++-- .../AddStaticAnonymousFunctionVisitorTest.php | 44 ++++++++++++++++ ...stBeStaticAnonymousFunctionRuleFixTest.php | 51 +++++++++++++++++++ 3 files changed, 142 insertions(+), 3 deletions(-) diff --git a/src/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitor.php b/src/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitor.php index 86a2efac..d2771713 100644 --- a/src/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitor.php +++ b/src/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitor.php @@ -7,12 +7,21 @@ use PhpParser\Node; use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\Closure; +use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Stmt\Class_; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor; use PhpParser\NodeVisitorAbstract; /** - * Adds the `static` modifier to the closure or arrow function that starts on - * the given line. An anonymous function has no name, so its start line is - * the only stable identity a violation can carry. + * Adds the `static` modifier to the closures and arrow functions starting on + * the given line that do not read `$this`. + * + * An anonymous function has no name, so its start line is the only identity + * a violation can carry, and several may start on one line. Re-applying the + * rule's own condition here — instead of trusting the line alone — means + * every function this visitor changes is one the rule flags, so a `$this` + * -reading closure sharing the line with a flagged one is left untouched. */ final class AddStaticAnonymousFunctionVisitor extends NodeVisitorAbstract { @@ -31,8 +40,43 @@ public function enterNode(Node $node): ?Node return null; } + if ($this->usesThis($node)) { + return null; + } + $node->static = true; return $node; } + + /** + * Whether the body reads `$this`, including through nested closures. + * `$this` inside a nested anonymous class body is that class's own, so + * anonymous classes are not descended into. + */ + private function usesThis(Closure|ArrowFunction $anonymousFunction): bool + { + $thisFinder = new class extends NodeVisitorAbstract { + public bool $found = false; + + public function enterNode(Node $node): ?int + { + if ($node instanceof Class_) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + if ($node instanceof Variable && $node->name === 'this') { + $this->found = true; + + return NodeVisitor::STOP_TRAVERSAL; + } + + return null; + } + }; + + (new NodeTraverser($thisFinder))->traverse([$anonymousFunction]); + + return $thisFinder->found; + } } diff --git a/tests/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitorTest.php b/tests/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitorTest.php index 0905eaf7..5ee0bf8a 100644 --- a/tests/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitorTest.php +++ b/tests/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitorTest.php @@ -8,8 +8,14 @@ use PhpParser\Node; use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\Closure; +use PhpParser\Node\Expr\New_; +use PhpParser\Node\Expr\PropertyFetch; +use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Identifier; use PhpParser\Node\Scalar\Int_; +use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Return_; use PhpParser\NodeTraverser; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -53,6 +59,44 @@ public function testDoesNotChangeAlreadyStaticClosure(): void $this->assertTrue($closure->static); } + public function testDoesNotChangeAnonymousFunctionReadingThisOnTheSameLine(): void + { + // `[fn () => 1, fn () => $this->value]` on one line: only the first is a violation. + $plain = new ArrowFunction(['expr' => new Int_(1)], ['startLine' => 12]); + $usingThis = new ArrowFunction( + ['expr' => new PropertyFetch(new Variable('this'), new Identifier('value'))], + ['startLine' => 12] + ); + + (new NodeTraverser(new AddStaticAnonymousFunctionVisitor(12)))->traverse([$plain, $usingThis]); + + $this->assertTrue($plain->static); + $this->assertFalse($usingThis->static); + } + + public function testDoesNotChangeClosureWhoseNestedClosureReadsThis(): void + { + $inner = new Closure(['stmts' => [new Return_(new Variable('this'))]], ['startLine' => 12]); + $outer = new Closure(['stmts' => [new Return_($inner)]], ['startLine' => 12]); + + (new NodeTraverser(new AddStaticAnonymousFunctionVisitor(12)))->traverse([$outer]); + + $this->assertFalse($outer->static); + $this->assertFalse($inner->static); + } + + public function testChangesClosureWhoseNestedAnonymousClassReadsThis(): void + { + $anonymousClass = new Class_(null, [ + 'stmts' => [new ClassMethod('run', ['stmts' => [new Return_(new Variable('this'))]])], + ]); + $closure = new Closure(['stmts' => [new Return_(new New_($anonymousClass))]], ['startLine' => 12]); + + (new NodeTraverser(new AddStaticAnonymousFunctionVisitor(12)))->traverse([$closure]); + + $this->assertTrue($closure->static); + } + public function testDoesNotChangeNonAnonymousFunctionNode(): void { $addStaticAnonymousFunctionVisitor = new AddStaticAnonymousFunctionVisitor(12); diff --git a/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleFixTest.php b/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleFixTest.php index df0b2197..29770016 100644 --- a/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleFixTest.php +++ b/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleFixTest.php @@ -23,6 +23,57 @@ final class MustBeStaticAnonymousFunctionRuleFixTest extends TestCase { use TemporaryDirectoryCleanupTrait; + public function testFixDoesNotChangeOtherAnonymousFunctionOnSameLine(): void + { + $basePath = $this->makeTemporaryDirectory('structarmed-static-closure-line'); + mkdir($basePath . '/src'); + + $file = $basePath . '/src/Handler.php'; + + file_put_contents( + $file, + " 1, fn () => \$this->value, function () { return 2; }];\n" + . " }\n" + . "}\n" + ); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('source.static_closures', new MustBeStaticAnonymousFunctionRule(layer: 'Source')); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.static_closures'); + + $this->assertCount(2, $violations); + $this->assertSame(9, $violations[0]->line); + $this->assertSame(9, $violations[1]->line); + + $rule = $architecture->getRules()['source.static_closures']; + $this->assertInstanceOf(MustBeStaticAnonymousFunctionRule::class, $rule); + + $this->assertTrue($rule->fix($violations[0])); + + $this->assertSame( + " 1, fn () => \$this->value, static function () { return 2; }];\n" + . " }\n" + . "}\n", + file_get_contents($file) + ); + } + public function testAnalyseThenFixAddsStaticOnlyToFlaggedAnonymousFunctions(): void { $basePath = $this->makeTemporaryDirectory('structarmed-static-closure'); From 49f54f25ff9e295ed4b02701e4411314a5cf7604 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 07:55:47 +0700 Subject: [PATCH 010/104] count of --fix result --- src/Cli/AnalyseCommand.php | 13 +++++- tests/Cli/StructArmedApplicationTest.php | 59 ++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index 26ed2c03..9ac8da0c 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -27,6 +27,7 @@ use function in_array; use function is_dir; use function is_file; +use function max; use function microtime; use function sprintf; use function str_starts_with; @@ -195,7 +196,7 @@ public function run(array $arguments, string $basePath): int break; } - $fixedCount += $passFixedCount; + $violationCountBeforePass = $ruleViolationCollection->count(); $analysisResultCache->clear(); $files = $analyser->filesForAnalysis($architecture, $scanPaths); @@ -226,7 +227,15 @@ public function run(array $arguments, string $basePath): int return $this->reportError($runtimeException); } - $elapsed = microtime(true) - $start; + // One fix can resolve several violations at once (e.g. two + // closures starting on the same line), and the later ones + // then report nothing to fix. Count what the re-analysis shows + // resolved, never less than the fixes that reported success. + $fixedCount += max( + $passFixedCount, + $violationCountBeforePass - $ruleViolationCollection->count() + ); + $elapsed = microtime(true) - $start; } } diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 77ba3a44..654ae5b7 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -576,6 +576,65 @@ public function testAnalyseCommandFixesFixableViolations(): void } } + public function testAnalyseCommandCountsEveryViolationResolvedByOneFix(): void + { + $basePath = $this->createProjectDirectory(); + + // Two flagged closures start on the same line: fixing the first one + // makes both static, so the second has nothing left to fix, yet both + // violations are gone and must be counted. + file_put_contents($basePath . '/src/Handler.php', <<<'PHP' + 1, fn () => $this->value, function () { return 2; }]; + } +} +PHP); + file_put_contents($basePath . '/structarmed.php', <<<'PHP' +layer('Source', 'src/') + ->rule('source.static_closures', new MustBeStaticAnonymousFunctionRule(layer: 'Source')); +PHP); + + try { + [$exitCode, $output] = $this->runApplication( + [ + 'structarmed', + 'analyze', + '--config=' . $basePath . '/structarmed.php', + '--fix', + '--no-progress', + ], + $basePath + ); + + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString('2 violations have been fixed.', $this->withoutAnsi($output)); + $this->assertStringContainsString('No violations found', $output); + $this->assertStringContainsString( + 'return [static fn () => 1, fn () => $this->value, static function () { return 2; }];', + (string) file_get_contents($basePath . '/src/Handler.php') + ); + } finally { + $this->removeTempDirectory($basePath); + } + } + public function testAnalyseCommandFixesCascadingYagniViolationsInOneRun(): void { $basePath = $this->createProjectDirectory(); From 9c89435a9be901fbff331d45232ddbe018468570 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 08:02:57 +0700 Subject: [PATCH 011/104] add more test --- tests/Analyser/AnalyserTest.php | 58 ++++++++++++++++++- tests/Analyser/FunctionLikeCollectionTest.php | 10 ++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index eaaa3ec2..1bbdf363 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -135,10 +135,66 @@ private function functionRuleProjectFiles(): array . '}' . "\n", 'src/Skipped/skip.php' => ' $_GET["x"];' . "\n", ]; } + public function testFunctionRulesSkipNodesTheyDoNotApplyTo(): void + { + $basePath = $this->makeTempProject($this->functionRuleProjectFiles() + [ + 'other/helpers.php' => ' $_POST["y"];' . "\n", + ]); + + // The rule applies to the Source layer only; nodes in Other are seen + // by the analyser but the rule declines them. + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->layer('Other', 'other/') + ->rule('functions.no_superglobals', $this->makeNoSuperglobalsInFunctionsRule()) + ->skip(['functions.no_superglobals' => ['src/Skipped/']]); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('functions.no_superglobals'); + + $this->assertCount(3, $violations); + + foreach ($violations as $violation) { + $this->assertStringNotContainsString('/other/', $this->normalisePath($violation->file)); + } + } + + public function testFunctionRulesHonourGlobalSkipPathsForPreResolvedFiles(): void + { + $basePath = $this->makeTempProject($this->functionRuleProjectFiles()); + + // A caller-supplied file list bypasses file discovery, so a globally + // skipped file can reach extraction; its nodes must still be skipped. + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('functions.no_superglobals', $this->makeNoSuperglobalsInFunctionsRule()) + ->skipPaths(['src/Skipped/']); + + $files = [ + $basePath . '/src/helpers.php', + $basePath . '/src/Handler.php', + $basePath . '/src/Skipped/skip.php', + ]; + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential(), $files) + ->forRule('functions.no_superglobals'); + + $this->assertCount(3, $violations); + + foreach ($violations as $violation) { + $this->assertStringNotContainsString('/Skipped/', $this->normalisePath($violation->file)); + } + } + public function testFunctionRulesAreEvaluatedAgainstFunctionsAndAnonymousFunctions(): void { $basePath = $this->makeTempProject($this->functionRuleProjectFiles()); diff --git a/tests/Analyser/FunctionLikeCollectionTest.php b/tests/Analyser/FunctionLikeCollectionTest.php index 7d9fc6f9..7e6e9d10 100644 --- a/tests/Analyser/FunctionLikeCollectionTest.php +++ b/tests/Analyser/FunctionLikeCollectionTest.php @@ -346,6 +346,16 @@ public function testTracksThisUsageThroughNestedClosuresButNotAcrossAnonymousCla $this->assertTrue($anonymousFunctionNodes[4]->isStatic); } + public function testIgnoresVariableVariablesWhenTrackingThisAndSuperglobals(): void + { + $anonymousFunctionNode = $this->collectAnonymousFunction( + 'assertFalse($anonymousFunctionNode->usesThis); + $this->assertFalse($anonymousFunctionNode->accessesSuperglobals()); + } + public function testTracksThisUsageInTopLevelClosure(): void { $anonymousFunctionNode = $this->collectAnonymousFunction( From 64b404e7a926d1035519572b6469e468269a256c Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 08:15:51 +0700 Subject: [PATCH 012/104] avoid redundant seed --- src/Analyser/AnalysisNodeCollector.php | 15 +- src/Cache/AnalysisResultCache.php | 163 ++++++++++++------ tests/Analyser/FunctionLikeCollectionTest.php | 8 +- tests/Cache/AnalysisResultCacheTest.php | 29 +++- 4 files changed, 143 insertions(+), 72 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index e55a9ba9..2b72be40 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -440,7 +440,7 @@ public function enterNode(Node $node): null $this->fileFunctions[$functionName] = true; $this->activeFunctionNames[] = $functionName; - $this->startFunctionLikeAnalysis($node, $this->currentNamespaceUses); + $this->startFunctionLikeAnalysis($node); return null; } @@ -628,13 +628,12 @@ private function startMethodAnalysis(ClassMethod $classMethod): void } /** - * A named function seeds its dependencies with the namespace imports, as - * a class-like does; a closure or arrow function only records what its - * own body references. - * - * @param list $dependencies + * Unlike a class-like, a function-like does not seed its dependencies + * with the namespace imports: a file may declare hundreds of functions, + * and its imports belong to the file, not to each of them. Only what the + * signature and body reference is recorded. */ - private function startFunctionLikeAnalysis(FunctionLike $functionLike, array $dependencies = []): void + private function startFunctionLikeAnalysis(FunctionLike $functionLike): void { $functionLikeAnalysis = new FunctionLikeAnalysis( $functionLike, @@ -642,8 +641,6 @@ private function startFunctionLikeAnalysis(FunctionLike $functionLike, array $de $this->activeFunctionNames === [] ? null : end($this->activeFunctionNames), ); - $functionLikeAnalysis->dependencies = $dependencies; - $this->activeFunctionLikeAnalyses[] = $functionLikeAnalysis; $this->fileFunctionLikeAnalyses[] = $functionLikeAnalysis; } diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index af42ac59..db51ca6d 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -212,7 +212,7 @@ public function loadAnalysisNodes(string $file, string $namespace): ?array return null; } - return $this->analysisNodeResultFromPayload($payload); + return $this->analysisNodeResultFromPayload($payload, $file); } /** @@ -242,7 +242,7 @@ public function loadAnalysisNodesWithFileAnalysis(string $file, string $namespac return null; } - $result = $this->analysisNodeResultFromPayload($payload); + $result = $this->analysisNodeResultFromPayload($payload, $file); if ($result === null) { return null; @@ -264,14 +264,14 @@ public function loadAnalysisNodesWithFileAnalysis(string $file, string $namespac * anonymousFunctionNodes: list * }|null */ - private function analysisNodeResultFromPayload(array $payload): ?array + private function analysisNodeResultFromPayload(array $payload, string $file): ?array { $classNodes = $this->classNodesFromPayload($payload); $anonymousClassNodes = $this->anonymousClassNodesFromPayload($payload); $fileReferences = $this->fileReferencesFromPayload($payload); $fileInstantiations = $this->fileInstantiationsFromPayload($payload); - $functionNodes = $this->functionNodesFromPayload($payload); - $anonymousFunctionNodes = $this->anonymousFunctionNodesFromPayload($payload); + $functionNodes = $this->functionNodesFromPayload($payload, $file); + $anonymousFunctionNodes = $this->anonymousFunctionNodesFromPayload($payload, $file); if ( $classNodes === null @@ -358,17 +358,24 @@ public function storeAnalysisNodes( $this->ensureCacheInitialised(); $payload = [ - 'metadata' => $this->fileMetadata($file, $namespace), - 'nodes' => array_map($this->classNodeToArray(...), $classNodes), - 'anonymousClassNodes' => array_map($this->anonymousClassNodeToArray(...), $anonymousClassNodes), - 'fileReferences' => $fileReferences, - 'fileInstantiations' => $fileInstantiations, - 'functionNodes' => array_map($this->functionNodeToArray(...), $functionNodes), - 'anonymousFunctionNodes' => array_map( + 'metadata' => $this->fileMetadata($file, $namespace), + 'nodes' => array_map($this->classNodeToArray(...), $classNodes), + 'anonymousClassNodes' => array_map($this->anonymousClassNodeToArray(...), $anonymousClassNodes), + 'fileReferences' => $fileReferences, + 'fileInstantiations' => $fileInstantiations, + ]; + + // Most files declare no function-likes; leave their keys out entirely. + if ($functionNodes !== []) { + $payload['functionNodes'] = array_map($this->functionNodeToArray(...), $functionNodes); + } + + if ($anonymousFunctionNodes !== []) { + $payload['anonymousFunctionNodes'] = array_map( $this->anonymousFunctionNodeToArray(...), $anonymousFunctionNodes - ), - ]; + ); + } if ($fileAnalysis instanceof FileAnalysis) { $payload['fileAnalysis'] = $this->fileAnalysisToArray($fileAnalysis); @@ -536,28 +543,78 @@ traits: $traits, */ private function functionNodeToArray(FunctionNode $functionNode): array { - return [ - 'functionName' => $functionNode->functionName, - 'file' => $functionNode->file, - 'line' => $functionNode->line, - 'layer' => $functionNode->layer, - 'hasReturnType' => $functionNode->hasReturnType, - 'paramCount' => $functionNode->paramCount, - 'cyclomaticComplexity' => $functionNode->cyclomaticComplexity, - 'lineCount' => $functionNode->lineCount, - 'dependencies' => $functionNode->dependencies, - 'functionCalls' => array_values($functionNode->functionCalls), - 'superglobals' => array_values($functionNode->superglobals), - 'languageConstructs' => array_values($functionNode->languageConstructs), - 'layers' => $functionNode->layers, + return ['functionName' => $functionNode->functionName] + $this->functionLikeBodyToArray( + $functionNode->line, + $functionNode->layer, + $functionNode->hasReturnType, + $functionNode->paramCount, + $functionNode->cyclomaticComplexity, + $functionNode->lineCount, + $functionNode->dependencies, + $functionNode->functionCalls, + $functionNode->superglobals, + $functionNode->languageConstructs, + $functionNode->layers, + ); + } + + /** + * The fields FunctionNode and AnonymousFunctionNode share. The file is + * not stored: the payload belongs to one file, known when loading. Empty + * lists — the common case for a closure — are left out and default on + * load, which keeps the many small function-like entries small. + * + * @param list $dependencies + * @param string[] $functionCalls + * @param string[] $superglobals + * @param string[] $languageConstructs + * @param list $layers + * @return array + */ + private function functionLikeBodyToArray( + int $line, + ?string $layer, + bool $hasReturnType, + int $paramCount, + int $cyclomaticComplexity, + int $lineCount, + array $dependencies, + array $functionCalls, + array $superglobals, + array $languageConstructs, + array $layers, + ): array { + $body = [ + 'line' => $line, + 'layer' => $layer, + 'hasReturnType' => $hasReturnType, + 'paramCount' => $paramCount, + 'cyclomaticComplexity' => $cyclomaticComplexity, + 'lineCount' => $lineCount, + ]; + + $lists = [ + 'dependencies' => $dependencies, + 'functionCalls' => $functionCalls, + 'superglobals' => $superglobals, + 'languageConstructs' => $languageConstructs, + 'layers' => $layers, ]; + + foreach ($lists as $key => $list) { + if ($list !== []) { + $body[$key] = array_values($list); + } + } + + return $body; } /** * @param array $payload * @return list|null */ - private function functionNodesFromPayload(array $payload): ?array + private function functionNodesFromPayload(array $payload, string $file): ?array { $rawNodes = $payload['functionNodes'] ?? []; @@ -573,7 +630,7 @@ private function functionNodesFromPayload(array $payload): ?array } $functionName = $rawNode['functionName'] ?? null; - $body = $this->functionLikeBodyFromArray($rawNode); + $body = $this->functionLikeBodyFromArray($rawNode, $file); if (! is_string($functionName) || $body === null) { return null; @@ -605,31 +662,31 @@ functionCalls: $body['functionCalls'], private function anonymousFunctionNodeToArray(AnonymousFunctionNode $anonymousFunctionNode): array { return [ - 'file' => $anonymousFunctionNode->file, - 'line' => $anonymousFunctionNode->line, - 'layer' => $anonymousFunctionNode->layer, 'isArrowFunction' => $anonymousFunctionNode->isArrowFunction, 'isStatic' => $anonymousFunctionNode->isStatic, 'enclosingClassName' => $anonymousFunctionNode->enclosingClassName, 'enclosingFunctionName' => $anonymousFunctionNode->enclosingFunctionName, 'usesThis' => $anonymousFunctionNode->usesThis, - 'hasReturnType' => $anonymousFunctionNode->hasReturnType, - 'paramCount' => $anonymousFunctionNode->paramCount, - 'cyclomaticComplexity' => $anonymousFunctionNode->cyclomaticComplexity, - 'lineCount' => $anonymousFunctionNode->lineCount, - 'dependencies' => $anonymousFunctionNode->dependencies, - 'functionCalls' => array_values($anonymousFunctionNode->functionCalls), - 'superglobals' => array_values($anonymousFunctionNode->superglobals), - 'languageConstructs' => array_values($anonymousFunctionNode->languageConstructs), - 'layers' => $anonymousFunctionNode->layers, - ]; + ] + $this->functionLikeBodyToArray( + $anonymousFunctionNode->line, + $anonymousFunctionNode->layer, + $anonymousFunctionNode->hasReturnType, + $anonymousFunctionNode->paramCount, + $anonymousFunctionNode->cyclomaticComplexity, + $anonymousFunctionNode->lineCount, + $anonymousFunctionNode->dependencies, + $anonymousFunctionNode->functionCalls, + $anonymousFunctionNode->superglobals, + $anonymousFunctionNode->languageConstructs, + $anonymousFunctionNode->layers, + ); } /** * @param array $payload * @return list|null */ - private function anonymousFunctionNodesFromPayload(array $payload): ?array + private function anonymousFunctionNodesFromPayload(array $payload, string $file): ?array { $rawNodes = $payload['anonymousFunctionNodes'] ?? []; @@ -649,7 +706,7 @@ private function anonymousFunctionNodesFromPayload(array $payload): ?array $enclosingClassName = $rawNode['enclosingClassName'] ?? null; $enclosingFunctionName = $rawNode['enclosingFunctionName'] ?? null; $usesThis = $rawNode['usesThis'] ?? null; - $body = $this->functionLikeBodyFromArray($rawNode); + $body = $this->functionLikeBodyFromArray($rawNode, $file); if ( ! is_bool($isArrowFunction) @@ -705,24 +762,22 @@ functionCalls: $body['functionCalls'], * layers: list * }|null */ - private function functionLikeBodyFromArray(array $node): ?array + private function functionLikeBodyFromArray(array $node, string $file): ?array { - $file = $node['file'] ?? null; $line = $node['line'] ?? null; $layer = $node['layer'] ?? null; $hasReturnType = $node['hasReturnType'] ?? null; $paramCount = $node['paramCount'] ?? null; $cyclomaticComplexity = $node['cyclomaticComplexity'] ?? null; $lineCount = $node['lineCount'] ?? null; - $dependencies = $node['dependencies'] ?? null; - $functionCalls = $node['functionCalls'] ?? null; - $superglobals = $node['superglobals'] ?? null; - $languageConstructs = $node['languageConstructs'] ?? null; - $layers = $node['layers'] ?? null; + $dependencies = $node['dependencies'] ?? []; + $functionCalls = $node['functionCalls'] ?? []; + $superglobals = $node['superglobals'] ?? []; + $languageConstructs = $node['languageConstructs'] ?? []; + $layers = $node['layers'] ?? []; if ( - ! is_string($file) - || ! is_int($line) + ! is_int($line) || ($layer !== null && ! is_string($layer)) || ! is_bool($hasReturnType) || ! is_int($paramCount) diff --git a/tests/Analyser/FunctionLikeCollectionTest.php b/tests/Analyser/FunctionLikeCollectionTest.php index 7e6e9d10..e2922850 100644 --- a/tests/Analyser/FunctionLikeCollectionTest.php +++ b/tests/Analyser/FunctionLikeCollectionTest.php @@ -94,7 +94,7 @@ public function testCollectsGlobalFunctionOutsideAnyLayer(): void $this->assertSame(0, $functionNode->lineCount); } - public function testCollectsFunctionDependenciesIncludingNamespaceImports(): void + public function testCollectsFunctionDependenciesWithoutSeedingNamespaceImports(): void { $functionNode = $this->collectFunction( 'assertSame( - ['App\Infrastructure\Mailer', 'Psr\Log\LoggerInterface', 'DateTimeImmutable', 'App\Domain\Order'], + ['App\Infrastructure\Mailer', 'DateTimeImmutable', 'App\Domain\Order'], $functionNode->dependencies ); } @@ -248,7 +250,7 @@ public function testRecordsEnclosingClassAndCountsClosureBodyOnBothNodes(): void $this->assertSame('App\Domain\Handler', $anonymousFunctionNode->enclosingScopeName()); $this->assertSame(5, $anonymousFunctionNode->line); - // The closure does not inherit the namespace imports; the class does. + // Neither function-like inherits the namespace imports; the class does. $this->assertSame(['App\Infrastructure\Mailer'], $anonymousFunctionNode->dependencies); $this->assertSame(['App\Infrastructure\Mailer'], $classNode->dependencies); diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index 99e8bbe7..7818f331 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -747,6 +747,24 @@ functionCalls: ['App\\helper'], $this->assertEquals($functionNodes, $loaded['functionNodes']); $this->assertEquals($anonymousFunctionNodes, $loaded['anonymousFunctionNodes']); + // Compact payload: no per-node file, and empty lists are omitted. + $payload = json_decode((string) file_get_contents($this->firstJsonFile($cacheDirectory)), true); + + $this->assertIsArray($payload); + $this->assertIsArray($payload['functionNodes']); + $this->assertIsArray($payload['anonymousFunctionNodes']); + + $storedFunction = $payload['functionNodes'][0]; + $storedClosure = $payload['anonymousFunctionNodes'][0]; + + $this->assertIsArray($storedFunction); + $this->assertIsArray($storedClosure); + $this->assertArrayNotHasKey('file', $storedFunction); + $this->assertArrayNotHasKey('file', $storedClosure); + $this->assertArrayNotHasKey('superglobals', $storedClosure); + $this->assertArrayNotHasKey('layers', $storedClosure); + $this->assertSame(['App\\helper'], $storedClosure['functionCalls']); + // Function-likes also survive the file-analysis load path. $analysisResultCache->storeAnalysisNodes( $sourceFile, @@ -774,7 +792,7 @@ functionCalls: ['App\\helper'], } } - public function testClassNodesLoadOldCachePayloadWithoutFunctionLikeNodes(): void + public function testFilesWithoutFunctionLikesOmitTheirKeysAndLoadAsEmpty(): void { $cacheDirectory = $this->createTempDirectory(); $sourceFile = $cacheDirectory . '/Foo.php'; @@ -786,12 +804,11 @@ public function testClassNodesLoadOldCachePayloadWithoutFunctionLikeNodes(): voi try { $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', []); - $cacheFile = $this->firstJsonFile($cacheDirectory); - $payload = json_decode((string) file_get_contents($cacheFile), true); + $payload = json_decode((string) file_get_contents($this->firstJsonFile($cacheDirectory)), true); $this->assertIsArray($payload); - unset($payload['functionNodes'], $payload['anonymousFunctionNodes']); - file_put_contents($cacheFile, json_encode($payload, JSON_THROW_ON_ERROR)); + $this->assertArrayNotHasKey('functionNodes', $payload); + $this->assertArrayNotHasKey('anonymousFunctionNodes', $payload); $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config'); @@ -887,7 +904,7 @@ public static function corruptedFunctionLikePayloadProvider(): Iterator ['anonymousFunctionNodes' => [['enclosingFunctionName' => 1] + $validClosure]], ]; yield 'anonymous function node with invalid body' => [ - ['anonymousFunctionNodes' => [['file' => 1] + $validClosure]], + ['anonymousFunctionNodes' => [['lineCount' => '0'] + $validClosure]], ]; } From 85bd046a2a1bc5094a9b73dca3ea9c14b6370930 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 12:39:12 +0700 Subject: [PATCH 013/104] perf: Optimize AnalysisNodeCollector traversal and layer resolution on AnalysisNodeCollector --- src/Analyser/AnalysisNodeCollector.php | 95 +++++++++++++++++--------- 1 file changed, 62 insertions(+), 33 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index 2b72be40..bf0dd7f1 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -69,6 +69,7 @@ use PhpParser\Node\Stmt\While_; use PhpParser\NodeVisitorAbstract; +use function array_keys; use function array_pop; use function array_unique; use function array_values; @@ -188,7 +189,7 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract /** @var array> */ private array $fileReferences = []; - /** @var list */ + /** @var array */ private array $currentFileReferences = []; /** @@ -206,7 +207,7 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract /** @var array> */ private array $fileInstantiations = []; - /** @var list */ + /** @var array */ private array $currentFileInstantiations = []; private readonly ConstExprEvaluator $constExprEvaluator; @@ -278,6 +279,9 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract */ private array $fileFunctionLikeAnalyses = []; + /** @var array}> */ + private array $resolvedLayerData = []; + public function __construct( private readonly LayerResolverInterface $layerResolver ) { @@ -316,6 +320,7 @@ public function setCurrentFile(string $file): void $this->activeFunctionLikeAnalyses = []; $this->fileFunctionLikeAnalyses = []; $this->functionLikeDepthAtClassLikeEntry = []; + $this->resolvedLayerData = []; } /** @return list */ @@ -446,13 +451,15 @@ public function enterNode(Node $node): null } if ($node instanceof ClassLike) { - $this->activeClassLikeScopes[] = $this->createClassLikeScope($node); - $this->activeClassLikeNames[] = $node->name instanceof Identifier + $classLikeName = $node->name instanceof Identifier ? $this->resolveClassName($node) : null; + + $this->activeClassLikeScopes[] = $this->createClassLikeScope($node, $classLikeName); + $this->activeClassLikeNames[] = $classLikeName; $this->functionLikeDepthAtClassLikeEntry[] = count($this->activeFunctionLikeAnalyses); - if ($node->name instanceof Identifier) { + if ($classLikeName !== null) { $this->startClassLikeAnalysis($node); } @@ -571,14 +578,12 @@ public function afterTraverse(array $nodes): null } if ($this->currentFileReferences !== []) { - $this->fileReferences[$this->currentFile] = array_values(array_unique($this->currentFileReferences)); + $this->fileReferences[$this->currentFile] = array_keys($this->currentFileReferences); $this->currentFileReferences = []; } if ($this->currentFileInstantiations !== []) { - $this->fileInstantiations[$this->currentFile] = array_values( - array_unique($this->currentFileInstantiations) - ); + $this->fileInstantiations[$this->currentFile] = array_keys($this->currentFileInstantiations); $this->currentFileInstantiations = []; } @@ -681,7 +686,7 @@ private function collectNodeAnalysis(Node $node): void preg_match(self::CLASS_LIKE_STRING_PATTERN, $value) === 1 && ! isset(self::KEYWORD_CONSTANTS[strtolower($value)]) ) { - $this->currentFileReferences[] = $value; + $this->currentFileReferences[$value] = true; } return; @@ -699,7 +704,7 @@ private function collectNodeAnalysis(Node $node): void // top-level statements, top-level anonymous class bodies — a // class-like reference still keeps the referenced class-like // alive. - $this->currentFileReferences[] = $name; + $this->currentFileReferences[$name] = true; } $this->addDependency($name); @@ -714,7 +719,9 @@ private function collectNodeAnalysis(Node $node): void // Branch nodes (conditions, loops, boolean operators) are among the // most frequent remaining node types, so they dispatch on one hash // lookup before the rarer per-type checks below. - if (isset(self::COMPLEXITY_BRANCH_NODES[$node::class])) { + $nodeClass = $node::class; + + if (isset(self::COMPLEXITY_BRANCH_NODES[$nodeClass])) { foreach ($this->activeMethodIds as $activeMethodId) { $this->methodClassLikeAnalyses[$activeMethodId]->complexityByMethodId[$activeMethodId]++; } @@ -777,7 +784,7 @@ private function collectNodeAnalysis(Node $node): void return; } - $languageConstruct = self::LANGUAGE_CONSTRUCT_NODES[$node::class] ?? null; + $languageConstruct = self::LANGUAGE_CONSTRUCT_NODES[$nodeClass] ?? null; if ($languageConstruct !== null) { $this->addLanguageConstruct($languageConstruct); @@ -792,7 +799,7 @@ private function collectInstantiation(New_ $new): void $className = $this->resolveClassLikeName($class); if ($className !== null) { - $this->currentFileInstantiations[] = $className; + $this->currentFileInstantiations[$className] = true; } return; @@ -811,7 +818,7 @@ private function collectInstantiation(New_ $new): void $className = $this->resolveClassNameExpr($class); if ($className !== null) { - $this->currentFileInstantiations[] = $className; + $this->currentFileInstantiations[$className] = true; } } @@ -847,29 +854,27 @@ private function resolveClassLikeName(Name $name): ?string * * @return array{self: string|null, static: string|null, parent: string|null} */ - private function createClassLikeScope(ClassLike $classLike): array + private function createClassLikeScope(ClassLike $classLike, ?string $classLikeName): array { $parent = $classLike instanceof Class_ && $classLike->extends instanceof Name ? $classLike->extends->toString() : null; - if (! $classLike->name instanceof Identifier) { + if ($classLikeName === null) { return ['self' => null, 'static' => null, 'parent' => $parent]; } - $name = $this->resolveClassName($classLike); - if ($classLike instanceof Trait_) { return [ - 'self' => self::deferredInstantiationMarker('self', $name), - 'static' => self::deferredInstantiationMarker('static', $name), - 'parent' => self::deferredInstantiationMarker('parent', $name), + 'self' => self::deferredInstantiationMarker('self', $classLikeName), + 'static' => self::deferredInstantiationMarker('static', $classLikeName), + 'parent' => self::deferredInstantiationMarker('parent', $classLikeName), ]; } return [ - 'self' => $name, - 'static' => self::deferredInstantiationMarker('static', $name), + 'self' => $classLikeName, + 'static' => self::deferredInstantiationMarker('static', $classLikeName), 'parent' => $parent, ]; } @@ -908,7 +913,7 @@ private function collectReflectionInstantiation(New_ $new): void $reflectionTarget = $this->resolveReflectionTarget($new); if ($reflectionTarget !== null) { - $this->currentFileInstantiations[] = $reflectionTarget; + $this->currentFileInstantiations[$reflectionTarget] = true; } } @@ -1037,8 +1042,7 @@ private function collectClassLike(ClassLike $classLike): void $classLikeId = spl_object_id($classLike); $analysis = $this->collectClassLikeAnalysis($classLikeId); $className = $this->resolveClassName($classLike); - $layers = $this->layerResolver->resolveAll($className, $this->currentFile); - $layer = $this->layerResolver->resolve($className, $this->currentFile); + [$layer, $layers] = $this->resolveLayerData($className); $implements = $this->collectImplements($classLike); $interfaceExtends = $this->collectInterfaceExtends($classLike); @@ -1097,13 +1101,14 @@ private function collectFunctionLike(FunctionLikeAnalysis $functionLikeAnalysis) $lineCount = $this->calculateLineCount($functionLike); if ($functionLike instanceof Function_) { - $functionName = $this->resolveFunctionDeclarationName($functionLike); + $functionName = $this->resolveFunctionDeclarationName($functionLike); + [$layer, $layers] = $this->resolveLayerData($functionName); $this->functionNodes[] = new FunctionNode( functionName: $functionName, file: $this->currentFile, line: $functionLike->getStartLine(), - layer: $this->layerResolver->resolve($functionName, $this->currentFile), + layer: $layer, hasReturnType: $hasReturnType, paramCount: $paramCount, cyclomaticComplexity: $functionLikeAnalysis->cyclomaticComplexity, @@ -1112,7 +1117,7 @@ functionName: $functionName, functionCalls: $functionCalls, superglobals: $superglobals, languageConstructs: $languageConstructs, - layers: $this->layerResolver->resolveAll($functionName, $this->currentFile), + layers: $layers, ); return; @@ -1120,14 +1125,15 @@ functionCalls: $functionCalls, // The layer of an anonymous function is resolved by its file and, for // class-name pattern layers, by the named scope declaring it. - $scopeName = $functionLikeAnalysis->enclosingClassName + $scopeName = $functionLikeAnalysis->enclosingClassName ?? $functionLikeAnalysis->enclosingFunctionName ?? ''; + [$layer, $layers] = $this->resolveLayerData($scopeName); $this->anonymousFunctionNodes[] = new AnonymousFunctionNode( file: $this->currentFile, line: $functionLike->getStartLine(), - layer: $this->layerResolver->resolve($scopeName, $this->currentFile), + layer: $layer, isArrowFunction: $functionLike instanceof ArrowFunction, isStatic: ($functionLike instanceof Closure || $functionLike instanceof ArrowFunction) && $functionLike->static, @@ -1142,10 +1148,33 @@ functionCalls: $functionCalls, functionCalls: $functionCalls, superglobals: $superglobals, languageConstructs: $languageConstructs, - layers: $this->layerResolver->resolveAll($scopeName, $this->currentFile), + layers: $layers, ); } + /** + * Resolve and cache both layer representations for a scope. With zero or + * one match, resolveAll() already determines the primary layer; the + * separate resolve() pass is only needed for overlapping layer matches. + * + * @return array{0: string|null, 1: list} + */ + private function resolveLayerData(string $scopeName): array + { + if (isset($this->resolvedLayerData[$scopeName])) { + return $this->resolvedLayerData[$scopeName]; + } + + $layers = $this->layerResolver->resolveAll($scopeName, $this->currentFile); + $layer = match (count($layers)) { + 0 => null, + 1 => $layers[0], + default => $this->layerResolver->resolve($scopeName, $this->currentFile), + }; + + return $this->resolvedLayerData[$scopeName] = [$layer, $layers]; + } + private function resolveFunctionDeclarationName(Function_ $function): string { return isset($function->namespacedName) From ea5ffcf85023c0dc012a97c060cd0bfd3fe93960 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 12:56:45 +0700 Subject: [PATCH 014/104] clean up --- src/Analyser/AnalysisNodeCollector.php | 83 ++++++++++++++++++-------- 1 file changed, 59 insertions(+), 24 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index bf0dd7f1..b99a166b 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -71,6 +71,7 @@ use function array_keys; use function array_pop; +use function array_push; use function array_unique; use function array_values; use function count; @@ -279,9 +280,6 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract */ private array $fileFunctionLikeAnalyses = []; - /** @var array}> */ - private array $resolvedLayerData = []; - public function __construct( private readonly LayerResolverInterface $layerResolver ) { @@ -320,7 +318,6 @@ public function setCurrentFile(string $file): void $this->activeFunctionLikeAnalyses = []; $this->fileFunctionLikeAnalyses = []; $this->functionLikeDepthAtClassLikeEntry = []; - $this->resolvedLayerData = []; } /** @return list */ @@ -490,7 +487,7 @@ public function leaveNode(Node $node): null // ClassLike are statements, so one instanceof splits the two groups. if ($node instanceof Expr) { if ($node instanceof Closure || $node instanceof ArrowFunction) { - array_pop($this->activeFunctionLikeAnalyses); + $this->finishFunctionLikeAnalysis(); return null; } @@ -529,7 +526,7 @@ public function leaveNode(Node $node): null } if ($node instanceof Function_) { - array_pop($this->activeFunctionLikeAnalyses); + $this->finishFunctionLikeAnalysis(); array_pop($this->activeFunctionNames); return null; @@ -670,6 +667,37 @@ private function finishMethodAnalysis(ClassMethod $classMethod): void array_pop($this->activeMethodIds); } + /** + * Roll a completed function-like's body facts into its lexical parent. + * Class-like facts are still collected directly during traversal, so a + * top-level function-like has no additional merge target here. + */ + private function finishFunctionLikeAnalysis(): void + { + $activeCount = count($this->activeFunctionLikeAnalyses); + + if ($activeCount === 0) { + return; + } + + $child = array_pop($this->activeFunctionLikeAnalyses); + + if ($activeCount === 1) { + return; + } + + $parent = $this->activeFunctionLikeAnalyses[$activeCount - 2]; + + array_push($parent->dependencies, ...$child->dependencies); + array_push($parent->functionCallNames, ...$child->functionCallNames); + array_push($parent->superglobals, ...$child->superglobals); + array_push($parent->languageConstructs, ...$child->languageConstructs); + + if ($child->cyclomaticComplexity > 1) { + $parent->cyclomaticComplexity += $child->cyclomaticComplexity - 1; + } + } + private function collectNodeAnalysis(Node $node): void { // A class-name-shaped string literal may feed `new $class`, @@ -726,8 +754,10 @@ private function collectNodeAnalysis(Node $node): void $this->methodClassLikeAnalyses[$activeMethodId]->complexityByMethodId[$activeMethodId]++; } - foreach ($this->activeFunctionLikeAnalyses as $activeFunctionLikeAnalysis) { - $activeFunctionLikeAnalysis->cyclomaticComplexity++; + $activeFunctionLikeCount = count($this->activeFunctionLikeAnalyses); + + if ($activeFunctionLikeCount > 0) { + $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1]->cyclomaticComplexity++; } return; @@ -983,8 +1013,10 @@ private function addDependency(string $dependency): void $activeClassLikeAnalysis->dependencies[] = $dependency; } - foreach ($this->activeFunctionLikeAnalyses as $activeFunctionLikeAnalysis) { - $activeFunctionLikeAnalysis->dependencies[] = $dependency; + $activeFunctionLikeCount = count($this->activeFunctionLikeAnalyses); + + if ($activeFunctionLikeCount > 0) { + $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1]->dependencies[] = $dependency; } } @@ -994,8 +1026,10 @@ private function addFunctionCallName(Name $functionCallName): void $activeClassLikeAnalysis->functionCallNames[] = $functionCallName; } - foreach ($this->activeFunctionLikeAnalyses as $activeFunctionLikeAnalysis) { - $activeFunctionLikeAnalysis->functionCallNames[] = $functionCallName; + $activeFunctionLikeCount = count($this->activeFunctionLikeAnalyses); + + if ($activeFunctionLikeCount > 0) { + $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1]->functionCallNames[] = $functionCallName; } } @@ -1005,8 +1039,10 @@ private function addSuperglobal(string $superglobal): void $activeClassLikeAnalysis->superglobals[] = $superglobal; } - foreach ($this->activeFunctionLikeAnalyses as $activeFunctionLikeAnalysis) { - $activeFunctionLikeAnalysis->superglobals[] = $superglobal; + $activeFunctionLikeCount = count($this->activeFunctionLikeAnalyses); + + if ($activeFunctionLikeCount > 0) { + $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1]->superglobals[] = $superglobal; } } @@ -1032,8 +1068,10 @@ private function addLanguageConstruct(string $languageConstruct): void $activeClassLikeAnalysis->languageConstructs[] = $languageConstruct; } - foreach ($this->activeFunctionLikeAnalyses as $activeFunctionLikeAnalysis) { - $activeFunctionLikeAnalysis->languageConstructs[] = $languageConstruct; + $activeFunctionLikeCount = count($this->activeFunctionLikeAnalyses); + + if ($activeFunctionLikeCount > 0) { + $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1]->languageConstructs[] = $languageConstruct; } } @@ -1153,18 +1191,15 @@ functionCalls: $functionCalls, } /** - * Resolve and cache both layer representations for a scope. With zero or - * one match, resolveAll() already determines the primary layer; the - * separate resolve() pass is only needed for overlapping layer matches. + * Resolve both layer representations for a scope. With zero or one match, + * resolveAll() already determines the primary layer; the separate + * resolve() pass is only needed for overlapping layer matches. Repeated + * lookups are cached by ChainLayerResolver. * * @return array{0: string|null, 1: list} */ private function resolveLayerData(string $scopeName): array { - if (isset($this->resolvedLayerData[$scopeName])) { - return $this->resolvedLayerData[$scopeName]; - } - $layers = $this->layerResolver->resolveAll($scopeName, $this->currentFile); $layer = match (count($layers)) { 0 => null, @@ -1172,7 +1207,7 @@ private function resolveLayerData(string $scopeName): array default => $this->layerResolver->resolve($scopeName, $this->currentFile), }; - return $this->resolvedLayerData[$scopeName] = [$layer, $layers]; + return [$layer, $layers]; } private function resolveFunctionDeclarationName(Function_ $function): string From 5f3e700c72c02d1672ca5651890cb0724430b153 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 13:03:27 +0700 Subject: [PATCH 015/104] rectify --- src/Analyser/AnalysisNodeCollector.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index b99a166b..6c121bcb 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -680,7 +680,7 @@ private function finishFunctionLikeAnalysis(): void return; } - $child = array_pop($this->activeFunctionLikeAnalyses); + $functionLikeAnalysis = array_pop($this->activeFunctionLikeAnalyses); if ($activeCount === 1) { return; @@ -688,13 +688,13 @@ private function finishFunctionLikeAnalysis(): void $parent = $this->activeFunctionLikeAnalyses[$activeCount - 2]; - array_push($parent->dependencies, ...$child->dependencies); - array_push($parent->functionCallNames, ...$child->functionCallNames); - array_push($parent->superglobals, ...$child->superglobals); - array_push($parent->languageConstructs, ...$child->languageConstructs); + array_push($parent->dependencies, ...$functionLikeAnalysis->dependencies); + array_push($parent->functionCallNames, ...$functionLikeAnalysis->functionCallNames); + array_push($parent->superglobals, ...$functionLikeAnalysis->superglobals); + array_push($parent->languageConstructs, ...$functionLikeAnalysis->languageConstructs); - if ($child->cyclomaticComplexity > 1) { - $parent->cyclomaticComplexity += $child->cyclomaticComplexity - 1; + if ($functionLikeAnalysis->cyclomaticComplexity > 1) { + $parent->cyclomaticComplexity += $functionLikeAnalysis->cyclomaticComplexity - 1; } } From 00cb92e7d8ff1bd33e0c3312e497fbb758970d3c Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 13:12:31 +0700 Subject: [PATCH 016/104] add more tests --- tests/Analyser/FunctionLikeCollectionTest.php | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/Analyser/FunctionLikeCollectionTest.php b/tests/Analyser/FunctionLikeCollectionTest.php index e2922850..d7be3e51 100644 --- a/tests/Analyser/FunctionLikeCollectionTest.php +++ b/tests/Analyser/FunctionLikeCollectionTest.php @@ -9,6 +9,7 @@ use Boundwize\StructArmed\Analyser\FunctionLikeAnalysis; use Boundwize\StructArmed\Analyser\FunctionNode; use Boundwize\StructArmed\LayerResolver\Resolvers\NamespaceLayerResolver; +use PhpParser\Node\Expr\Closure; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; use PhpParser\ParserFactory; @@ -94,6 +95,27 @@ public function testCollectsGlobalFunctionOutsideAnyLayer(): void $this->assertSame(0, $functionNode->lineCount); } + public function testSelectsMostSpecificLayerWhenMultipleLayersMatch(): void + { + $namespaceLayerResolver = new NamespaceLayerResolver( + ['Source' => 'src/', 'Domain' => 'src/Domain/'], + self::BASE_PATH + ); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $ast = $parser->parse('setCurrentFile(self::FILE); + + $nodeTraverser = new NodeTraverser(new NameResolver(), $analysisNodeCollector); + $nodeTraverser->traverse($ast ?? []); + + $functionNode = $analysisNodeCollector->getFunctionNodes()[0]; + + $this->assertSame('Domain', $functionNode->layer); + $this->assertSame(['Source', 'Domain'], $functionNode->layers); + } + public function testCollectsFunctionDependenciesWithoutSeedingNamespaceImports(): void { $functionNode = $this->collectFunction( @@ -367,6 +389,16 @@ public function testTracksThisUsageInTopLevelClosure(): void $this->assertTrue($anonymousFunctionNode->usesThis); } + public function testIgnoresFunctionLikeExitWithoutMatchingEntry(): void + { + $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); + + $analysisNodeCollector->leaveNode(new Closure()); + + $this->assertSame([], $analysisNodeCollector->getAnonymousFunctionNodes()); + } + public function testMethodComplexityStillAggregatesNestedClosureBranches(): void { $classNode = $this->makeCollector( From 9e26bf7601a117de1b9941ede260e46ce8fb8f84 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 13:23:44 +0700 Subject: [PATCH 017/104] reduce collector overhead --- src/Analyser/AnalysisNodeCollector.php | 52 ++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index 6c121bcb..9c519561 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -175,6 +175,50 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract List_::class => 'list', ]; + /** + * Every node class enterNode() acts on: the scope-tracking statements, + * function-likes, and everything collectNodeAnalysis() records. The + * parser only ever instantiates these exact classes, so a single ::class + * hash lookup lets the large majority of nodes (identifiers, arguments, + * scalars, assignments, ...) return before any instanceof check. + */ + private const ENTER_NODES = self::COMPLEXITY_BRANCH_NODES + self::LANGUAGE_CONSTRUCT_NODES + [ + Namespace_::class => true, + Use_::class => true, + GroupUse::class => true, + Function_::class => true, + Class_::class => true, + Interface_::class => true, + Trait_::class => true, + Enum_::class => true, + ClassMethod::class => true, + Closure::class => true, + ArrowFunction::class => true, + String_::class => true, + FullyQualified::class => true, + Variable::class => true, + FuncCall::class => true, + Exit_::class => true, + Include_::class => true, + ]; + + /** + * Every node class leaveNode() acts on, see ENTER_NODES. + */ + private const LEAVE_NODES = [ + Closure::class => true, + ArrowFunction::class => true, + New_::class => true, + MethodCall::class => true, + NullsafeMethodCall::class => true, + ClassMethod::class => true, + Function_::class => true, + Class_::class => true, + Interface_::class => true, + Trait_::class => true, + Enum_::class => true, + ]; + /** @var list */ private array $nodes = []; @@ -409,6 +453,10 @@ public static function parseDeferredInstantiationMarker(string $instantiation): public function enterNode(Node $node): null { + if (! isset(self::ENTER_NODES[$node::class])) { + return null; + } + // The scope-tracking node types are all statements, so the far more // frequent expression/name/identifier nodes skip their checks with a // single instanceof. @@ -481,6 +529,10 @@ public function enterNode(Node $node): null public function leaveNode(Node $node): null { + if (! isset(self::LEAVE_NODES[$node::class])) { + return null; + } + // Both instantiation handlers run on leave, once the NameResolver // has resolved the nested name nodes (e.g. Base::class inside the // class expression). They only match expressions, and ClassMethod / From 0aed5dc84e8237444ab422f6a7d067819076db1c Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 13:24:51 +0700 Subject: [PATCH 018/104] fix --- src/Analyser/AnalysisNodeCollector.php | 34 +++++++++++++------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index 9c519561..b6b09d55 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -183,23 +183,23 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract * scalars, assignments, ...) return before any instanceof check. */ private const ENTER_NODES = self::COMPLEXITY_BRANCH_NODES + self::LANGUAGE_CONSTRUCT_NODES + [ - Namespace_::class => true, - Use_::class => true, - GroupUse::class => true, - Function_::class => true, - Class_::class => true, - Interface_::class => true, - Trait_::class => true, - Enum_::class => true, - ClassMethod::class => true, - Closure::class => true, - ArrowFunction::class => true, - String_::class => true, - FullyQualified::class => true, - Variable::class => true, - FuncCall::class => true, - Exit_::class => true, - Include_::class => true, + Namespace_::class => true, + Use_::class => true, + GroupUse::class => true, + Function_::class => true, + Class_::class => true, + Interface_::class => true, + Trait_::class => true, + Enum_::class => true, + ClassMethod::class => true, + Closure::class => true, + ArrowFunction::class => true, + String_::class => true, + FullyQualified::class => true, + Variable::class => true, + FuncCall::class => true, + Exit_::class => true, + Include_::class => true, ]; /** From a4b8f8d9aa514fd4dfd1cb776de5701959f03de5 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 13:28:33 +0700 Subject: [PATCH 019/104] rectify --- tests/Analyser/AnalysisNodeCollectorTest.php | 23 ++++++++------------ 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index 460747b2..91b91dff 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -684,28 +684,23 @@ public function testCollectsMagicMethodFlag(): void $this->assertFalse($classNode->methods[1]->isMagic); } - public function testFiltersClassMethodsOncePerClassLike(): void + /** + * Node dispatch is keyed by exact node class, as the parser never + * subclasses its nodes, so a hand-built Class_ (not a subclass of it) is + * traversed like a parsed one. + */ + public function testCollectsEachClassMethodOnce(): void { $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); - $classLike = new class ('Foo', [ + $class = new Class_('Foo', [ 'stmts' => [new ClassMethod('__construct'), new ClassMethod('bar')], - ]) extends Class_ { - public int $getMethodsCallCount = 0; - - public function getMethods(): array - { - ++$this->getMethodsCallCount; - - return parent::getMethods(); - } - }; + ]); $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php'); - (new NodeTraverser(new NameResolver(), $analysisNodeCollector))->traverse([$classLike]); + (new NodeTraverser(new NameResolver(), $analysisNodeCollector))->traverse([$class]); - $this->assertSame(1, $classLike->getMethodsCallCount); $this->assertSame( ['__construct', 'bar'], array_column($analysisNodeCollector->getNodes()[0]->methods, 'name'), From a63e63615566c8778ad3813d84064bd3b2397189 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 18:14:43 +0700 Subject: [PATCH 020/104] perf: Decode composer.json once per run in Psr4PathResolver --- src/Composer/Psr4PathResolver.php | 29 +++++++++++++++++-- .../Composer/Psr4EmptyNamespacePrefixRule.php | 9 ++---- src/Rule/Rules/Composer/Psr4RootPathRule.php | 9 ++---- tests/Composer/Psr4PathResolverTest.php | 13 +++++++++ 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/Composer/Psr4PathResolver.php b/src/Composer/Psr4PathResolver.php index 43f43e99..95f2934b 100644 --- a/src/Composer/Psr4PathResolver.php +++ b/src/Composer/Psr4PathResolver.php @@ -14,11 +14,18 @@ use function is_int; use function is_string; use function json_decode; -use function rtrim; use function trim; final class Psr4PathResolver { + /** + * Decoded composer.json per file, keyed by raw contents so a rewritten file + * is re-decoded while repeated reads of an unchanged file are not. + * + * @var array|null}> + */ + private static array $decodedByFile = []; + /** * @return list */ @@ -72,13 +79,29 @@ public function namespacePaths(string $basePath): array */ public function composerConfig(string $basePath): ?array { - $composerFile = rtrim($basePath, '/') . '/composer.json'; + $composerFile = Path::normalise(Path::resolve('composer.json', $basePath), canonicalise: true); if (! file_exists($composerFile)) { return null; } - $composer = json_decode((string) file_get_contents($composerFile), true); + $contents = (string) file_get_contents($composerFile); + + if (isset(self::$decodedByFile[$composerFile]) && self::$decodedByFile[$composerFile][0] === $contents) { + return self::$decodedByFile[$composerFile][1]; + } + + self::$decodedByFile[$composerFile] = [$contents, $this->decode($contents)]; + + return self::$decodedByFile[$composerFile][1]; + } + + /** + * @return array|null + */ + private function decode(string $contents): ?array + { + $composer = json_decode($contents, true); if (! is_array($composer)) { return null; diff --git a/src/Rule/Rules/Composer/Psr4EmptyNamespacePrefixRule.php b/src/Rule/Rules/Composer/Psr4EmptyNamespacePrefixRule.php index 58424005..fe532a20 100644 --- a/src/Rule/Rules/Composer/Psr4EmptyNamespacePrefixRule.php +++ b/src/Rule/Rules/Composer/Psr4EmptyNamespacePrefixRule.php @@ -11,7 +11,6 @@ use Boundwize\StructArmed\Rule\RuleViolation; use function array_keys; -use function file_exists; use function is_array; use function is_string; use function rtrim; @@ -38,18 +37,14 @@ public function evaluateProject(string $basePath, Architecture $architecture, ar */ public function evaluateProjectAll(string $basePath, Architecture $architecture, array $skipPaths = []): array { - $composerFile = rtrim($basePath, '/') . '/composer.json'; - - if (! file_exists($composerFile)) { - return []; - } - $composer = $this->psr4PathResolver->composerConfig($basePath); if ($composer === null) { return []; } + $composerFile = rtrim($basePath, '/') . '/composer.json'; + $violations = []; foreach (['autoload', 'autoload-dev'] as $section) { diff --git a/src/Rule/Rules/Composer/Psr4RootPathRule.php b/src/Rule/Rules/Composer/Psr4RootPathRule.php index 5fb93676..354a01ce 100644 --- a/src/Rule/Rules/Composer/Psr4RootPathRule.php +++ b/src/Rule/Rules/Composer/Psr4RootPathRule.php @@ -11,7 +11,6 @@ use Boundwize\StructArmed\Rule\RuleViolation; use Boundwize\StructArmed\Util\Path; -use function file_exists; use function is_array; use function is_string; use function rtrim; @@ -36,18 +35,14 @@ public function evaluateProject(string $basePath, Architecture $architecture, ar */ public function evaluateProjectAll(string $basePath, Architecture $architecture, array $skipPaths = []): array { - $composerFile = rtrim($basePath, '/') . '/composer.json'; - - if (! file_exists($composerFile)) { - return []; - } - $composer = $this->psr4PathResolver->composerConfig($basePath); if ($composer === null) { return []; } + $composerFile = rtrim($basePath, '/') . '/composer.json'; + $violations = []; $normalisedBasePath = Path::normalise($basePath, canonicalise: true); diff --git a/tests/Composer/Psr4PathResolverTest.php b/tests/Composer/Psr4PathResolverTest.php index 18a04ec8..4d3a8805 100644 --- a/tests/Composer/Psr4PathResolverTest.php +++ b/tests/Composer/Psr4PathResolverTest.php @@ -115,6 +115,19 @@ public function testSkipsInvalidAutoloadShapes(): void $this->assertSame([], $psr4PathResolver->namespacePaths($basePath)); } + public function testRereadsComposerJsonAfterItIsRewritten(): void + { + $basePath = $this->makeTempProject('{"autoload": {"psr-4": {"App\\\\": "src/"}}}'); + $psr4PathResolver = new Psr4PathResolver(); + + $this->assertSame(['src'], $psr4PathResolver->paths($basePath)); + + file_put_contents($basePath . '/composer.json', '{"autoload": {"psr-4": {"App\\\\": "lib/"}}}'); + + $this->assertSame(['lib'], (new Psr4PathResolver())->paths($basePath)); + $this->assertSame(['lib'], $psr4PathResolver->paths($basePath . '/')); + } + private function makeTempProject(string $composerJson): string { $basePath = $this->makeTempDir(); From acf0eebe3fae2d4db8dac2921ab9b43fdc609fd3 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 18:39:42 +0700 Subject: [PATCH 021/104] move logic to ComposerJsonProvider --- src/Cache/AnalysisResultCache.php | 13 +++- src/Composer/ComposerJsonProvider.php | 79 +++++++++++++++++++++ src/Composer/Psr4PathResolver.php | 57 ++------------- structarmed.php | 2 +- tests/Cache/AnalysisResultCacheTest.php | 23 ++++++ tests/Composer/ComposerJsonProviderTest.php | 58 +++++++++++++++ tests/Composer/Psr4PathResolverTest.php | 13 ---- 7 files changed, 179 insertions(+), 66 deletions(-) create mode 100644 src/Composer/ComposerJsonProvider.php create mode 100644 tests/Composer/ComposerJsonProviderTest.php diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index db51ca6d..829402e6 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -13,6 +13,7 @@ use Boundwize\StructArmed\Analyser\FunctionNode; use Boundwize\StructArmed\Analyser\MethodNode; use Boundwize\StructArmed\Analyser\PropertyNode; +use Boundwize\StructArmed\Composer\ComposerJsonProvider; use Boundwize\StructArmed\Rule\RuleViolation; use Boundwize\StructArmed\Rule\RuleViolationCollection; @@ -34,6 +35,7 @@ use function json_encode; use function mkdir; use function rmdir; +use function rtrim; use function sprintf; use function unlink; @@ -63,6 +65,9 @@ final class AnalysisResultCache private readonly string $cacheDirectory; + /** Hash of the project composer.json: its PSR-4 mappings decide layer assignments. */ + private readonly string $composerHash; + private bool $isCacheInitialised = false; public function __construct( @@ -71,8 +76,11 @@ public function __construct( ?string $cacheDirectory = null, private readonly string $configHash = '', private readonly string $composerGeneratedVersionHash = '', + private readonly ComposerJsonProvider $composerJsonProvider = new ComposerJsonProvider(), ) { $this->cacheDirectory = CachePathFactory::getPath($cacheDirectory, $basePath); + $composerFile = rtrim($basePath, '/') . '/composer.json'; + $this->composerHash = file_exists($composerFile) ? $fileHashProvider->hash($composerFile) : ''; } /** @@ -130,6 +138,7 @@ public function clear(): void { $this->isCacheInitialised = false; $this->fileHashProvider->clear(); + $this->composerJsonProvider->clear(); if (! is_dir($this->cacheDirectory)) { return; @@ -168,7 +177,8 @@ public function shouldInvalidate(): bool return ($payload['version'] ?? null) !== self::FORMAT_VERSION || ($payload['configHash'] ?? null) !== $this->configHash - || ($payload['composerGeneratedVersionHash'] ?? null) !== $this->composerGeneratedVersionHash; + || ($payload['composerGeneratedVersionHash'] ?? null) !== $this->composerGeneratedVersionHash + || ($payload['composerHash'] ?? null) !== $this->composerHash; } private function ensureCacheInitialised(): void @@ -188,6 +198,7 @@ private function ensureCacheInitialised(): void 'version' => self::FORMAT_VERSION, 'configHash' => $this->configHash, 'composerGeneratedVersionHash' => $this->composerGeneratedVersionHash, + 'composerHash' => $this->composerHash, ], JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR)); } diff --git a/src/Composer/ComposerJsonProvider.php b/src/Composer/ComposerJsonProvider.php new file mode 100644 index 00000000..9f94fa2c --- /dev/null +++ b/src/Composer/ComposerJsonProvider.php @@ -0,0 +1,79 @@ +|null}> */ + private static array $decodedByFile = []; + + /** + * @return array|null + */ + public function config(string $basePath): ?array + { + $composerFile = Path::normalise(Path::resolve('composer.json', $basePath), canonicalise: true); + + clearstatcache(true, $composerFile); + + $stat = file_exists($composerFile) ? filemtime($composerFile) . ':' . filesize($composerFile) : ''; + + if (isset(self::$decodedByFile[$composerFile]) && self::$decodedByFile[$composerFile][0] === $stat) { + return self::$decodedByFile[$composerFile][1]; + } + + $config = $stat === '' ? null : $this->decode((string) file_get_contents($composerFile)); + + self::$decodedByFile[$composerFile] = [$stat, $config]; + + return $config; + } + + public function clear(): void + { + self::$decodedByFile = []; + } + + /** + * @return array|null + */ + private function decode(string $contents): ?array + { + $composer = json_decode($contents, true); + + if (! is_array($composer)) { + return null; + } + + $config = []; + + foreach ($composer as $key => $value) { + if (is_int($key)) { + return null; + } + + $config[$key] = $value; + } + + return $config; + } +} diff --git a/src/Composer/Psr4PathResolver.php b/src/Composer/Psr4PathResolver.php index 95f2934b..1fdc09a1 100644 --- a/src/Composer/Psr4PathResolver.php +++ b/src/Composer/Psr4PathResolver.php @@ -8,23 +8,16 @@ use function array_merge; use function array_values; -use function file_exists; -use function file_get_contents; use function is_array; -use function is_int; use function is_string; -use function json_decode; use function trim; -final class Psr4PathResolver +final readonly class Psr4PathResolver { - /** - * Decoded composer.json per file, keyed by raw contents so a rewritten file - * is re-decoded while repeated reads of an unchanged file are not. - * - * @var array|null}> - */ - private static array $decodedByFile = []; + public function __construct( + private ComposerJsonProvider $composerJsonProvider = new ComposerJsonProvider(), + ) { + } /** * @return list @@ -79,45 +72,7 @@ public function namespacePaths(string $basePath): array */ public function composerConfig(string $basePath): ?array { - $composerFile = Path::normalise(Path::resolve('composer.json', $basePath), canonicalise: true); - - if (! file_exists($composerFile)) { - return null; - } - - $contents = (string) file_get_contents($composerFile); - - if (isset(self::$decodedByFile[$composerFile]) && self::$decodedByFile[$composerFile][0] === $contents) { - return self::$decodedByFile[$composerFile][1]; - } - - self::$decodedByFile[$composerFile] = [$contents, $this->decode($contents)]; - - return self::$decodedByFile[$composerFile][1]; - } - - /** - * @return array|null - */ - private function decode(string $contents): ?array - { - $composer = json_decode($contents, true); - - if (! is_array($composer)) { - return null; - } - - $config = []; - - foreach ($composer as $key => $value) { - if (is_int($key)) { - return null; - } - - $config[$key] = $value; - } - - return $config; + return $this->composerJsonProvider->config($basePath); } /** diff --git a/structarmed.php b/structarmed.php index e043d9ad..e7549f57 100644 --- a/structarmed.php +++ b/structarmed.php @@ -29,7 +29,7 @@ ->ruleset([ 'Analyser' => ['+Cache', 'Composer', 'LayerResolver', 'Progress', 'Util'], 'Baseline' => ['Core', 'Rule', 'Util'], - 'Cache' => ['Analyser', 'Core', 'Rule', 'Util'], + 'Cache' => ['Analyser', 'Composer', 'Core', 'Rule', 'Util'], 'Cli' => ['Baseline', '+Cache', 'Config', 'Progress', 'Report', 'Util'], 'Composer' => ['Util'], 'Config' => ['Core'], diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index 7818f331..791769fa 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -450,6 +450,29 @@ public function testCacheFromOlderFormatVersionIsInvalidated(): void } } + public function testCacheIsInvalidatedWhenComposerJsonChanges(): void + { + $basePath = $this->createTempDirectory(); + $cacheDirectory = $this->createTempDirectory(); + file_put_contents($basePath . '/composer.json', '{"autoload": {"psr-4": {"App\\\\": "src/"}}}'); + + try { + $analysisResultCache = new AnalysisResultCache($basePath, new FileHashProvider(), $cacheDirectory); + $analysisResultCache->store('key', [], new RuleViolationCollection()); + + $this->assertFalse($analysisResultCache->shouldInvalidate()); + + file_put_contents($basePath . '/composer.json', '{"autoload": {"psr-4": {"App\\\\": "lib/"}}}'); + + $this->assertTrue( + (new AnalysisResultCache($basePath, new FileHashProvider(), $cacheDirectory))->shouldInvalidate() + ); + } finally { + $this->removeTempDirectory($basePath); + $this->removeTempDirectory($cacheDirectory); + } + } + public function testPopulatedCacheWithoutMetadataMarkerIsInvalidated(): void { $cacheDirectory = $this->createTempDirectory(); diff --git a/tests/Composer/ComposerJsonProviderTest.php b/tests/Composer/ComposerJsonProviderTest.php new file mode 100644 index 00000000..e25639e1 --- /dev/null +++ b/tests/Composer/ComposerJsonProviderTest.php @@ -0,0 +1,58 @@ +assertNull($composerJsonProvider->config($this->makeTempDir())); + $this->assertNull($composerJsonProvider->config($this->makeTempProject('{not json'))); + $this->assertNull($composerJsonProvider->config($this->makeTempProject('["not", "an", "object"]'))); + } + + public function testRedecodesComposerJsonWhenItIsRewritten(): void + { + $basePath = $this->makeTempProject('{"name": "app/first"}'); + $composerJsonProvider = new ComposerJsonProvider(); + + $this->assertSame(['name' => 'app/first'], $composerJsonProvider->config($basePath)); + $this->assertSame(['name' => 'app/first'], (new ComposerJsonProvider())->config($basePath . '/')); + + file_put_contents($basePath . '/composer.json', '{"name": "app/second"}'); + + $this->assertSame(['name' => 'app/second'], $composerJsonProvider->config($basePath)); + + $composerJsonProvider->clear(); + + $this->assertSame(['name' => 'app/second'], $composerJsonProvider->config($basePath)); + } + + private function makeTempProject(string $composerJson): string + { + $basePath = $this->makeTempDir(); + + file_put_contents($basePath . '/composer.json', $composerJson); + + return $basePath; + } + + private function makeTempDir(): string + { + return $this->makeTemporaryDirectory('structarmed-composer-json-provider'); + } +} diff --git a/tests/Composer/Psr4PathResolverTest.php b/tests/Composer/Psr4PathResolverTest.php index 4d3a8805..18a04ec8 100644 --- a/tests/Composer/Psr4PathResolverTest.php +++ b/tests/Composer/Psr4PathResolverTest.php @@ -115,19 +115,6 @@ public function testSkipsInvalidAutoloadShapes(): void $this->assertSame([], $psr4PathResolver->namespacePaths($basePath)); } - public function testRereadsComposerJsonAfterItIsRewritten(): void - { - $basePath = $this->makeTempProject('{"autoload": {"psr-4": {"App\\\\": "src/"}}}'); - $psr4PathResolver = new Psr4PathResolver(); - - $this->assertSame(['src'], $psr4PathResolver->paths($basePath)); - - file_put_contents($basePath . '/composer.json', '{"autoload": {"psr-4": {"App\\\\": "lib/"}}}'); - - $this->assertSame(['lib'], (new Psr4PathResolver())->paths($basePath)); - $this->assertSame(['lib'], $psr4PathResolver->paths($basePath . '/')); - } - private function makeTempProject(string $composerJson): string { $basePath = $this->makeTempDir(); From d53e727984019adb1f49fd592a2430a1b82dc996 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 19:01:25 +0700 Subject: [PATCH 022/104] avoid filemtime:size usage --- src/Composer/ComposerJsonProvider.php | 32 ++++++++----------- tests/Composer/ComposerJsonProviderTest.php | 11 ++++--- .../Composer/Psr4DirectoryExistsRuleTest.php | 12 ------- 3 files changed, 19 insertions(+), 36 deletions(-) diff --git a/src/Composer/ComposerJsonProvider.php b/src/Composer/ComposerJsonProvider.php index 9f94fa2c..149daf10 100644 --- a/src/Composer/ComposerJsonProvider.php +++ b/src/Composer/ComposerJsonProvider.php @@ -6,24 +6,24 @@ use Boundwize\StructArmed\Util\Path; -use function clearstatcache; +use function array_key_exists; use function file_exists; use function file_get_contents; -use function filemtime; -use function filesize; use function is_array; use function is_int; use function json_decode; /** - * Provides memoised decoded composer.json contents. The memo is shared across - * instances because rules and presets create their own Psr4PathResolver; it is - * validated against the file's mtime and size, so a rewrite (e.g. by a fixer) - * is re-decoded, and AnalysisResultCache::clear() drops it entirely. + * Provides memoised decoded composer.json contents, treated as immutable during + * a single analysis run: after the first call no filesystem access happens. The + * memo is shared across instances because rules and presets create their own + * Psr4PathResolver. AnalysisResultCache::clear() drops it, which the CLI runs + * after every fix pass; any other caller that rewrites composer.json and then + * re-evaluates in the same process must call clear() itself. */ final class ComposerJsonProvider { - /** @var array|null}> */ + /** @var array|null> */ private static array $decodedByFile = []; /** @@ -33,19 +33,13 @@ public function config(string $basePath): ?array { $composerFile = Path::normalise(Path::resolve('composer.json', $basePath), canonicalise: true); - clearstatcache(true, $composerFile); - - $stat = file_exists($composerFile) ? filemtime($composerFile) . ':' . filesize($composerFile) : ''; - - if (isset(self::$decodedByFile[$composerFile]) && self::$decodedByFile[$composerFile][0] === $stat) { - return self::$decodedByFile[$composerFile][1]; + if (array_key_exists($composerFile, self::$decodedByFile)) { + return self::$decodedByFile[$composerFile]; } - $config = $stat === '' ? null : $this->decode((string) file_get_contents($composerFile)); - - self::$decodedByFile[$composerFile] = [$stat, $config]; - - return $config; + return self::$decodedByFile[$composerFile] = file_exists($composerFile) + ? $this->decode((string) file_get_contents($composerFile)) + : null; } public function clear(): void diff --git a/tests/Composer/ComposerJsonProviderTest.php b/tests/Composer/ComposerJsonProviderTest.php index e25639e1..c9d74e34 100644 --- a/tests/Composer/ComposerJsonProviderTest.php +++ b/tests/Composer/ComposerJsonProviderTest.php @@ -25,21 +25,22 @@ public function testReturnsNullWhenComposerJsonIsMissingInvalidOrNotObject(): vo $this->assertNull($composerJsonProvider->config($this->makeTempProject('["not", "an", "object"]'))); } - public function testRedecodesComposerJsonWhenItIsRewritten(): void + public function testMemoisesDecodedComposerJsonAcrossInstancesUntilCleared(): void { $basePath = $this->makeTempProject('{"name": "app/first"}'); $composerJsonProvider = new ComposerJsonProvider(); $this->assertSame(['name' => 'app/first'], $composerJsonProvider->config($basePath)); - $this->assertSame(['name' => 'app/first'], (new ComposerJsonProvider())->config($basePath . '/')); - file_put_contents($basePath . '/composer.json', '{"name": "app/second"}'); + // Same byte length, rewritten immediately: the memo is lifecycle-bound, + // not tied to filesystem metadata, so it is served until cleared. + file_put_contents($basePath . '/composer.json', '{"name": "app/other"}'); - $this->assertSame(['name' => 'app/second'], $composerJsonProvider->config($basePath)); + $this->assertSame(['name' => 'app/first'], (new ComposerJsonProvider())->config($basePath . '/')); $composerJsonProvider->clear(); - $this->assertSame(['name' => 'app/second'], $composerJsonProvider->config($basePath)); + $this->assertSame(['name' => 'app/other'], $composerJsonProvider->config($basePath)); } private function makeTempProject(string $composerJson): string diff --git a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php index 68b6cce0..e0dc4b82 100644 --- a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php +++ b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php @@ -223,10 +223,6 @@ public function testFixRemovesPsr4MappingsForMissingDirectories(): void } } JSON, file_get_contents($basePath . '/composer.json')); - $this->assertNotInstanceOf( - RuleViolation::class, - $psr4DirectoryExistsRule->evaluateProject($basePath, Architecture::define()) - ); } public function testFixRemovesPsr4BlockWhenEveryMappingDirectoryIsMissing(): void @@ -250,10 +246,6 @@ public function testFixRemovesPsr4BlockWhenEveryMappingDirectoryIsMissing(): voi { } JSON, file_get_contents($basePath . '/composer.json')); - $this->assertNotInstanceOf( - RuleViolation::class, - $psr4DirectoryExistsRule->evaluateProject($basePath, Architecture::define()) - ); } public function testFixKeepsUnchangedEmptyPsr4Block(): void @@ -285,10 +277,6 @@ public function testFixKeepsUnchangedEmptyPsr4Block(): void } } JSON, file_get_contents($basePath . '/composer.json')); - $this->assertNotInstanceOf( - RuleViolation::class, - $psr4DirectoryExistsRule->evaluateProject($basePath, Architecture::define()) - ); } public function testFixReturnsFalseWhenAllPsr4DirectoriesExist(): void From 0e7930e12a29ee51d5878bad95f5035c3497ab79 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 19:26:58 +0700 Subject: [PATCH 023/104] cache improve --- src/Analyser/Analyser.php | 48 +++------------- src/Analyser/AnalysisNodeExtractor.php | 13 ++++- src/Analyser/Parallel/AnalysisNodeWorker.php | 15 +++-- .../ParallelAnalysisNodeExtractor.php | 5 ++ src/Cache/AnalysisResultCache.php | 55 +++++++++++++++++++ .../Parallel/AnalysisNodeWorkerTest.php | 40 ++++++++++++++ tests/Cache/AnalysisResultCacheTest.php | 27 +++++++++ 7 files changed, 158 insertions(+), 45 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 64e06d59..50cd7d8f 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -1324,37 +1324,31 @@ private function collectAnalysisNodes( $options = $analyserOptions ?? AnalyserOptions::parallel(); if ($options->isParallel()) { + // Workers write their own files' cache payloads while other workers are + // still parsing, instead of the coordinator doing it serially afterwards. $parsedResult = (new ParallelAnalysisNodeExtractor( $this->basePath, $layers, $layerPatterns, $options->workerCount, $this->analysisResultCache?->getCacheDirectory(), + $this->analysisResultCache, + $this->analysisNodeCacheNamespace, ))->extract($filesToParse, $progressHandler, $withFileAnalysis); } else { - $parsedResult = (new AnalysisNodeExtractor($chainLayerResolver))->extract( - $filesToParse, - $progressHandler, - $withFileAnalysis, - ); + $parsedResult = (new AnalysisNodeExtractor( + $chainLayerResolver, + analysisResultCache: $this->analysisResultCache, + analysisNodeCacheNamespace: $this->analysisNodeCacheNamespace, + ))->extract($filesToParse, $progressHandler, $withFileAnalysis); } - $classNodesByFile = array_fill_keys($filesToParse, []); foreach ($parsedResult->classNodes as $parsedClassNode) { $classNodes[] = $parsedClassNode; - - if (isset($classNodesByFile[$parsedClassNode->file])) { - $classNodesByFile[$parsedClassNode->file][] = $parsedClassNode; - } } - $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) { @@ -1369,36 +1363,12 @@ private function collectAnalysisNodes( $fileInstantiations[$file] = $parsedFileInstantiations; } - $functionNodesByFile = array_fill_keys($filesToParse, []); foreach ($parsedResult->functionNodes as $parsedFunctionNode) { $functionNodes[] = $parsedFunctionNode; - - if (isset($functionNodesByFile[$parsedFunctionNode->file])) { - $functionNodesByFile[$parsedFunctionNode->file][] = $parsedFunctionNode; - } } - $anonymousFunctionNodesByFile = array_fill_keys($filesToParse, []); foreach ($parsedResult->anonymousFunctionNodes as $parsedAnonymousFunctionNode) { $anonymousFunctionNodes[] = $parsedAnonymousFunctionNode; - - if (isset($anonymousFunctionNodesByFile[$parsedAnonymousFunctionNode->file])) { - $anonymousFunctionNodesByFile[$parsedAnonymousFunctionNode->file][] = $parsedAnonymousFunctionNode; - } - } - - foreach ($classNodesByFile as $fileToParse => $fileClassNodes) { - $this->analysisResultCache?->storeAnalysisNodes( - $fileToParse, - $this->analysisNodeCacheNamespace, - $fileClassNodes, - $fileAnalyses[$fileToParse] ?? null, - $anonymousClassNodesByFile[$fileToParse] ?? [], - $fileReferences[$fileToParse] ?? [], - $fileInstantiations[$fileToParse] ?? [], - $functionNodesByFile[$fileToParse] ?? [], - $anonymousFunctionNodesByFile[$fileToParse] ?? [], - ); } $progressHandler?->finish(); diff --git a/src/Analyser/AnalysisNodeExtractor.php b/src/Analyser/AnalysisNodeExtractor.php index 4053269e..fa1b73c1 100644 --- a/src/Analyser/AnalysisNodeExtractor.php +++ b/src/Analyser/AnalysisNodeExtractor.php @@ -4,6 +4,7 @@ namespace Boundwize\StructArmed\Analyser; +use Boundwize\StructArmed\Cache\AnalysisResultCache; use Boundwize\StructArmed\LayerResolver\LayerResolverInterface; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; use PhpParser\NodeTraverser; @@ -16,9 +17,15 @@ { private FileAnalysisProvider $fileAnalysisProvider; + /** + * @param AnalysisResultCache|null $analysisResultCache When given, every extracted file's nodes + * are stored under $analysisNodeCacheNamespace. + */ public function __construct( private LayerResolverInterface $layerResolver, ?FileAnalysisProvider $fileAnalysisProvider = null, + private ?AnalysisResultCache $analysisResultCache = null, + private string $analysisNodeCacheNamespace = '', ) { $this->fileAnalysisProvider = $fileAnalysisProvider ?? new FileAnalysisProvider(); } @@ -56,7 +63,7 @@ public function extract( } } - return new ExtractionResult( + $extractionResult = new ExtractionResult( $analysisNodeCollector->getNodes(), $fileAnalyses, $analysisNodeCollector->getAnonymousClassNodes(), @@ -65,5 +72,9 @@ public function extract( $analysisNodeCollector->getFunctionNodes(), $analysisNodeCollector->getAnonymousFunctionNodes(), ); + + $this->analysisResultCache?->storeExtractionResult($files, $this->analysisNodeCacheNamespace, $extractionResult); + + return $extractionResult; } } diff --git a/src/Analyser/Parallel/AnalysisNodeWorker.php b/src/Analyser/Parallel/AnalysisNodeWorker.php index 4b20037d..bfdf45ac 100644 --- a/src/Analyser/Parallel/AnalysisNodeWorker.php +++ b/src/Analyser/Parallel/AnalysisNodeWorker.php @@ -5,6 +5,7 @@ namespace Boundwize\StructArmed\Analyser\Parallel; use Boundwize\StructArmed\Analyser\AnalysisNodeExtractor; +use Boundwize\StructArmed\Cache\AnalysisResultCache; use Boundwize\StructArmed\LayerResolver\ChainLayerResolver; use Throwable; @@ -56,11 +57,15 @@ public static function run(string $inputFile, string $outputFile, mixed $outputS $progressHandler = $emitProgress ? new WorkerProgressHandler($stream) : null; - $result = (new AnalysisNodeExtractor($layerResolver))->extract( - $files, - $progressHandler, - $withFileAnalysis, - ); + $cache = $payload['cache'] ?? null; + /** @var string $cacheNamespace */ + $cacheNamespace = $payload['cacheNamespace'] ?? ''; + + $result = (new AnalysisNodeExtractor( + $layerResolver, + analysisResultCache: $cache instanceof AnalysisResultCache ? $cache : null, + analysisNodeCacheNamespace: $cacheNamespace, + ))->extract($files, $progressHandler, $withFileAnalysis); file_put_contents($outputFile, serialize([ 'nodes' => $result->classNodes, diff --git a/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php b/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php index b4daddc5..0ad46a9f 100644 --- a/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php +++ b/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php @@ -10,6 +10,7 @@ use Boundwize\StructArmed\Analyser\ExtractionResult; use Boundwize\StructArmed\Analyser\FileAnalysis; use Boundwize\StructArmed\Analyser\FunctionNode; +use Boundwize\StructArmed\Cache\AnalysisResultCache; use Boundwize\StructArmed\Cache\CachePathFactory; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; use RuntimeException; @@ -63,6 +64,8 @@ public function __construct( private array $layerPatterns, private int $workerCount, private ?string $cacheDirectory = null, + private ?AnalysisResultCache $analysisResultCache = null, + private string $analysisNodeCacheNamespace = '', ) { } @@ -101,6 +104,8 @@ public function extract( 'files' => $chunk, 'emitProgress' => $emitProgress, 'withFileAnalysis' => $withFileAnalysis, + 'cache' => $this->analysisResultCache, + 'cacheNamespace' => $this->analysisNodeCacheNamespace, ])); // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 829402e6..020ccdb2 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -9,6 +9,7 @@ use Boundwize\StructArmed\Analyser\ClassNode; use Boundwize\StructArmed\Analyser\ConstantNode; use Boundwize\StructArmed\Analyser\EnumCaseNode; +use Boundwize\StructArmed\Analyser\ExtractionResult; use Boundwize\StructArmed\Analyser\FileAnalysis; use Boundwize\StructArmed\Analyser\FunctionNode; use Boundwize\StructArmed\Analyser\MethodNode; @@ -17,6 +18,7 @@ use Boundwize\StructArmed\Rule\RuleViolation; use Boundwize\StructArmed\Rule\RuleViolationCollection; +use function array_fill_keys; use function array_key_exists; use function array_keys; use function array_map; @@ -346,6 +348,59 @@ private function classNodesFromPayload(array $payload): ?array return $nodes; } + /** + * Stores the parsed nodes of every file in $files from one extraction result, + * one payload per file (files without nodes get an empty payload too, so they + * are cache hits next run). + * + * @param list $files + */ + public function storeExtractionResult(array $files, string $namespace, ExtractionResult $extractionResult): void + { + $classNodesByFile = array_fill_keys($files, []); + $anonymousClassNodesByFile = $classNodesByFile; + $functionNodesByFile = $classNodesByFile; + $anonymousFunctionNodesByFile = $classNodesByFile; + + foreach ($extractionResult->classNodes as $classNode) { + if (isset($classNodesByFile[$classNode->file])) { + $classNodesByFile[$classNode->file][] = $classNode; + } + } + + foreach ($extractionResult->anonymousClassNodes as $anonymousClassNode) { + if (isset($anonymousClassNodesByFile[$anonymousClassNode->file])) { + $anonymousClassNodesByFile[$anonymousClassNode->file][] = $anonymousClassNode; + } + } + + foreach ($extractionResult->functionNodes as $functionNode) { + if (isset($functionNodesByFile[$functionNode->file])) { + $functionNodesByFile[$functionNode->file][] = $functionNode; + } + } + + foreach ($extractionResult->anonymousFunctionNodes as $anonymousFunctionNode) { + if (isset($anonymousFunctionNodesByFile[$anonymousFunctionNode->file])) { + $anonymousFunctionNodesByFile[$anonymousFunctionNode->file][] = $anonymousFunctionNode; + } + } + + foreach ($files as $file) { + $this->storeAnalysisNodes( + $file, + $namespace, + $classNodesByFile[$file], + $extractionResult->fileAnalyses[$file] ?? null, + $anonymousClassNodesByFile[$file], + $extractionResult->fileReferences[$file] ?? [], + $extractionResult->fileInstantiations[$file] ?? [], + $functionNodesByFile[$file], + $anonymousFunctionNodesByFile[$file], + ); + } + } + /** * @param list $classNodes * @param list $anonymousClassNodes diff --git a/tests/Analyser/Parallel/AnalysisNodeWorkerTest.php b/tests/Analyser/Parallel/AnalysisNodeWorkerTest.php index f18e7619..1d75389a 100644 --- a/tests/Analyser/Parallel/AnalysisNodeWorkerTest.php +++ b/tests/Analyser/Parallel/AnalysisNodeWorkerTest.php @@ -7,6 +7,8 @@ use Boundwize\StructArmed\Analyser\Parallel\AnalysisNodeWorker; use Boundwize\StructArmed\Analyser\Parallel\WorkerFailedException; use Boundwize\StructArmed\Analyser\Parallel\WorkerProgressHandler; +use Boundwize\StructArmed\Cache\AnalysisResultCache; +use Boundwize\StructArmed\Cache\FileHashProvider; use Boundwize\StructArmed\Tests\Support\TemporaryDirectoryCleanupTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -63,6 +65,44 @@ final class Foo $this->assertIsArray($result['nodes']); } + public function testRunStoresAnalysisNodesInSuppliedCache(): void + { + $dir = $this->makeTemporaryDirectory('structarmed-worker-test'); + $cacheDir = $this->makeTemporaryDirectory('structarmed-worker-cache'); + $srcFile = $dir . '/Foo.php'; + + file_put_contents($srcFile, <<<'PHP' +makeTemporaryFile('structarmed-worker-input'); + $outputFile = $this->makeTemporaryFile('structarmed-worker-output'); + + file_put_contents($inputFile, serialize([ + 'basePath' => $dir, + 'layers' => ['Domain' => 'App\\Domain'], + 'layerPatterns' => [], + 'files' => [$srcFile], + 'cache' => new AnalysisResultCache($dir, new FileHashProvider(), $cacheDir), + 'cacheNamespace' => 'namespace', + ])); + + $this->assertSame(0, AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream())); + + $cached = (new AnalysisResultCache($dir, new FileHashProvider(), $cacheDir)) + ->loadAnalysisNodesWithFileAnalysis($srcFile, 'namespace'); + + $this->assertNotNull($cached); + $this->assertCount(1, $cached['classNodes']); + $this->assertSame('App\\Domain\\Foo', $cached['classNodes'][0]->className); + } + public function testRunWithInvalidPayloadReturnsOneAndWritesError(): void { $inputFile = $this->makeTemporaryFile('structarmed-worker-input'); diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index 791769fa..a6387a33 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -10,6 +10,7 @@ use Boundwize\StructArmed\Analyser\ClassNode; use Boundwize\StructArmed\Analyser\ConstantNode; use Boundwize\StructArmed\Analyser\EnumCaseNode; +use Boundwize\StructArmed\Analyser\ExtractionResult; use Boundwize\StructArmed\Analyser\FileAnalysis; use Boundwize\StructArmed\Analyser\FunctionNode; use Boundwize\StructArmed\Analyser\MethodNode; @@ -578,6 +579,32 @@ public function testClassNodeCacheNamespaceDependsOnConfigAndComposerJson(): voi } } + public function testStoreExtractionResultStoresOnePayloadPerFile(): void + { + $cacheDirectory = $this->createTempDirectory(); + $analysisResultCache = new AnalysisResultCache(__DIR__, new FileHashProvider(), $cacheDirectory); + $fileWithNodes = __FILE__; + $fileWithoutNodes = __DIR__ . '/FileHashProviderTest.php'; + + try { + $analysisResultCache->storeExtractionResult( + [$fileWithNodes, $fileWithoutNodes], + 'namespace', + new ExtractionResult([$this->makeClassNode($fileWithNodes)], []) + ); + + $withNodes = $analysisResultCache->loadAnalysisNodes($fileWithNodes, 'namespace'); + $withoutNodes = $analysisResultCache->loadAnalysisNodes($fileWithoutNodes, 'namespace'); + + $this->assertNotNull($withNodes); + $this->assertCount(1, $withNodes['classNodes']); + $this->assertNotNull($withoutNodes); + $this->assertSame([], $withoutNodes['classNodes']); + } finally { + $this->removeTempDirectory($cacheDirectory); + } + } + public function testStoreCreatesMissingCacheDirectory(): void { $basePath = $this->createTempDirectory(); From 480b23a2245c054ef8186b580b70fd6438cda156 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 19:29:17 +0700 Subject: [PATCH 024/104] cs fix --- src/Analyser/AnalysisNodeExtractor.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Analyser/AnalysisNodeExtractor.php b/src/Analyser/AnalysisNodeExtractor.php index fa1b73c1..5754be70 100644 --- a/src/Analyser/AnalysisNodeExtractor.php +++ b/src/Analyser/AnalysisNodeExtractor.php @@ -73,7 +73,11 @@ public function extract( $analysisNodeCollector->getAnonymousFunctionNodes(), ); - $this->analysisResultCache?->storeExtractionResult($files, $this->analysisNodeCacheNamespace, $extractionResult); + $this->analysisResultCache?->storeExtractionResult( + $files, + $this->analysisNodeCacheNamespace, + $extractionResult + ); return $extractionResult; } From 184328c9984fa1b6b7a517a762b496dc87c7af46 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 19:37:59 +0700 Subject: [PATCH 025/104] simplify: reduce loop --- src/Analyser/Analyser.php | 118 +++++++------------------------------- 1 file changed, 21 insertions(+), 97 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 50cd7d8f..2e5f6b60 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -35,6 +35,7 @@ use function array_key_exists; use function array_keys; use function array_merge; +use function array_push; use function array_unique; use function array_values; use function count; @@ -1230,78 +1231,28 @@ private function collectAnalysisNodes( $filesToParse = []; foreach ($files as $file) { - if ($withFileAnalysis) { - $cachedResult = $this->analysisResultCache?->loadAnalysisNodesWithFileAnalysis( + $cachedResult = $withFileAnalysis + ? $this->analysisResultCache?->loadAnalysisNodesWithFileAnalysis( $file, $this->analysisNodeCacheNamespace - ); - - if ($cachedResult === null) { - $filesToParse[] = $file; - continue; - } - - foreach ($cachedResult['classNodes'] as $cachedClassNode) { - $classNodes[] = $cachedClassNode; - } - - foreach ($cachedResult['anonymousClassNodes'] as $cachedAnonymousClassNode) { - $anonymousClassNodes[] = $cachedAnonymousClassNode; - } - - if ($cachedResult['fileReferences'] !== []) { - $fileReferences[$file] = $cachedResult['fileReferences']; - } - - if ($cachedResult['fileInstantiations'] !== []) { - $fileInstantiations[$file] = $cachedResult['fileInstantiations']; - } - - foreach ($cachedResult['functionNodes'] as $cachedFunctionNode) { - $functionNodes[] = $cachedFunctionNode; - } - - foreach ($cachedResult['anonymousFunctionNodes'] as $cachedAnonymousFunctionNode) { - $anonymousFunctionNodes[] = $cachedAnonymousFunctionNode; - } - - $fileAnalyses[$file] = $cachedResult['fileAnalysis']; - - continue; - } - - $cachedResult = $this->analysisResultCache?->loadAnalysisNodes( - $file, - $this->analysisNodeCacheNamespace, - ); + ) + : $this->analysisResultCache?->loadAnalysisNodes($file, $this->analysisNodeCacheNamespace); if ($cachedResult === null) { $filesToParse[] = $file; continue; } - foreach ($cachedResult['classNodes'] as $cachedClassNode) { - $classNodes[] = $cachedClassNode; - } - - foreach ($cachedResult['anonymousClassNodes'] as $cachedAnonymousClassNode) { - $anonymousClassNodes[] = $cachedAnonymousClassNode; - } - - if ($cachedResult['fileReferences'] !== []) { - $fileReferences[$file] = $cachedResult['fileReferences']; - } - - if ($cachedResult['fileInstantiations'] !== []) { - $fileInstantiations[$file] = $cachedResult['fileInstantiations']; - } + array_push($classNodes, ...$cachedResult['classNodes']); + array_push($anonymousClassNodes, ...$cachedResult['anonymousClassNodes']); + array_push($functionNodes, ...$cachedResult['functionNodes']); + array_push($anonymousFunctionNodes, ...$cachedResult['anonymousFunctionNodes']); - foreach ($cachedResult['functionNodes'] as $cachedFunctionNode) { - $functionNodes[] = $cachedFunctionNode; - } + $fileReferences[$file] = $cachedResult['fileReferences']; + $fileInstantiations[$file] = $cachedResult['fileInstantiations']; - foreach ($cachedResult['anonymousFunctionNodes'] as $cachedAnonymousFunctionNode) { - $anonymousFunctionNodes[] = $cachedAnonymousFunctionNode; + if (isset($cachedResult['fileAnalysis'])) { + $fileAnalyses[$file] = $cachedResult['fileAnalysis']; } } @@ -1343,44 +1294,17 @@ private function collectAnalysisNodes( ))->extract($filesToParse, $progressHandler, $withFileAnalysis); } - foreach ($parsedResult->classNodes as $parsedClassNode) { - $classNodes[] = $parsedClassNode; - } - - foreach ($parsedResult->anonymousClassNodes as $parsedAnonymousClassNode) { - $anonymousClassNodes[] = $parsedAnonymousClassNode; - } - - foreach ($parsedResult->fileAnalyses as $file => $fileAnalysis) { - $fileAnalyses[$file] = $fileAnalysis; - } - - foreach ($parsedResult->fileReferences as $file => $parsedFileReferences) { - $fileReferences[$file] = $parsedFileReferences; - } - - foreach ($parsedResult->fileInstantiations as $file => $parsedFileInstantiations) { - $fileInstantiations[$file] = $parsedFileInstantiations; - } - - foreach ($parsedResult->functionNodes as $parsedFunctionNode) { - $functionNodes[] = $parsedFunctionNode; - } - - foreach ($parsedResult->anonymousFunctionNodes as $parsedAnonymousFunctionNode) { - $anonymousFunctionNodes[] = $parsedAnonymousFunctionNode; - } - $progressHandler?->finish(); + // Cached nodes first, then the freshly parsed ones. return new ExtractionResult( - $classNodes, - $fileAnalyses, - $anonymousClassNodes, - $fileReferences, - $fileInstantiations, - $functionNodes, - $anonymousFunctionNodes, + classNodes: [...$classNodes, ...$parsedResult->classNodes], + fileAnalyses: $fileAnalyses + $parsedResult->fileAnalyses, + anonymousClassNodes: [...$anonymousClassNodes, ...$parsedResult->anonymousClassNodes], + fileReferences: $fileReferences + $parsedResult->fileReferences, + fileInstantiations: $fileInstantiations + $parsedResult->fileInstantiations, + functionNodes: [...$functionNodes, ...$parsedResult->functionNodes], + anonymousFunctionNodes: [...$anonymousFunctionNodes, ...$parsedResult->anonymousFunctionNodes], ); } From 59e87f3c85504d0e9f94efaee4ce882eb4619e5b Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 19:44:34 +0700 Subject: [PATCH 026/104] add more test --- src/Analyser/AnalysisNodeCollector.php | 6 ++-- tests/Cache/AnalysisResultCacheTest.php | 38 ++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index b6b09d55..b72928a3 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -74,6 +74,7 @@ use function array_push; use function array_unique; use function array_values; +use function assert; use function count; use function end; use function in_array; @@ -584,9 +585,8 @@ public function leaveNode(Node $node): null return null; } - if (! $node instanceof ClassLike) { - return null; - } + // Every remaining LEAVE_NODES entry is a class-like statement. + assert($node instanceof ClassLike); array_pop($this->activeClassLikeScopes); array_pop($this->activeClassLikeNames); diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index a6387a33..cec39a00 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -590,7 +590,39 @@ public function testStoreExtractionResultStoresOnePayloadPerFile(): void $analysisResultCache->storeExtractionResult( [$fileWithNodes, $fileWithoutNodes], 'namespace', - new ExtractionResult([$this->makeClassNode($fileWithNodes)], []) + new ExtractionResult( + classNodes: [$this->makeClassNode($fileWithNodes)], + fileAnalyses: [], + anonymousClassNodes: [new AnonymousClassNode(file: $fileWithNodes, line: 7, extends: null)], + functionNodes: [ + new FunctionNode( + functionName: 'App\\format', + file: $fileWithNodes, + line: 3, + layer: 'Source', + hasReturnType: true, + paramCount: 0, + cyclomaticComplexity: 1, + lineCount: 1, + ), + ], + anonymousFunctionNodes: [ + new AnonymousFunctionNode( + file: $fileWithNodes, + line: 5, + layer: null, + isArrowFunction: true, + isStatic: true, + enclosingClassName: null, + enclosingFunctionName: 'App\\format', + usesThis: false, + hasReturnType: false, + paramCount: 0, + cyclomaticComplexity: 1, + lineCount: 1, + ), + ], + ) ); $withNodes = $analysisResultCache->loadAnalysisNodes($fileWithNodes, 'namespace'); @@ -598,8 +630,12 @@ public function testStoreExtractionResultStoresOnePayloadPerFile(): void $this->assertNotNull($withNodes); $this->assertCount(1, $withNodes['classNodes']); + $this->assertCount(1, $withNodes['anonymousClassNodes']); + $this->assertCount(1, $withNodes['functionNodes']); + $this->assertCount(1, $withNodes['anonymousFunctionNodes']); $this->assertNotNull($withoutNodes); $this->assertSame([], $withoutNodes['classNodes']); + $this->assertSame([], $withoutNodes['functionNodes']); } finally { $this->removeTempDirectory($cacheDirectory); } From b2c2fed072217e5c0fec668b95c77b0a41dd9b5a Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 19:46:25 +0700 Subject: [PATCH 027/104] rectify --- tests/Cache/AnalysisResultCacheTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index cec39a00..50d17dfa 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -613,7 +613,6 @@ functionName: 'App\\format', layer: null, isArrowFunction: true, isStatic: true, - enclosingClassName: null, enclosingFunctionName: 'App\\format', usesThis: false, hasReturnType: false, From 62c1406c1dfc6519a86b53d5228462586bc12319 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 21:24:34 +0700 Subject: [PATCH 028/104] perf: skip getMethods() pre-pass, resolve method's class analysis on ClassMethod enter --- src/Analyser/AnalysisNodeCollector.php | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index b72928a3..dba8845c 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -660,24 +660,28 @@ private function startClassLikeAnalysis(ClassLike $classLike): void $this->classLikeAnalysis[$classLikeId] = $classLikeAnalysis; $this->activeClassLikeAnalyses[] = $classLikeAnalysis; - - foreach ($classLike->getMethods() as $classMethod) { - $this->methodClassLikeAnalyses[spl_object_id($classMethod)] = $classLikeAnalysis; - } } + /** + * The owning analysis is the innermost active class-like: an anonymous + * class (null name) starts no analysis, so its methods are not tracked. + */ private function startMethodAnalysis(ClassMethod $classMethod): void { - $methodId = spl_object_id($classMethod); + if ($this->activeClassLikeNames === [] || end($this->activeClassLikeNames) === null) { + return; + } - $analysis = $this->methodClassLikeAnalyses[$methodId] ?? null; + $analysis = end($this->activeClassLikeAnalyses); if (! $analysis instanceof ClassLikeAnalysis) { return; } - $this->activeMethodIds[] = $methodId; + $methodId = spl_object_id($classMethod); + $this->activeMethodIds[] = $methodId; + $this->methodClassLikeAnalyses[$methodId] = $analysis; $analysis->complexityByMethodId[$methodId] = 1; } From 75d98bdb69cc511c5e158196721b18a68f9a7c70 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 21:45:35 +0700 Subject: [PATCH 029/104] reduce rewalk nodes --- src/Analyser/AnalysisNodeCollector.php | 362 +++++++++++++------------ src/Analyser/ClassLikeAnalysis.php | 24 +- 2 files changed, 213 insertions(+), 173 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index dba8845c..c6278068 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -176,6 +176,16 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract List_::class => 'list', ]; + /** + * Class-like member statements collected on enter, see collectMember(). + */ + private const MEMBER_NODES = [ + Property::class => true, + ClassConst::class => true, + TraitUse::class => true, + EnumCase::class => true, + ]; + /** * Every node class enterNode() acts on: the scope-tracking statements, * function-likes, and everything collectNodeAnalysis() records. The @@ -183,25 +193,28 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract * hash lookup lets the large majority of nodes (identifiers, arguments, * scalars, assignments, ...) return before any instanceof check. */ - private const ENTER_NODES = self::COMPLEXITY_BRANCH_NODES + self::LANGUAGE_CONSTRUCT_NODES + [ - Namespace_::class => true, - Use_::class => true, - GroupUse::class => true, - Function_::class => true, - Class_::class => true, - Interface_::class => true, - Trait_::class => true, - Enum_::class => true, - ClassMethod::class => true, - Closure::class => true, - ArrowFunction::class => true, - String_::class => true, - FullyQualified::class => true, - Variable::class => true, - FuncCall::class => true, - Exit_::class => true, - Include_::class => true, - ]; + private const ENTER_NODES = self::COMPLEXITY_BRANCH_NODES + + self::LANGUAGE_CONSTRUCT_NODES + + self::MEMBER_NODES + + [ + Namespace_::class => true, + Use_::class => true, + GroupUse::class => true, + Function_::class => true, + Class_::class => true, + Interface_::class => true, + Trait_::class => true, + Enum_::class => true, + ClassMethod::class => true, + Closure::class => true, + ArrowFunction::class => true, + String_::class => true, + FullyQualified::class => true, + Variable::class => true, + FuncCall::class => true, + Exit_::class => true, + Include_::class => true, + ]; /** * Every node class leaveNode() acts on, see ENTER_NODES. @@ -286,11 +299,14 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract /** @var list */ private array $activeClassLikeAnalyses = []; - /** @var list */ - private array $activeMethodIds = []; - - /** @var array */ - private array $methodClassLikeAnalyses = []; + /** + * Cyclomatic complexity of each tracked method currently being entered, + * innermost last. A branch node increments every entry: a method nested + * through an anonymous class still adds to its enclosing method. + * + * @var list + */ + private array $activeMethodComplexities = []; /** * Names of the class-likes currently being entered, innermost last; an @@ -356,8 +372,7 @@ public function setCurrentFile(string $file): void $this->fileFunctions = []; $this->classLikeAnalysis = []; $this->activeClassLikeAnalyses = []; - $this->activeMethodIds = []; - $this->methodClassLikeAnalyses = []; + $this->activeMethodComplexities = []; $this->activeClassLikeNames = []; $this->activeFunctionNames = []; $this->activeFunctionLikeAnalyses = []; @@ -513,7 +528,13 @@ public function enterNode(Node $node): null } if ($node instanceof ClassMethod) { - $this->startMethodAnalysis($node); + $this->startMethodAnalysis(); + + return null; + } + + if (isset(self::MEMBER_NODES[$node::class])) { + $this->collectMember($node); return null; } @@ -640,8 +661,7 @@ public function afterTraverse(array $nodes): null $this->classLikeAnalysis = []; $this->activeClassLikeAnalyses = []; $this->activeClassLikeScopes = []; - $this->activeMethodIds = []; - $this->methodClassLikeAnalyses = []; + $this->activeMethodComplexities = []; $this->activeClassLikeNames = []; $this->activeFunctionNames = []; $this->activeFunctionLikeAnalyses = []; @@ -654,7 +674,7 @@ public function afterTraverse(array $nodes): null private function startClassLikeAnalysis(ClassLike $classLike): void { $classLikeId = spl_object_id($classLike); - $classLikeAnalysis = new ClassLikeAnalysis(); + $classLikeAnalysis = new ClassLikeAnalysis($classLike instanceof Interface_); $classLikeAnalysis->dependencies = $this->currentNamespaceUses; @@ -663,26 +683,91 @@ private function startClassLikeAnalysis(ClassLike $classLike): void } /** - * The owning analysis is the innermost active class-like: an anonymous - * class (null name) starts no analysis, so its methods are not tracked. + * The analysis of the class-like declaring the member being entered: the + * innermost active class-like. An anonymous class (null name) starts no + * analysis, so its members are not collected. */ - private function startMethodAnalysis(ClassMethod $classMethod): void + private function declaringClassLikeAnalysis(): ?ClassLikeAnalysis { - if ($this->activeClassLikeNames === [] || end($this->activeClassLikeNames) === null) { - return; + if (end($this->activeClassLikeNames) === null) { + return null; } $analysis = end($this->activeClassLikeAnalyses); + return $analysis instanceof ClassLikeAnalysis ? $analysis : null; + } + + private function startMethodAnalysis(): void + { + if ($this->declaringClassLikeAnalysis() instanceof ClassLikeAnalysis) { + $this->activeMethodComplexities[] = 1; + } + } + + /** + * Members other than methods carry no body facts, so they are recorded + * completely the moment the traverser enters them. + */ + private function collectMember(Stmt $stmt): void + { + $analysis = $this->declaringClassLikeAnalysis(); + if (! $analysis instanceof ClassLikeAnalysis) { return; } - $methodId = spl_object_id($classMethod); + if ($stmt instanceof Property) { + $visibility = $this->resolveVisibilityName($stmt); + $hasExplicitVisibility = VisibilityFlagChecker::hasExplicitVisibilityFlag($stmt->flags); + + foreach ($stmt->props as $prop) { + $analysis->properties[] = new PropertyNode( + name: (string) $prop->name, + visibility: $visibility, + hasExplicitVisibility: $hasExplicitVisibility, + line: $prop->getStartLine(), + ); + } + + return; + } + + if ($stmt instanceof ClassConst) { + $visibility = $this->resolveVisibilityName($stmt); + $hasExplicitVisibility = VisibilityFlagChecker::hasExplicitVisibilityFlag($stmt->flags); + + foreach ($stmt->consts as $const) { + $analysis->constants[] = new ConstantNode( + name: (string) $const->name, + visibility: $visibility, + hasExplicitVisibility: $hasExplicitVisibility, + line: $const->getStartLine(), + ); + } + + return; + } - $this->activeMethodIds[] = $methodId; - $this->methodClassLikeAnalyses[$methodId] = $analysis; - $analysis->complexityByMethodId[$methodId] = 1; + if ($stmt instanceof TraitUse) { + if ($analysis->isInterface) { + return; + } + + foreach ($stmt->traits as $trait) { + $analysis->traits[] = $trait->toString(); + } + + return; + } + + if ($stmt instanceof EnumCase) { + $analysis->enumCases[] = new EnumCaseNode( + name: (string) $stmt->name, + line: $stmt->getStartLine(), + value: $this->resolveEnumCaseValue($stmt->expr), + ); + } } /** @@ -714,13 +799,54 @@ private function innermostActiveClassLikeName(): ?string return null; } + /** + * The method's complexity is final once its body has been left, so the + * MethodNode is built here; a constructor's promoted parameters are the + * class's properties. + */ private function finishMethodAnalysis(ClassMethod $classMethod): void { - if (! isset($this->methodClassLikeAnalyses[spl_object_id($classMethod)])) { + $analysis = $this->declaringClassLikeAnalysis(); + + if (! $analysis instanceof ClassLikeAnalysis) { + return; + } + + $cyclomaticComplexity = array_pop($this->activeMethodComplexities); + + $analysis->methods[] = new MethodNode( + name: (string) $classMethod->name, + visibility: $this->resolveVisibilityName($classMethod), + hasReturnType: $classMethod->returnType instanceof Node, + isStatic: $classMethod->isStatic(), + paramCount: count($classMethod->params), + cyclomaticComplexity: $cyclomaticComplexity ?? 1, + lineCount: $this->calculateLineCount($classMethod), + hasExplicitVisibility: VisibilityFlagChecker::hasExplicitVisibilityFlag($classMethod->flags), + line: $classMethod->getStartLine(), + isMagic: $classMethod->isMagic(), + ); + + if ($classMethod->name->toLowerString() !== '__construct') { return; } - array_pop($this->activeMethodIds); + foreach ($classMethod->params as $param) { + if ( + ! $param->isPromoted() + || ! $param->var instanceof Variable + || ! is_string($param->var->name) + ) { + continue; + } + + $analysis->properties[] = new PropertyNode( + name: (string) $param->var->name, + visibility: $this->resolveVisibilityName($param), + hasExplicitVisibility: VisibilityFlagChecker::hasExplicitVisibilityFlag($param->flags), + line: $param->getStartLine(), + ); + } } /** @@ -806,10 +932,12 @@ private function collectNodeAnalysis(Node $node): void $nodeClass = $node::class; if (isset(self::COMPLEXITY_BRANCH_NODES[$nodeClass])) { - foreach ($this->activeMethodIds as $activeMethodId) { - $this->methodClassLikeAnalyses[$activeMethodId]->complexityByMethodId[$activeMethodId]++; + foreach ($this->activeMethodComplexities as &$activeMethodComplexity) { + $activeMethodComplexity++; } + unset($activeMethodComplexity); + $activeFunctionLikeCount = count($this->activeFunctionLikeAnalyses); if ($activeFunctionLikeCount > 0) { @@ -1140,11 +1268,6 @@ private function collectClassLike(ClassLike $classLike): void $implements = $this->collectImplements($classLike); $interfaceExtends = $this->collectInterfaceExtends($classLike); - [$traits, $constants, $properties, $methods, $enumCases] = $this->collectMembers( - $classLike, - $analysis['complexityByMethodId'] - ); - $this->nodes[] = new ClassNode( className: $className, file: $this->currentFile, @@ -1160,17 +1283,17 @@ className: $className, isTrait: $classLike instanceof Trait_, dependencies: $analysis['dependencies'], implements: $implements, - traits: $traits, - methods: $methods, - constants: $constants, - properties: $properties, + traits: $analysis['traits'], + methods: $analysis['methods'], + constants: $analysis['constants'], + properties: $analysis['properties'], functionCalls: $analysis['functionCalls'], superglobals: $analysis['superglobals'], languageConstructs: $analysis['languageConstructs'], layers: $layers, isEnum: $classLike instanceof Enum_, interfaceExtends: $interfaceExtends, - enumCases: $enumCases, + enumCases: $analysis['enumCases'], enumBackingType: $classLike instanceof Enum_ && $classLike->scalarType instanceof Identifier ? $classLike->scalarType->toLowerString() : null, @@ -1273,117 +1396,6 @@ private function resolveFunctionDeclarationName(Function_ $function): string : (string) $function->name; } - /** - * Collect traits, constants, properties, methods, and enum cases in a single - * pass over the class-like statements instead of one loop per member kind. - * - * @param array $complexityByMethodId - * @return array{0: string[], 1: ConstantNode[], 2: PropertyNode[], 3: MethodNode[], 4: EnumCaseNode[]} - */ - private function collectMembers(ClassLike $classLike, array $complexityByMethodId): array - { - $isInterface = $classLike instanceof Interface_; - $traits = []; - $constants = []; - $properties = []; - $methods = []; - $enumCases = []; - - foreach ($classLike->stmts as $stmt) { - if ($stmt instanceof TraitUse) { - if ($isInterface) { - continue; - } - - foreach ($stmt->traits as $trait) { - $traits[] = $trait->toString(); - } - - continue; - } - - if ($stmt instanceof ClassConst) { - $visibility = $this->resolveVisibilityName($stmt); - $hasExplicitVisibility = VisibilityFlagChecker::hasExplicitVisibilityFlag($stmt->flags); - - foreach ($stmt->consts as $const) { - $constants[] = new ConstantNode( - name: (string) $const->name, - visibility: $visibility, - hasExplicitVisibility: $hasExplicitVisibility, - line: $const->getStartLine(), - ); - } - - continue; - } - - if ($stmt instanceof Property) { - $visibility = $this->resolveVisibilityName($stmt); - $hasExplicitVisibility = VisibilityFlagChecker::hasExplicitVisibilityFlag($stmt->flags); - - foreach ($stmt->props as $prop) { - $properties[] = new PropertyNode( - name: (string) $prop->name, - visibility: $visibility, - hasExplicitVisibility: $hasExplicitVisibility, - line: $prop->getStartLine(), - ); - } - - continue; - } - - if ($stmt instanceof EnumCase) { - $enumCases[] = new EnumCaseNode( - name: (string) $stmt->name, - line: $stmt->getStartLine(), - value: $this->resolveEnumCaseValue($stmt->expr), - ); - - continue; - } - - if ($stmt instanceof ClassMethod) { - $methods[] = new MethodNode( - name: (string) $stmt->name, - visibility: $this->resolveVisibilityName($stmt), - hasReturnType: $stmt->returnType instanceof Node, - isStatic: $stmt->isStatic(), - paramCount: count($stmt->params), - cyclomaticComplexity: $complexityByMethodId[spl_object_id($stmt)] ?? 1, - lineCount: $this->calculateLineCount($stmt), - hasExplicitVisibility: VisibilityFlagChecker::hasExplicitVisibilityFlag($stmt->flags), - line: $stmt->getStartLine(), - isMagic: $stmt->isMagic(), - ); - - if ($stmt->name->toLowerString() !== '__construct') { - continue; - } - - foreach ($stmt->params as $param) { - if ( - ! $param->isPromoted() - || ! $param->var instanceof Variable - || ! is_string($param->var->name) - ) { - continue; - } - - $properties[] = new PropertyNode( - name: (string) $param->var->name, - visibility: $this->resolveVisibilityName($param), - hasExplicitVisibility: VisibilityFlagChecker::hasExplicitVisibilityFlag($param->flags), - line: $param->getStartLine(), - ); - } - } - } - - return [$traits, $constants, $properties, $methods, $enumCases]; - } - private function resolveClassName(ClassLike $classLike): string { return isset($classLike->namespacedName) @@ -1397,12 +1409,16 @@ private function resolveClassName(ClassLike $classLike): string * functionCalls: string[], * superglobals: string[], * languageConstructs: string[], - * complexityByMethodId: array + * traits: string[], + * constants: ConstantNode[], + * properties: PropertyNode[], + * methods: MethodNode[], + * enumCases: EnumCaseNode[] * } */ private function collectClassLikeAnalysis(int $classLikeId): array { - $analysis = $this->classLikeAnalysis[$classLikeId] ?? new ClassLikeAnalysis(); + $analysis = $this->classLikeAnalysis[$classLikeId] ?? new ClassLikeAnalysis(false); $functionCalls = []; foreach ($analysis->functionCallNames as $functionCallName) { @@ -1410,11 +1426,15 @@ private function collectClassLikeAnalysis(int $classLikeId): array } return [ - 'dependencies' => array_values(array_unique($analysis->dependencies)), - 'functionCalls' => array_values(array_unique($functionCalls)), - 'superglobals' => array_values(array_unique($analysis->superglobals)), - 'languageConstructs' => array_values(array_unique($analysis->languageConstructs)), - 'complexityByMethodId' => $analysis->complexityByMethodId, + 'dependencies' => array_values(array_unique($analysis->dependencies)), + 'functionCalls' => array_values(array_unique($functionCalls)), + 'superglobals' => array_values(array_unique($analysis->superglobals)), + 'languageConstructs' => array_values(array_unique($analysis->languageConstructs)), + 'traits' => $analysis->traits, + 'constants' => $analysis->constants, + 'properties' => $analysis->properties, + 'methods' => $analysis->methods, + 'enumCases' => $analysis->enumCases, ]; } diff --git a/src/Analyser/ClassLikeAnalysis.php b/src/Analyser/ClassLikeAnalysis.php index e35731d2..8ca820a5 100644 --- a/src/Analyser/ClassLikeAnalysis.php +++ b/src/Analyser/ClassLikeAnalysis.php @@ -7,6 +7,9 @@ use PhpParser\Node\Name; /** + * Facts collected while traversing a named class-like: body-level references + * plus its members, each recorded as the traverser passes the declaring node. + * * @internal */ final class ClassLikeAnalysis @@ -23,6 +26,23 @@ final class ClassLikeAnalysis /** @var string[] */ public array $languageConstructs = []; - /** @var array */ - public array $complexityByMethodId = []; + /** @var string[] */ + public array $traits = []; + + /** @var ConstantNode[] */ + public array $constants = []; + + /** @var PropertyNode[] */ + public array $properties = []; + + /** @var MethodNode[] */ + public array $methods = []; + + /** @var EnumCaseNode[] */ + public array $enumCases = []; + + public function __construct( + public readonly bool $isInterface, + ) { + } } From 9ce78b149242fd69a1f1680dfbdb693b2c313b62 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 21:50:26 +0700 Subject: [PATCH 030/104] handle enum case --- src/Analyser/AnalysisNodeCollector.php | 36 +++++++++++++------- tests/Analyser/AnalysisNodeCollectorTest.php | 18 ++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index c6278068..b18cae60 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -178,12 +178,14 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract /** * Class-like member statements collected on enter, see collectMember(). + * EnumCase is collected on leave instead: its value expression may hold + * class names that the NameResolver only resolves on entering the + * expression's own nodes, after this visitor has entered the case. */ private const MEMBER_NODES = [ Property::class => true, ClassConst::class => true, TraitUse::class => true, - EnumCase::class => true, ]; /** @@ -226,6 +228,7 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract MethodCall::class => true, NullsafeMethodCall::class => true, ClassMethod::class => true, + EnumCase::class => true, Function_::class => true, Class_::class => true, Interface_::class => true, @@ -599,6 +602,12 @@ public function leaveNode(Node $node): null return null; } + if ($node instanceof EnumCase) { + $this->collectEnumCase($node); + + return null; + } + if ($node instanceof Function_) { $this->finishFunctionLikeAnalysis(); array_pop($this->activeFunctionNames); @@ -749,25 +758,26 @@ private function collectMember(Stmt $stmt): void return; } - if ($stmt instanceof TraitUse) { - if ($analysis->isInterface) { - return; - } - + if ($stmt instanceof TraitUse && ! $analysis->isInterface) { foreach ($stmt->traits as $trait) { $analysis->traits[] = $trait->toString(); } + } + } + + private function collectEnumCase(EnumCase $enumCase): void + { + $analysis = $this->declaringClassLikeAnalysis(); + if (! $analysis instanceof ClassLikeAnalysis) { return; } - if ($stmt instanceof EnumCase) { - $analysis->enumCases[] = new EnumCaseNode( - name: (string) $stmt->name, - line: $stmt->getStartLine(), - value: $this->resolveEnumCaseValue($stmt->expr), - ); - } + $analysis->enumCases[] = new EnumCaseNode( + name: (string) $enumCase->name, + line: $enumCase->getStartLine(), + value: $this->resolveEnumCaseValue($enumCase->expr), + ); } /** diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index 91b91dff..b19abad0 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -766,6 +766,24 @@ public function label(): string $this->assertSame(['label'], array_column($classNode->methods, 'name')); } + public function testResolvesImportedClassNameInEnumCaseValue(): void + { + $classNode = $this->collect(<<<'PHP' + assertSame(['Vendor\Foo', 'App\Type'], array_column($classNode->enumCases, 'value')); + } + public function testCollectsIntBackedEnumCaseValues(): void { $classNode = $this->collect( From 6080e566e84e5d40b5bfabdd93938d5467517adc Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 21:57:16 +0700 Subject: [PATCH 031/104] add more test --- tests/Analyser/AnalysisNodeCollectorTest.php | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index b19abad0..8dcfa40c 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -784,6 +784,28 @@ enum Type: string $this->assertSame(['Vendor\Foo', 'App\Type'], array_column($classNode->enumCases, 'value')); } + public function testIgnoresEnumCaseDeclaredInAnonymousClass(): void + { + // php-parser accepts a case in a class body; only PHP's compiler + // rejects it, so the collector must not attribute it to any class. + $classNode = $this->collect(<<<'PHP' + assertSame(['One'], array_column($classNode->enumCases, 'name')); + } + public function testCollectsIntBackedEnumCaseValues(): void { $classNode = $this->collect( From fc20e0fd941a734e9ed302f9157dd98e693ecdf7 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 30 Aug 2026 22:50:46 +0700 Subject: [PATCH 032/104] perf: Remove fflush call from advance method --- src/Analyser/Parallel/WorkerProgressHandler.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Analyser/Parallel/WorkerProgressHandler.php b/src/Analyser/Parallel/WorkerProgressHandler.php index fb80d677..0df022b9 100644 --- a/src/Analyser/Parallel/WorkerProgressHandler.php +++ b/src/Analyser/Parallel/WorkerProgressHandler.php @@ -6,7 +6,6 @@ use Boundwize\StructArmed\Progress\ProgressHandlerInterface; -use function fflush; use function fwrite; final readonly class WorkerProgressHandler implements ProgressHandlerInterface @@ -23,7 +22,6 @@ public function start(int $total): void public function advance(string $file): void { fwrite($this->stream, "\n"); - fflush($this->stream); } public function finish(): void From 395a1c761ee08c9f331a567c6b6e3793db07315b Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 31 Aug 2026 00:19:51 +0700 Subject: [PATCH 033/104] perf: Reduce array_values(array_unique()) on PhpFileFinder::sourcePaths() --- src/Rule/Rules/File/PhpFileFinder.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Rule/Rules/File/PhpFileFinder.php b/src/Rule/Rules/File/PhpFileFinder.php index ee271900..ac31dd12 100644 --- a/src/Rule/Rules/File/PhpFileFinder.php +++ b/src/Rule/Rules/File/PhpFileFinder.php @@ -105,6 +105,10 @@ public function filesFromScope( /** @return list */ private function sourcePaths(string $basePath): array { - return array_values(array_unique($this->sourcePaths ?? $this->psr4PathResolver->paths($basePath))); + if ($this->sourcePaths === null) { + return $this->psr4PathResolver->paths($basePath); + } + + return array_values(array_unique($this->sourcePaths)); } } From 76f55ae77d83038a53f2c570f0b790518e5cadd1 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 31 Aug 2026 06:56:18 +0700 Subject: [PATCH 034/104] perf: directly append violations on RuleViolationCollection::merge() --- src/Rule/RuleViolationCollection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Rule/RuleViolationCollection.php b/src/Rule/RuleViolationCollection.php index 78fb2ea9..cfdcbe38 100644 --- a/src/Rule/RuleViolationCollection.php +++ b/src/Rule/RuleViolationCollection.php @@ -33,7 +33,7 @@ public function add(RuleViolation $ruleViolation): void public function merge(self $other): void { foreach ($other as $violation) { - $this->add($violation); + $this->violations[] = $violation; } } From e8244208a7e0ba4842aac8cfea014cb3009eda30 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 31 Aug 2026 09:28:13 +0700 Subject: [PATCH 035/104] update version screenshot --- docs/assets/no-violation.svg | 2 +- docs/assets/structarmed-showoff.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/assets/no-violation.svg b/docs/assets/no-violation.svg index a2f72eb7..e84a01af 100644 --- a/docs/assets/no-violation.svg +++ b/docs/assets/no-violation.svg @@ -14,7 +14,7 @@ prj-ddd vendor/bin/structarmed analyze - StructArmed 0.16.28 — Architecture Enforcement + StructArmed 0.16.29 — Architecture Enforcement =============================================== diff --git a/docs/assets/structarmed-showoff.svg b/docs/assets/structarmed-showoff.svg index c5a610de..f79bfef2 100644 --- a/docs/assets/structarmed-showoff.svg +++ b/docs/assets/structarmed-showoff.svg @@ -15,7 +15,7 @@ prj-ddd vendor/bin/structarmed analyze - StructArmed 0.16.28 — Architecture Enforcement + StructArmed 0.16.29 — Architecture Enforcement =============================================== From 9ab955dd6dc0cb818de9707103a35031aeb36608 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 31 Aug 2026 09:51:56 +0700 Subject: [PATCH 036/104] Add MustHaveReturnTypeFunctionRule --- docs/available-rules.md | 1 + docs/presets.md | 2 +- src/Preset/Presets/MvcPreset.php | 21 +++ .../MustHaveReturnTypeFunctionRule.php | 43 +++++ tests/Preset/PresetTest.php | 15 ++ ...veReturnTypeFunctionRuleFunctionalTest.php | 147 ++++++++++++++++++ .../MustHaveReturnTypeFunctionRuleTest.php | 87 +++++++++++ 7 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 src/Rule/Rules/Function_/MustHaveReturnTypeFunctionRule.php create mode 100644 tests/Rule/Function_/MustHaveReturnTypeFunctionRuleFunctionalTest.php create mode 100644 tests/Rule/Function_/MustHaveReturnTypeFunctionRuleTest.php diff --git a/docs/available-rules.md b/docs/available-rules.md index 1d156698..3c8e6830 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -105,6 +105,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Function_`. | Rule | Constructor | Checks | |---|---|---| | `MustBeStaticAnonymousFunctionRule` | `new MustBeStaticAnonymousFunctionRule(layer: 'Domain')` | Closures and arrow functions in a layer are declared `static`. Anonymous functions that read `$this` (directly or through a nested closure) are skipped, since a static closure cannot access `$this`. Supports `--fix` by adding the `static` modifier. | +| `MustHaveReturnTypeFunctionRule` | `new MustHaveReturnTypeFunctionRule(layer: 'Helper')` | Named function declarations in a layer declare a return type. | {: .rule-table } ## Layer Rules diff --git a/docs/presets.md b/docs/presets.md index 0d1dc94c..c7429b58 100644 --- a/docs/presets.md +++ b/docs/presets.md @@ -24,7 +24,7 @@ StructArmed ships with presets for common PHP standards and architecture styles. | `Preset::PSR15()` | `*Middleware` classes must implement PSR-15 `MiddlewareInterface`; `*Handler` classes must implement PSR-15 `RequestHandlerInterface`; StructArmed also enforces matching `Middleware`/`Handler` suffixes for implementations of those interfaces | | `Preset::PSR4()` | Verifies configured source paths exist in composer.json `autoload` or `autoload-dev` PSR-4 mappings | | `Preset::DDD()` | Layer isolation, entity/VO/repository/event/service conventions | -| `Preset::MVC()` | Layer isolation, thin controllers, model/view/service rules | +| `Preset::MVC()` | Layer isolation, thin controllers, model/view/service rules, return types for helper functions | | `Preset::YAGNI()` | Speculative-abstraction cleanup: interfaces must be implemented by a class or extended by another interface, abstract classes must be extended, traits must be used, and extended classes that are never instantiated must be abstract — a dependency reference (type hint, `instanceof`, `::class`, static call, a class-name string, ...) also counts as usage within the scanned paths, while only instantiation (`new X`, `new self`/`static`/`parent`, or a constant class expression such as `new (X::class)`) keeps an extended class concrete. All rules support `--fix`, removing the unused declaration or adding the `abstract` modifier | ## Initialize Presets diff --git a/src/Preset/Presets/MvcPreset.php b/src/Preset/Presets/MvcPreset.php index f70e7641..91f990b9 100644 --- a/src/Preset/Presets/MvcPreset.php +++ b/src/Preset/Presets/MvcPreset.php @@ -10,6 +10,7 @@ use Boundwize\StructArmed\Rule\Rules\Class_\ClassNameMustNotHavePrefixRule; use Boundwize\StructArmed\Rule\Rules\Class_\MaxDependencyCountRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule; +use Boundwize\StructArmed\Rule\Rules\Function_\MustHaveReturnTypeFunctionRule; use Boundwize\StructArmed\Rule\Rules\Layer\MayNotDependOnRule; use Boundwize\StructArmed\Rule\Rules\Method\MaxCyclomaticComplexityRule; use Boundwize\StructArmed\Rule\Rules\Method\MaxMethodLengthRule; @@ -96,6 +97,9 @@ public const SERVICE_MUST_HAVE_RETURN_TYPES = 'mvc.service.must_have_return_types'; + // Helper rules + public const HELPER_MUST_HAVE_RETURN_TYPES = 'mvc.helper.must_have_return_types'; + public function __construct( private int $controllerMaxComplexity = 5, private int $controllerMaxMethodLength = 20, @@ -114,6 +118,7 @@ public function apply(Architecture $architecture): void ->applyModelRules($architecture) ->applyViewRules($architecture) ->applyServiceRules($architecture) + ->applyHelperRules($architecture) ->applySafetyRules($architecture); } @@ -134,6 +139,11 @@ private function applyDefaultLayers(Architecture $architecture): self ], 'View' => 'src/View/', 'Service' => 'src/Service/', + 'Helper' => [ + 'src/Helper/', + 'src/Helpers/', + 'app/Helpers/', + ], ]; foreach ($defaultLayers as $layer => $path) { @@ -156,6 +166,7 @@ private function applyDefaultLayerPatterns(Architecture $architecture): self 'Model' => '/(?:^|\\\\)Models?(?:\\\\|$)/', 'View' => '/(?:^|\\\\)Views?(?:\\\\|$)/', 'Service' => '/(?:^|\\\\)Services?(?:\\\\|$)/', + 'Helper' => '/(?:^|\\\\)Helpers?(?:\\\\|$)/', ]; $testNamespaceOrClassPattern = '/(?:^|\\\\)[^\\\\]*Tests?(?:\\\\|$)/'; @@ -357,6 +368,16 @@ private function applyServiceRules(Architecture $architecture): self return $this; } + private function applyHelperRules(Architecture $architecture): self + { + $architecture->rule( + self::HELPER_MUST_HAVE_RETURN_TYPES, + new MustHaveReturnTypeFunctionRule(layer: 'Helper') + ); + + return $this; + } + private function applySafetyRules(Architecture $architecture): self { foreach (['Controller', 'Model', 'View', 'Service'] as $layer) { diff --git a/src/Rule/Rules/Function_/MustHaveReturnTypeFunctionRule.php b/src/Rule/Rules/Function_/MustHaveReturnTypeFunctionRule.php new file mode 100644 index 00000000..2ec7be1e --- /dev/null +++ b/src/Rule/Rules/Function_/MustHaveReturnTypeFunctionRule.php @@ -0,0 +1,43 @@ +isInLayer($this->layer); + } + + public function evaluateFunction(FunctionNode $functionNode): ?RuleViolation + { + if ($functionNode->hasReturnType) { + return null; + } + + return new RuleViolation( + message: sprintf( + 'Function [%s()] must declare a return type', + $functionNode->functionName + ), + file: $functionNode->file, + line: $functionNode->line, + className: $functionNode->functionName, + layer: $functionNode->layer, + functionName: $functionNode->functionName, + ); + } +} diff --git a/tests/Preset/PresetTest.php b/tests/Preset/PresetTest.php index 067698f2..55488ae6 100644 --- a/tests/Preset/PresetTest.php +++ b/tests/Preset/PresetTest.php @@ -387,6 +387,11 @@ public function testMvcPresetRegistersAllRules(): void ], 'View' => 'src/View/', 'Service' => 'src/Service/', + 'Helper' => [ + 'src/Helper/', + 'src/Helpers/', + 'app/Helpers/', + ], ], $architecture->getLayers() ); @@ -407,6 +412,10 @@ public function testMvcPresetRegistersAllRules(): void 'pattern' => '/(?:^|\\\\)Services?(?:\\\\|$)/', 'excludePattern' => '/(?:^|\\\\)[^\\\\]*Tests?(?:\\\\|$)/', ], + 'Helper' => [ + 'pattern' => '/(?:^|\\\\)Helpers?(?:\\\\|$)/', + 'excludePattern' => '/(?:^|\\\\)[^\\\\]*Tests?(?:\\\\|$)/', + ], ], $architecture->getLayerPatterns()); $rules = $architecture->getRules(); @@ -416,6 +425,7 @@ public function testMvcPresetRegistersAllRules(): void $this->assertArrayHasKey(MvcPreset::MODEL_MUST_HAVE_RETURN_TYPES, $rules); $this->assertArrayHasKey(MvcPreset::VIEW_NO_SUPERGLOBALS, $rules); $this->assertArrayHasKey(MvcPreset::SERVICE_MUST_HAVE_RETURN_TYPES, $rules); + $this->assertArrayHasKey(MvcPreset::HELPER_MUST_HAVE_RETURN_TYPES, $rules); $this->assertArrayHasKey('mvc.safety.controller_no_dd', $rules); $this->assertArrayHasKey('mvc.safety.service_no_exit', $rules); } @@ -438,6 +448,11 @@ public function testMvcPresetDoesNotReplaceConfiguredLayersOrPatterns(): void ], 'View' => 'src/View/', 'Service' => 'src/Service/', + 'Helper' => [ + 'src/Helper/', + 'src/Helpers/', + 'app/Helpers/', + ], ], $architecture->getLayers() ); diff --git a/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleFunctionalTest.php b/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleFunctionalTest.php new file mode 100644 index 00000000..fb598ad5 --- /dev/null +++ b/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleFunctionalTest.php @@ -0,0 +1,147 @@ +makeTempProject([ + 'src/Helper/functions.php' => <<<'PHP' + analyse($basePath)->forRule('helper.must_have_return_type'); + + $this->assertCount(2, $violations); + $this->assertStringContainsString('App\Helper\format_price()', $violations[0]->message); + $this->assertStringContainsString('App\Helper\format_date()', $violations[1]->message); + } + + public function testPassesWhenAllFunctionsDeclareReturnTypes(): void + { + $basePath = $this->makeTempProject([ + 'src/Helper/functions.php' => <<<'PHP' + analyse($basePath)->forRule('helper.must_have_return_type'); + + $this->assertCount(0, $violations); + } + + public function testMvcPresetFlagsUntypedHelperFunctions(): void + { + $basePath = $this->makeTempProject([ + 'src/Helper/functions.php' => <<<'PHP' + apply($architecture); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule(MvcPreset::HELPER_MUST_HAVE_RETURN_TYPES); + + $this->assertCount(2, $violations); + $this->assertStringContainsString('App\Helper\format_price()', $violations[0]->message); + $this->assertStringContainsString('App\Helper\format_date()', $violations[1]->message); + } + + private function analyse(string $basePath): RuleViolationCollection + { + $architecture = Architecture::define() + ->layer('Helper', 'src/Helper/') + ->rule('helper.must_have_return_type', new MustHaveReturnTypeFunctionRule('Helper')); + + return (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()); + } + + /** @param array $files */ + private function makeTempProject(array $files): string + { + $basePath = $this->makeTemporaryDirectory('structarmed-must-have-return-type-function'); + + foreach ($files as $file => $contents) { + $path = $basePath . '/' . $file; + + if (! is_dir(dirname($path))) { + mkdir(dirname($path), 0777, true); + } + + file_put_contents($path, $contents); + } + + return $basePath; + } +} diff --git a/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleTest.php b/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleTest.php new file mode 100644 index 00000000..f69c95b0 --- /dev/null +++ b/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleTest.php @@ -0,0 +1,87 @@ +makeNode(hasReturnType: true); + + $this->assertNotInstanceOf( + RuleViolation::class, + $mustHaveReturnTypeFunctionRule->evaluateFunction($functionNode) + ); + } + + public function testViolatesWhenFunctionMissingReturnType(): void + { + $mustHaveReturnTypeFunctionRule = new MustHaveReturnTypeFunctionRule(layer: 'Helper'); + $functionNode = $this->makeNode(hasReturnType: false); + + $violation = $mustHaveReturnTypeFunctionRule->evaluateFunction($functionNode); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertStringContainsString('App\\Helper\\format_price()', $violation->message); + $this->assertSame('App\\Helper\\format_price', $violation->functionName); + $this->assertSame('Helper', $violation->layer); + } + + public function testAppliesToMatchingLayer(): void + { + $mustHaveReturnTypeFunctionRule = new MustHaveReturnTypeFunctionRule(layer: 'Helper'); + + $this->assertTrue($mustHaveReturnTypeFunctionRule->appliesToFunction($this->makeNode())); + } + + public function testDoesNotApplyToWrongLayer(): void + { + $mustHaveReturnTypeFunctionRule = new MustHaveReturnTypeFunctionRule(layer: 'Helper'); + $functionNode = $this->makeNode(layer: 'Controller'); + + $this->assertFalse($mustHaveReturnTypeFunctionRule->appliesToFunction($functionNode)); + } + + public function testSingleRuleInstanceReportsOneViolationPerFunction(): void + { + // One FunctionRuleInterface instance is evaluated once per function + // node, so multiple functions yield multiple independent violations. + $mustHaveReturnTypeFunctionRule = new MustHaveReturnTypeFunctionRule(layer: 'Helper'); + + $firstViolation = $mustHaveReturnTypeFunctionRule->evaluateFunction( + $this->makeNode(functionName: 'App\\Helper\\format_price') + ); + $secondViolation = $mustHaveReturnTypeFunctionRule->evaluateFunction( + $this->makeNode(functionName: 'App\\Helper\\format_date') + ); + + $this->assertInstanceOf(RuleViolation::class, $firstViolation); + $this->assertInstanceOf(RuleViolation::class, $secondViolation); + $this->assertStringContainsString('format_price', $firstViolation->message); + $this->assertStringContainsString('format_date', $secondViolation->message); + } +} From 5808fa52e52f5ed4cf9f7b90a2676b75e84d971d Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 31 Aug 2026 18:35:58 +0700 Subject: [PATCH 037/104] Register MayNotExtendClassRule to DDD preset --- docs/presets.md | 2 +- src/Preset/Presets/DddPreset.php | 12 +++++++++ tests/Analyser/AnalyserTest.php | 43 ++++++++++++++++++++++++++++++++ tests/Preset/PresetTest.php | 5 ++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/docs/presets.md b/docs/presets.md index c7429b58..efea35ef 100644 --- a/docs/presets.md +++ b/docs/presets.md @@ -23,7 +23,7 @@ StructArmed ships with presets for common PHP standards and architecture styles. | `Preset::PSR12()` | Extends PSR-1: all methods, constants, and properties must declare explicit visibility | | `Preset::PSR15()` | `*Middleware` classes must implement PSR-15 `MiddlewareInterface`; `*Handler` classes must implement PSR-15 `RequestHandlerInterface`; StructArmed also enforces matching `Middleware`/`Handler` suffixes for implementations of those interfaces | | `Preset::PSR4()` | Verifies configured source paths exist in composer.json `autoload` or `autoload-dev` PSR-4 mappings | -| `Preset::DDD()` | Layer isolation, entity/VO/repository/event/service conventions | +| `Preset::DDD()` | Layer isolation, entity/VO/repository/event/service conventions, including keeping Doctrine ORM repository inheritance out of the Domain layer | | `Preset::MVC()` | Layer isolation, thin controllers, model/view/service rules, return types for helper functions | | `Preset::YAGNI()` | Speculative-abstraction cleanup: interfaces must be implemented by a class or extended by another interface, abstract classes must be extended, traits must be used, and extended classes that are never instantiated must be abstract — a dependency reference (type hint, `instanceof`, `::class`, static call, a class-name string, ...) also counts as usage within the scanned paths, while only instantiation (`new X`, `new self`/`static`/`parent`, or a constant class expression such as `new (X::class)`) keeps an extended class concrete. All rules support `--fix`, removing the unused declaration or adding the `abstract` modifier | diff --git a/src/Preset/Presets/DddPreset.php b/src/Preset/Presets/DddPreset.php index 14dd90a6..2ba49ded 100644 --- a/src/Preset/Presets/DddPreset.php +++ b/src/Preset/Presets/DddPreset.php @@ -6,6 +6,7 @@ use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Preset\PresetInterface; +use Boundwize\StructArmed\Rule\Rules\Class_\MayNotExtendClassRule; use Boundwize\StructArmed\Rule\Rules\Class_\MayNotImplementInterfaceRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeInterfaceRule; @@ -52,6 +53,9 @@ public const REPOSITORY_IMPL_IN_INFRASTRUCTURE = 'ddd.repository.implementation_in_infrastructure'; + public const DOMAIN_MUST_NOT_EXTEND_DOCTRINE_ENTITY_REPOSITORY = + 'ddd.repository.domain_must_not_extend_doctrine_entity_repository'; + // Service rules public const DOMAIN_SERVICE_IN_DOMAIN = 'ddd.service.domain_service_in_domain'; @@ -173,6 +177,14 @@ classNamePattern: '/ValueObject$/' private function applyRepositoryRules(Architecture $architecture): self { + $architecture->rule( + self::DOMAIN_MUST_NOT_EXTEND_DOCTRINE_ENTITY_REPOSITORY, + new MayNotExtendClassRule( + layer: 'Domain', + class: 'Doctrine\\ORM\\EntityRepository' + ) + ); + $architecture->rule( self::REPOSITORY_MUST_BE_INTERFACE, new MustBeInterfaceRule(layer: 'Domain', classNamePattern: '/Repository$/') diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index 1bbdf363..ce7432d5 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -16,6 +16,7 @@ use Boundwize\StructArmed\File\PhpFileCollector; use Boundwize\StructArmed\File\SkipPathMatcher; use Boundwize\StructArmed\Preset\Preset; +use Boundwize\StructArmed\Preset\Presets\DddPreset; use Boundwize\StructArmed\Preset\Presets\MvcPreset; use Boundwize\StructArmed\Preset\Presets\Psr12Preset; use Boundwize\StructArmed\Preset\Presets\Psr15Preset; @@ -418,6 +419,48 @@ public function testAnalyserDetectsViolationsInBadCode(): void $this->assertTrue($ruleViolationCollection->hasViolations()); } + public function testDddPresetRejectsDoctrineEntityRepositoryInheritanceOnlyInDomain(): void + { + $basePath = $this->makeTempProject([ + 'src/Domain/Order/OrderStore.php' => <<<'PHP' + <<<'PHP' + analyse( + Architecture::define()->withPreset(Preset::DDD()), + analyserOptions: AnalyserOptions::sequential(), + ) + ->forRule(DddPreset::DOMAIN_MUST_NOT_EXTEND_DOCTRINE_ENTITY_REPOSITORY); + + $this->assertCount(1, $violations); + $this->assertSame('App\\Domain\\Order\\OrderStore', $violations[0]->className); + $this->assertSame( + 'Class [App\\Domain\\Order\\OrderStore] must not extend class [Doctrine\\ORM\\EntityRepository]', + $violations[0]->message + ); + } + public function testAnalyserCollectsClassNodesWithSequentialRunner(): void { $basePath = $this->makeTempProject([ diff --git a/tests/Preset/PresetTest.php b/tests/Preset/PresetTest.php index 55488ae6..b71b95dc 100644 --- a/tests/Preset/PresetTest.php +++ b/tests/Preset/PresetTest.php @@ -15,6 +15,7 @@ use Boundwize\StructArmed\Preset\Presets\ResolvesSourceLayerNameTrait; use Boundwize\StructArmed\Preset\Presets\YagniPreset; use Boundwize\StructArmed\Rule\Rules\Class_\ExtendedClassMustBeAbstractOrInstantiatedRule; +use Boundwize\StructArmed\Rule\Rules\Class_\MayNotExtendClassRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeUsedAbstractClassRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeUsedInterfaceRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeUsedTraitRule; @@ -218,6 +219,10 @@ public function testDddPresetRegistersAllDefaultRules(): void $this->assertArrayHasKey(DddPreset::VALUE_OBJECT_MUST_BE_FINAL, $rules); $this->assertArrayHasKey(DddPreset::EVENT_MUST_BE_FINAL, $rules); $this->assertArrayHasKey(DddPreset::DOMAIN_NO_JSON_SERIALIZABLE, $rules); + $this->assertInstanceOf( + MayNotExtendClassRule::class, + $rules[DddPreset::DOMAIN_MUST_NOT_EXTEND_DOCTRINE_ENTITY_REPOSITORY] ?? null + ); $this->assertArrayHasKey('ddd.safety.domain_no_dd', $rules); $this->assertArrayHasKey('ddd.safety.application_no_exit', $rules); } From 5b96dd38d4d9c3dbbd6d3e85ff192039c8c1a3b1 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 31 Aug 2026 18:38:26 +0700 Subject: [PATCH 038/104] fix conflict version --- docs/assets/structarmed-showoff.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/assets/structarmed-showoff.svg b/docs/assets/structarmed-showoff.svg index f79bfef2..509164a7 100644 --- a/docs/assets/structarmed-showoff.svg +++ b/docs/assets/structarmed-showoff.svg @@ -15,7 +15,7 @@ prj-ddd vendor/bin/structarmed analyze - StructArmed 0.16.29 — Architecture Enforcement + StructArmed 0.16.30 — Architecture Enforcement =============================================== From 60cb365ddc07a59e90c096c0e7489e4798b2b324 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 1 Sep 2026 14:18:01 +0700 Subject: [PATCH 039/104] refactor: Unify rule interfaces under shared appliesTo()/evaluate() method names with a single evaluation loop --- src/Analyser/Analyser.php | 171 +++++++----------- src/Rule/AnonymousFunctionRuleInterface.php | 9 +- src/Rule/FunctionRuleInterface.php | 9 +- .../MustBeStaticAnonymousFunctionRule.php | 4 +- .../MustHaveReturnTypeFunctionRule.php | 4 +- tests/Analyser/AnalyserTest.php | 48 ++--- .../MustBeStaticAnonymousFunctionRuleTest.php | 14 +- .../MustHaveReturnTypeFunctionRuleTest.php | 12 +- 8 files changed, 116 insertions(+), 155 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 2e5f6b60..d93aab83 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -82,9 +82,7 @@ public function analyse( $projectRuleViolations = []; $fileAnalysisRules = []; - $classRules = []; - $functionRules = []; - $anonymousFunctionRules = []; + $nodeRules = []; $layerAwareRules = []; $hasExtendedClassAwareRule = false; $hasUsedInterfaceAwareRule = false; @@ -95,16 +93,12 @@ public function analyse( continue; } - if ($rule instanceof RuleInterface) { - $classRules[$key] = $rule; - } - - if ($rule instanceof FunctionRuleInterface) { - $functionRules[$key] = $rule; - } - - if ($rule instanceof AnonymousFunctionRuleInterface) { - $anonymousFunctionRules[$key] = $rule; + if ( + $rule instanceof RuleInterface + || $rule instanceof FunctionRuleInterface + || $rule instanceof AnonymousFunctionRuleInterface + ) { + $nodeRules[$key] = $rule; } if ($rule instanceof LayerAwareRuleInterface) { @@ -230,10 +224,7 @@ functionName: $violation->functionName, } $globalSkipPathMatcher = SkipPathMatcher::compile($this->basePath, $globalSkipPaths); - $ruleSkipMatchers = $this->ruleSkipMatchers( - $classRules + $functionRules + $anonymousFunctionRules, - $ruleSkipPaths - ); + $ruleSkipMatchers = $this->ruleSkipMatchers($nodeRules, $ruleSkipPaths); $rulesetSkipPaths = $architecture->getRulesetSkipPaths(); $rulesetSkipPathMatcher = SkipPathMatcher::compile($this->basePath, $rulesetSkipPaths); $rulesetViolationCollection = new RuleViolationCollection(); @@ -248,56 +239,24 @@ functionName: $violation->functionName, $resolvedInheritedDependencies = []; - foreach ($layerAwareRules as $rule) { - $rule->injectClassNodeMap($classDependencyMaps['classNodeMap']); + foreach ($layerAwareRules as $layerAwareRule) { + $layerAwareRule->injectClassNodeMap($classDependencyMaps['classNodeMap']); } - foreach ($classNodes as $classNode) { - if ($globalSkipPathMatcher->isSkipped($classNode->file)) { - continue; - } - - foreach ($classRules as $key => $rule) { - if (isset($ruleSkipMatchers[$key]) && $ruleSkipMatchers[$key]->isSkipped($classNode->file)) { - continue; - } - - if (! $rule->appliesTo($classNode)) { - continue; - } - - if ($rule instanceof MultipleRuleViolationInterface) { - $violations = $rule->evaluateAll($classNode); - } else { - $violation = $rule->evaluate($classNode); - if (! $violation instanceof RuleViolation) { - continue; - } - - $violations = [$violation]; - } - - $isFixable = $rule instanceof FixableInterface; - - foreach ($violations as $violation) { - // Inject the rule key into the violation - $ruleViolationCollection->add(new RuleViolation( - message: $violation->message, - file: $violation->file, - line: $violation->line, - className: $violation->className, - layer: $violation->layer, - ruleKey: $key, - fixable: $isFixable, - methodName: $violation->methodName, - constantName: $violation->constantName, - propertyName: $violation->propertyName, - functionName: $violation->functionName, - )); - } - } + // Function-likes are not part of the class hierarchy, so they take no + // part in the declarative ruleset below; a rule only sees the node + // kind whose interface it implements. + $this->evaluateNodeRules( + [...$classNodes, ...$extractionResult->functionNodes, ...$extractionResult->anonymousFunctionNodes], + $nodeRules, + $globalSkipPathMatcher, + $ruleSkipMatchers, + $ruleViolationCollection + ); - if (! $hasRuleset) { + // Declarative ruleset dependency checks, per class node. + foreach ($hasRuleset ? $classNodes : [] as $classNode) { + if ($globalSkipPathMatcher->isSkipped($classNode->file)) { continue; } @@ -385,58 +344,68 @@ className: $classNode->className, } } - // Function-likes are not part of the class hierarchy, so they take no - // part in the declarative ruleset; only rules that opt in see them. - foreach ($extractionResult->functionNodes as $functionNode) { - if ($globalSkipPathMatcher->isSkipped($functionNode->file)) { + $ruleViolationCollection->merge($rulesetViolationCollection); + + return $ruleViolationCollection; + } + + /** + * Evaluates rules against nodes of the matching kind in a single loop: + * all three rule interfaces share the appliesTo()/evaluate() method + * names, and a rule only receives the node kind whose interface it + * implements. + * + * @param list $nodes + * @param array $rules + * @param array $ruleSkipMatchers + */ + private function evaluateNodeRules( + array $nodes, + array $rules, + SkipPathMatcher $globalSkipPathMatcher, + array $ruleSkipMatchers, + RuleViolationCollection $ruleViolationCollection + ): void { + foreach ($nodes as $node) { + if ($globalSkipPathMatcher->isSkipped($node->file)) { continue; } - foreach ($functionRules as $key => $rule) { - if (isset($ruleSkipMatchers[$key]) && $ruleSkipMatchers[$key]->isSkipped($functionNode->file)) { - continue; - } + foreach ($rules as $key => $rule) { + $ruleHandlesNode = match (true) { + $node instanceof ClassNode => $rule instanceof RuleInterface, + $node instanceof FunctionNode => $rule instanceof FunctionRuleInterface, + default => $rule instanceof AnonymousFunctionRuleInterface, + }; - if (! $rule->appliesToFunction($functionNode)) { + if (! $ruleHandlesNode) { continue; } - $violation = $rule->evaluateFunction($functionNode); - - if ($violation instanceof RuleViolation) { - $ruleViolationCollection->add($this->withRuleKey($violation, $key, $rule)); - } - } - } - - foreach ($extractionResult->anonymousFunctionNodes as $anonymousFunctionNode) { - if ($globalSkipPathMatcher->isSkipped($anonymousFunctionNode->file)) { - continue; - } - - foreach ($anonymousFunctionRules as $key => $rule) { - if ( - isset($ruleSkipMatchers[$key]) - && $ruleSkipMatchers[$key]->isSkipped($anonymousFunctionNode->file) - ) { + if (isset($ruleSkipMatchers[$key]) && $ruleSkipMatchers[$key]->isSkipped($node->file)) { continue; } - if (! $rule->appliesToAnonymousFunction($anonymousFunctionNode)) { + if (! $rule->appliesTo($node)) { continue; } - $violation = $rule->evaluateAnonymousFunction($anonymousFunctionNode); + if ($rule instanceof MultipleRuleViolationInterface && $node instanceof ClassNode) { + $violations = $rule->evaluateAll($node); + } else { + $violation = $rule->evaluate($node); + if (! $violation instanceof RuleViolation) { + continue; + } + + $violations = [$violation]; + } - if ($violation instanceof RuleViolation) { + foreach ($violations as $violation) { $ruleViolationCollection->add($this->withRuleKey($violation, $key, $rule)); } } } - - $ruleViolationCollection->merge($rulesetViolationCollection); - - return $ruleViolationCollection; } private function withRuleKey(RuleViolation $ruleViolation, string $key, object $rule): RuleViolation @@ -563,16 +532,16 @@ private function isSourceSynthesised(Architecture $architecture): bool } /** - * @param array $classRules + * @param array $nodeRules * @param array> $ruleSkipPaths * @return array */ - private function ruleSkipMatchers(array $classRules, array $ruleSkipPaths): array + private function ruleSkipMatchers(array $nodeRules, array $ruleSkipPaths): array { $ruleSkipMatchers = []; foreach ($ruleSkipPaths as $key => $skipPaths) { - if (! isset($classRules[$key]) || $skipPaths === []) { + if (! isset($nodeRules[$key]) || $skipPaths === []) { continue; } diff --git a/src/Rule/AnonymousFunctionRuleInterface.php b/src/Rule/AnonymousFunctionRuleInterface.php index d2119fbd..83706df9 100644 --- a/src/Rule/AnonymousFunctionRuleInterface.php +++ b/src/Rule/AnonymousFunctionRuleInterface.php @@ -8,8 +8,9 @@ /** * A rule evaluated against every closure and arrow function in the scanned - * paths, wherever it is declared. The method names differ from - * {@see RuleInterface} so one rule class can implement both. + * paths, wherever it is declared. The method names mirror {@see RuleInterface}; + * a rule class implementing more than one rule interface must widen the + * parameter to a union type and branch on the node type. */ interface AnonymousFunctionRuleInterface { @@ -17,11 +18,11 @@ interface AnonymousFunctionRuleInterface * Whether this rule applies to the given AnonymousFunctionNode at all. * Allows rules to skip nodes outside their scope. */ - public function appliesToAnonymousFunction(AnonymousFunctionNode $anonymousFunctionNode): bool; + public function appliesTo(AnonymousFunctionNode $anonymousFunctionNode): bool; /** * Evaluate this rule against an AnonymousFunctionNode. * Returns a RuleViolation if the rule is violated, null if it passes. */ - public function evaluateAnonymousFunction(AnonymousFunctionNode $anonymousFunctionNode): ?RuleViolation; + public function evaluate(AnonymousFunctionNode $anonymousFunctionNode): ?RuleViolation; } diff --git a/src/Rule/FunctionRuleInterface.php b/src/Rule/FunctionRuleInterface.php index cce24267..480bd0ab 100644 --- a/src/Rule/FunctionRuleInterface.php +++ b/src/Rule/FunctionRuleInterface.php @@ -8,8 +8,9 @@ /** * A rule evaluated against every named function declaration in the scanned - * paths. The method names differ from {@see RuleInterface} so one rule class - * can implement both and check classes and functions alike. + * paths. The method names mirror {@see RuleInterface}; a rule class + * implementing more than one rule interface must widen the parameter to a + * union type and branch on the node type. */ interface FunctionRuleInterface { @@ -17,11 +18,11 @@ interface FunctionRuleInterface * Whether this rule applies to the given FunctionNode at all. * Allows rules to skip nodes outside their scope. */ - public function appliesToFunction(FunctionNode $functionNode): bool; + public function appliesTo(FunctionNode $functionNode): bool; /** * Evaluate this rule against a FunctionNode. * Returns a RuleViolation if the rule is violated, null if it passes. */ - public function evaluateFunction(FunctionNode $functionNode): ?RuleViolation; + public function evaluate(FunctionNode $functionNode): ?RuleViolation; } diff --git a/src/Rule/Rules/Function_/MustBeStaticAnonymousFunctionRule.php b/src/Rule/Rules/Function_/MustBeStaticAnonymousFunctionRule.php index bdadbed2..672f588b 100644 --- a/src/Rule/Rules/Function_/MustBeStaticAnonymousFunctionRule.php +++ b/src/Rule/Rules/Function_/MustBeStaticAnonymousFunctionRule.php @@ -24,12 +24,12 @@ public function __construct( ) { } - public function appliesToAnonymousFunction(AnonymousFunctionNode $anonymousFunctionNode): bool + public function appliesTo(AnonymousFunctionNode $anonymousFunctionNode): bool { return $anonymousFunctionNode->isInLayer($this->layer); } - public function evaluateAnonymousFunction(AnonymousFunctionNode $anonymousFunctionNode): ?RuleViolation + public function evaluate(AnonymousFunctionNode $anonymousFunctionNode): ?RuleViolation { // A closure reading `$this` cannot be static: PHP raises an error // when a static closure accesses `$this`. diff --git a/src/Rule/Rules/Function_/MustHaveReturnTypeFunctionRule.php b/src/Rule/Rules/Function_/MustHaveReturnTypeFunctionRule.php index 2ec7be1e..22c40fd4 100644 --- a/src/Rule/Rules/Function_/MustHaveReturnTypeFunctionRule.php +++ b/src/Rule/Rules/Function_/MustHaveReturnTypeFunctionRule.php @@ -17,12 +17,12 @@ public function __construct( ) { } - public function appliesToFunction(FunctionNode $functionNode): bool + public function appliesTo(FunctionNode $functionNode): bool { return $functionNode->isInLayer($this->layer); } - public function evaluateFunction(FunctionNode $functionNode): ?RuleViolation + public function evaluate(FunctionNode $functionNode): ?RuleViolation { if ($functionNode->hasReturnType) { return null; diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index ce7432d5..0b675014 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -75,46 +75,36 @@ final class AnalyserTest extends TestCase private function makeNoSuperglobalsInFunctionsRule(): FunctionRuleInterface&AnonymousFunctionRuleInterface { return new class implements FunctionRuleInterface, AnonymousFunctionRuleInterface { - public function appliesToFunction(FunctionNode $functionNode): bool + public function appliesTo(FunctionNode|AnonymousFunctionNode $node): bool { - return $functionNode->isInLayer('Source'); + return $node->isInLayer('Source'); } - public function evaluateFunction(FunctionNode $functionNode): ?RuleViolation + public function evaluate(FunctionNode|AnonymousFunctionNode $node): ?RuleViolation { - if (! $functionNode->accessesSuperglobals()) { + if (! $node->accessesSuperglobals()) { return null; } - return new RuleViolation( - message: 'Function [' . $functionNode->functionName . '] must not access superglobals', - file: $functionNode->file, - line: $functionNode->line, - className: $functionNode->functionName, - layer: $functionNode->layer, - functionName: $functionNode->functionName, - ); - } - - public function appliesToAnonymousFunction(AnonymousFunctionNode $anonymousFunctionNode): bool - { - return $anonymousFunctionNode->isInLayer('Source'); - } - - public function evaluateAnonymousFunction(AnonymousFunctionNode $anonymousFunctionNode): ?RuleViolation - { - if (! $anonymousFunctionNode->accessesSuperglobals()) { - return null; + if ($node instanceof FunctionNode) { + return new RuleViolation( + message: 'Function [' . $node->functionName . '] must not access superglobals', + file: $node->file, + line: $node->line, + className: $node->functionName, + layer: $node->layer, + functionName: $node->functionName, + ); } return new RuleViolation( - message: $anonymousFunctionNode->getType() . ' in [' - . $anonymousFunctionNode->enclosingScopeName() + message: $node->getType() . ' in [' + . $node->enclosingScopeName() . '] must not access superglobals', - file: $anonymousFunctionNode->file, - line: $anonymousFunctionNode->line, - className: $anonymousFunctionNode->enclosingScopeName(), - layer: $anonymousFunctionNode->layer, + file: $node->file, + line: $node->line, + className: $node->enclosingScopeName(), + layer: $node->layer, ); } }; diff --git a/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleTest.php b/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleTest.php index 6dafba97..ad0c1c3a 100644 --- a/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleTest.php +++ b/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleTest.php @@ -38,12 +38,12 @@ public function testAppliesOnlyToConfiguredLayer(): void { $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); - $this->assertTrue($mustBeStaticAnonymousFunctionRule->appliesToAnonymousFunction($this->makeNode())); + $this->assertTrue($mustBeStaticAnonymousFunctionRule->appliesTo($this->makeNode())); $this->assertFalse( - $mustBeStaticAnonymousFunctionRule->appliesToAnonymousFunction($this->makeNode(layer: 'Infrastructure')) + $mustBeStaticAnonymousFunctionRule->appliesTo($this->makeNode(layer: 'Infrastructure')) ); $this->assertFalse( - $mustBeStaticAnonymousFunctionRule->appliesToAnonymousFunction($this->makeNode(layer: null)) + $mustBeStaticAnonymousFunctionRule->appliesTo($this->makeNode(layer: null)) ); } @@ -53,7 +53,7 @@ public function testPassesWhenAlreadyStatic(): void $this->assertNotInstanceOf( RuleViolation::class, - $mustBeStaticAnonymousFunctionRule->evaluateAnonymousFunction($this->makeNode(isStatic: true)) + $mustBeStaticAnonymousFunctionRule->evaluate($this->makeNode(isStatic: true)) ); } @@ -63,14 +63,14 @@ public function testPassesWhenClosureUsesThis(): void $this->assertNotInstanceOf( RuleViolation::class, - $mustBeStaticAnonymousFunctionRule->evaluateAnonymousFunction($this->makeNode(usesThis: true)) + $mustBeStaticAnonymousFunctionRule->evaluate($this->makeNode(usesThis: true)) ); } public function testViolatesForNonStaticClosure(): void { $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); - $violation = $mustBeStaticAnonymousFunctionRule->evaluateAnonymousFunction( + $violation = $mustBeStaticAnonymousFunctionRule->evaluate( $this->makeNode() ); @@ -85,7 +85,7 @@ public function testViolatesForNonStaticClosure(): void public function testViolatesForNonStaticArrowFunctionAtFileScope(): void { $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); - $violation = $mustBeStaticAnonymousFunctionRule->evaluateAnonymousFunction( + $violation = $mustBeStaticAnonymousFunctionRule->evaluate( $this->makeNode(isArrowFunction: true, enclosingClassName: null) ); diff --git a/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleTest.php b/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleTest.php index f69c95b0..e8727af4 100644 --- a/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleTest.php +++ b/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleTest.php @@ -34,7 +34,7 @@ public function testPassesWhenFunctionHasReturnType(): void $this->assertNotInstanceOf( RuleViolation::class, - $mustHaveReturnTypeFunctionRule->evaluateFunction($functionNode) + $mustHaveReturnTypeFunctionRule->evaluate($functionNode) ); } @@ -43,7 +43,7 @@ public function testViolatesWhenFunctionMissingReturnType(): void $mustHaveReturnTypeFunctionRule = new MustHaveReturnTypeFunctionRule(layer: 'Helper'); $functionNode = $this->makeNode(hasReturnType: false); - $violation = $mustHaveReturnTypeFunctionRule->evaluateFunction($functionNode); + $violation = $mustHaveReturnTypeFunctionRule->evaluate($functionNode); $this->assertInstanceOf(RuleViolation::class, $violation); $this->assertStringContainsString('App\\Helper\\format_price()', $violation->message); @@ -55,7 +55,7 @@ public function testAppliesToMatchingLayer(): void { $mustHaveReturnTypeFunctionRule = new MustHaveReturnTypeFunctionRule(layer: 'Helper'); - $this->assertTrue($mustHaveReturnTypeFunctionRule->appliesToFunction($this->makeNode())); + $this->assertTrue($mustHaveReturnTypeFunctionRule->appliesTo($this->makeNode())); } public function testDoesNotApplyToWrongLayer(): void @@ -63,7 +63,7 @@ public function testDoesNotApplyToWrongLayer(): void $mustHaveReturnTypeFunctionRule = new MustHaveReturnTypeFunctionRule(layer: 'Helper'); $functionNode = $this->makeNode(layer: 'Controller'); - $this->assertFalse($mustHaveReturnTypeFunctionRule->appliesToFunction($functionNode)); + $this->assertFalse($mustHaveReturnTypeFunctionRule->appliesTo($functionNode)); } public function testSingleRuleInstanceReportsOneViolationPerFunction(): void @@ -72,10 +72,10 @@ public function testSingleRuleInstanceReportsOneViolationPerFunction(): void // node, so multiple functions yield multiple independent violations. $mustHaveReturnTypeFunctionRule = new MustHaveReturnTypeFunctionRule(layer: 'Helper'); - $firstViolation = $mustHaveReturnTypeFunctionRule->evaluateFunction( + $firstViolation = $mustHaveReturnTypeFunctionRule->evaluate( $this->makeNode(functionName: 'App\\Helper\\format_price') ); - $secondViolation = $mustHaveReturnTypeFunctionRule->evaluateFunction( + $secondViolation = $mustHaveReturnTypeFunctionRule->evaluate( $this->makeNode(functionName: 'App\\Helper\\format_date') ); From 42a7bad93da2cf28a84768f6910ede8495ad4048 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 1 Sep 2026 14:20:15 +0700 Subject: [PATCH 040/104] docs update --- docs/custom-rules-and-presets.md | 50 +++++++++++++------------------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index 9d570b9b..8471dade 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -190,7 +190,7 @@ Both carry the body-level facts a `ClassNode` has — `$dependencies`, `$functio A closure declared inside a class or a named function is counted on both nodes: the enclosing `ClassNode` (or `FunctionNode`) keeps seeing everything the closure does, exactly as it sees its own method bodies, and the `AnonymousFunctionNode` reports the closure body on its own. -Rules opt in to these nodes by implementing `Boundwize\StructArmed\Rule\FunctionRuleInterface` and/or `Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface`. Their method names differ from `RuleInterface` (`appliesToFunction()` / `evaluateFunction()` and `appliesToAnonymousFunction()` / `evaluateAnonymousFunction()`), so one rule class can implement all three and check classes, functions, and closures alike. Global skip paths, rule-scoped `skip()` paths, and `skipRule()` apply the same way. Function-likes are not part of the declarative `ruleset()` layer-dependency check. +Rules opt in to these nodes by implementing `Boundwize\StructArmed\Rule\FunctionRuleInterface` and/or `Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface`. Both share the `appliesTo()` / `evaluate()` method names with `RuleInterface`, each typed against its own node kind; a rule class implementing more than one of them widens the parameter to a union type and branches on the node type, as in the example below. Global skip paths, rule-scoped `skip()` paths, and `skipRule()` apply the same way. Function-likes are not part of the declarative `ruleset()` layer-dependency check. ```php isInLayer($this->layer); + return $node->isInLayer($this->layer); } - public function evaluateFunction(FunctionNode $functionNode): ?RuleViolation + public function evaluate(FunctionNode|AnonymousFunctionNode $node): ?RuleViolation { - if (! $functionNode->accessesSuperglobals()) { + if (! $node->accessesSuperglobals()) { return null; } - return new RuleViolation( - message: sprintf('Function [%s()] must not access superglobals', $functionNode->functionName), - file: $functionNode->file, - line: $functionNode->line, - className: $functionNode->functionName, - layer: $functionNode->layer, - functionName: $functionNode->functionName, - ); - } - - public function appliesToAnonymousFunction(AnonymousFunctionNode $anonymousFunctionNode): bool - { - return $anonymousFunctionNode->isInLayer($this->layer); - } - - public function evaluateAnonymousFunction(AnonymousFunctionNode $anonymousFunctionNode): ?RuleViolation - { - if (! $anonymousFunctionNode->accessesSuperglobals()) { - return null; + if ($node instanceof FunctionNode) { + return new RuleViolation( + message: sprintf('Function [%s()] must not access superglobals', $node->functionName), + file: $node->file, + line: $node->line, + className: $node->functionName, + layer: $node->layer, + functionName: $node->functionName, + ); } return new RuleViolation( message: sprintf( '%s in [%s] must not access superglobals', - $anonymousFunctionNode->getType(), - $anonymousFunctionNode->enclosingScopeName() + $node->getType(), + $node->enclosingScopeName() ), - file: $anonymousFunctionNode->file, - line: $anonymousFunctionNode->line, - className: $anonymousFunctionNode->enclosingScopeName(), - layer: $anonymousFunctionNode->layer, + file: $node->file, + line: $node->line, + className: $node->enclosingScopeName(), + layer: $node->layer, ); } } From f368491ea92cf6cdf6e9e65264d9aef8e9a0527f Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 1 Sep 2026 14:22:16 +0700 Subject: [PATCH 041/104] fix doc --- docs/custom-rules-and-presets.md | 79 ++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 25 deletions(-) diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index 8471dade..4b6a68fb 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -190,66 +190,95 @@ Both carry the body-level facts a `ClassNode` has — `$dependencies`, `$functio A closure declared inside a class or a named function is counted on both nodes: the enclosing `ClassNode` (or `FunctionNode`) keeps seeing everything the closure does, exactly as it sees its own method bodies, and the `AnonymousFunctionNode` reports the closure body on its own. -Rules opt in to these nodes by implementing `Boundwize\StructArmed\Rule\FunctionRuleInterface` and/or `Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface`. Both share the `appliesTo()` / `evaluate()` method names with `RuleInterface`, each typed against its own node kind; a rule class implementing more than one of them widens the parameter to a union type and branches on the node type, as in the example below. Global skip paths, rule-scoped `skip()` paths, and `skipRule()` apply the same way. Function-likes are not part of the declarative `ruleset()` layer-dependency check. +Rules opt in to these nodes by implementing `Boundwize\StructArmed\Rule\FunctionRuleInterface` and/or `Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface`. Both share the `appliesTo()` / `evaluate()` method names with `RuleInterface`, each typed against its own node kind. Global skip paths, rule-scoped `skip()` paths, and `skipRule()` apply the same way. Function-likes are not part of the declarative `ruleset()` layer-dependency check. ```php isInLayer($this->layer); + return $functionNode->isInLayer($this->layer); } - public function evaluate(FunctionNode|AnonymousFunctionNode $node): ?RuleViolation + public function evaluate(FunctionNode $functionNode): ?RuleViolation { - if (! $node->accessesSuperglobals()) { + if (! $functionNode->accessesSuperglobals()) { return null; } - if ($node instanceof FunctionNode) { - return new RuleViolation( - message: sprintf('Function [%s()] must not access superglobals', $node->functionName), - file: $node->file, - line: $node->line, - className: $node->functionName, - layer: $node->layer, - functionName: $node->functionName, - ); + return new RuleViolation( + message: sprintf('Function [%s()] must not access superglobals', $functionNode->functionName), + file: $functionNode->file, + line: $functionNode->line, + className: $functionNode->functionName, + layer: $functionNode->layer, + functionName: $functionNode->functionName, + ); + } +} +``` + +An anonymous-function rule looks the same with `AnonymousFunctionNode` in the signatures: + +```php +isInLayer($this->layer); + } + + public function evaluate(AnonymousFunctionNode $anonymousFunctionNode): ?RuleViolation + { + if (! $anonymousFunctionNode->accessesSuperglobals()) { + return null; } return new RuleViolation( message: sprintf( '%s in [%s] must not access superglobals', - $node->getType(), - $node->enclosingScopeName() + $anonymousFunctionNode->getType(), + $anonymousFunctionNode->enclosingScopeName() ), - file: $node->file, - line: $node->line, - className: $node->enclosingScopeName(), - layer: $node->layer, + file: $anonymousFunctionNode->file, + line: $anonymousFunctionNode->line, + className: $anonymousFunctionNode->enclosingScopeName(), + layer: $anonymousFunctionNode->layer, ); } } ``` +One rule class can also implement several of these interfaces at once; PHP then requires the shared methods to widen the parameter to a union type (for example `appliesTo(FunctionNode|AnonymousFunctionNode $node): bool`) and the rule branches on the node type inside. + `RuleViolation::$className` is required, so a function rule passes the function name there (and, optionally, in the dedicated `functionName` field, which the JSON report emits as `"function"`); an anonymous-function rule passes `enclosingScopeName()`, which is the enclosing class-like or named function, or `AnonymousFunctionNode::FILE_SCOPE` (`'file scope'`) for a closure in top-level procedural code. ## Making A Custom Rule Fixable From e69206a22d832670390cdca8dc0dc9566b14156c Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 1 Sep 2026 16:46:01 +0700 Subject: [PATCH 042/104] perf: Evaluate node rules per node kind via pre-grouped (nodes, rules) pairs --- src/Analyser/Analyser.php | 114 +++++++++++++++++++++----------------- 1 file changed, 64 insertions(+), 50 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index d93aab83..95d04376 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -80,25 +80,37 @@ public function analyse( $ruleSkipPaths = $architecture->getRuleSkipPaths(); $skippedRuleKeys = $this->skippedRuleKeyMap($architecture->getSkippedRuleKeys()); - $projectRuleViolations = []; - $fileAnalysisRules = []; - $nodeRules = []; - $layerAwareRules = []; - $hasExtendedClassAwareRule = false; - $hasUsedInterfaceAwareRule = false; - $hasUsedTraitAwareRule = false; + $projectRuleViolations = []; + $fileAnalysisRules = []; + $nodeRules = []; + $classNodeRules = []; + $functionNodeRules = []; + $anonymousFunctionNodeRules = []; + $layerAwareRules = []; + $hasExtendedClassAwareRule = false; + $hasUsedInterfaceAwareRule = false; + $hasUsedTraitAwareRule = false; foreach ($rules as $key => $rule) { if (array_key_exists($key, $skippedRuleKeys)) { continue; } - if ( - $rule instanceof RuleInterface - || $rule instanceof FunctionRuleInterface - || $rule instanceof AnonymousFunctionRuleInterface - ) { - $nodeRules[$key] = $rule; + // Grouped per node kind here so the evaluation loop below matches + // rules to nodes without re-checking interfaces per node × rule. + if ($rule instanceof RuleInterface) { + $nodeRules[$key] = $rule; + $classNodeRules[$key] = $rule; + } + + if ($rule instanceof FunctionRuleInterface) { + $nodeRules[$key] = $rule; + $functionNodeRules[$key] = $rule; + } + + if ($rule instanceof AnonymousFunctionRuleInterface) { + $nodeRules[$key] = $rule; + $anonymousFunctionNodeRules[$key] = $rule; } if ($rule instanceof LayerAwareRuleInterface) { @@ -245,10 +257,14 @@ functionName: $violation->functionName, // Function-likes are not part of the class hierarchy, so they take no // part in the declarative ruleset below; a rule only sees the node - // kind whose interface it implements. + // kind whose interface it implements, so each node collection is + // paired with the rules grouped for its kind above. $this->evaluateNodeRules( - [...$classNodes, ...$extractionResult->functionNodes, ...$extractionResult->anonymousFunctionNodes], - $nodeRules, + [ + [$classNodes, $classNodeRules], + [$extractionResult->functionNodes, $functionNodeRules], + [$extractionResult->anonymousFunctionNodes, $anonymousFunctionNodeRules], + ], $globalSkipPathMatcher, $ruleSkipMatchers, $ruleViolationCollection @@ -350,59 +366,57 @@ className: $classNode->className, } /** - * Evaluates rules against nodes of the matching kind in a single loop: - * all three rule interfaces share the appliesTo()/evaluate() method - * names, and a rule only receives the node kind whose interface it - * implements. + * Evaluates each node collection against the rules grouped for its node + * kind, in a single evaluation implementation: all three rule interfaces + * share the appliesTo()/evaluate() method names, and a rule only receives + * the node kind whose interface it implements. * - * @param list $nodes - * @param array $rules + * @param list, 1: array}> $nodeGroups * @param array $ruleSkipMatchers + * @phpstan-param list|list|list, + * 1: array + * }> $nodeGroups */ private function evaluateNodeRules( - array $nodes, - array $rules, + array $nodeGroups, SkipPathMatcher $globalSkipPathMatcher, array $ruleSkipMatchers, RuleViolationCollection $ruleViolationCollection ): void { - foreach ($nodes as $node) { - if ($globalSkipPathMatcher->isSkipped($node->file)) { + foreach ($nodeGroups as [$nodes, $rules]) { + if ($rules === []) { continue; } - foreach ($rules as $key => $rule) { - $ruleHandlesNode = match (true) { - $node instanceof ClassNode => $rule instanceof RuleInterface, - $node instanceof FunctionNode => $rule instanceof FunctionRuleInterface, - default => $rule instanceof AnonymousFunctionRuleInterface, - }; - - if (! $ruleHandlesNode) { - continue; - } - - if (isset($ruleSkipMatchers[$key]) && $ruleSkipMatchers[$key]->isSkipped($node->file)) { + foreach ($nodes as $node) { + if ($globalSkipPathMatcher->isSkipped($node->file)) { continue; } - if (! $rule->appliesTo($node)) { - continue; - } + foreach ($rules as $key => $rule) { + if (isset($ruleSkipMatchers[$key]) && $ruleSkipMatchers[$key]->isSkipped($node->file)) { + continue; + } - if ($rule instanceof MultipleRuleViolationInterface && $node instanceof ClassNode) { - $violations = $rule->evaluateAll($node); - } else { - $violation = $rule->evaluate($node); - if (! $violation instanceof RuleViolation) { + if (! $rule->appliesTo($node)) { continue; } - $violations = [$violation]; - } + if ($rule instanceof MultipleRuleViolationInterface && $node instanceof ClassNode) { + $violations = $rule->evaluateAll($node); + } else { + $violation = $rule->evaluate($node); + if (! $violation instanceof RuleViolation) { + continue; + } + + $violations = [$violation]; + } - foreach ($violations as $violation) { - $ruleViolationCollection->add($this->withRuleKey($violation, $key, $rule)); + foreach ($violations as $violation) { + $ruleViolationCollection->add($this->withRuleKey($violation, $key, $rule)); + } } } } From 881d2a4673a6bd7ad5dd6f1cc037b8f3586d2ed4 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 1 Sep 2026 17:05:52 +0700 Subject: [PATCH 043/104] Perf: precompute trailing-slash layer-path prefixes in NamespaceLayerResolver for single str_starts_with matching --- .../Resolvers/NamespaceLayerResolver.php | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/LayerResolver/Resolvers/NamespaceLayerResolver.php b/src/LayerResolver/Resolvers/NamespaceLayerResolver.php index 6036e152..4ec15878 100644 --- a/src/LayerResolver/Resolvers/NamespaceLayerResolver.php +++ b/src/LayerResolver/Resolvers/NamespaceLayerResolver.php @@ -19,7 +19,13 @@ */ final readonly class NamespaceLayerResolver implements LayerResolverInterface { - /** @var array> */ + /** + * Layer paths stored with a trailing '/' so a single str_starts_with() + * against the file path (also suffixed with '/') covers both exact and + * descendant matches. + * + * @var array> + */ private array $normalisedLayers; /** @@ -36,7 +42,7 @@ public function __construct( $normalisedLayers[$layerName][] = Path::normalise( Path::resolve($layerPath, $basePath), canonicalise: true - ); + ) . '/'; } } @@ -45,13 +51,13 @@ public function __construct( public function resolve(string $className, string $filePath): ?string { - $normalised = Path::normalise($filePath, canonicalise: true); + $pathWithSlash = Path::normalise($filePath, canonicalise: true) . '/'; $matchedLayer = null; $matchedLength = -1; foreach ($this->normalisedLayers as $layerName => $layerPaths) { foreach ($layerPaths as $layerPath) { - if ($this->matchesLayerPath($normalised, $layerPath)) { + if (str_starts_with($pathWithSlash, $layerPath)) { $length = strlen($layerPath); if ($length > $matchedLength) { @@ -70,12 +76,12 @@ public function resolve(string $className, string $filePath): ?string */ public function resolveAll(string $className, string $filePath): array { - $normalised = Path::normalise($filePath, canonicalise: true); - $matched = []; + $pathWithSlash = Path::normalise($filePath, canonicalise: true) . '/'; + $matched = []; foreach ($this->normalisedLayers as $layerName => $layerPaths) { foreach ($layerPaths as $layerPath) { - if ($this->matchesLayerPath($normalised, $layerPath)) { + if (str_starts_with($pathWithSlash, $layerPath)) { $matched[] = $layerName; break; } @@ -84,9 +90,4 @@ public function resolveAll(string $className, string $filePath): array return $matched; } - - private function matchesLayerPath(string $path, string $layerPath): bool - { - return $path === $layerPath || str_starts_with($path, $layerPath . '/'); - } } From 3c29b4c0436813825f3640dd16f44188ee794719 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 1 Sep 2026 17:19:38 +0700 Subject: [PATCH 044/104] refactor: Reuse NodeQueryTrait (renamed from FunctionLikeNodeTrait) in ClassNode --- src/Analyser/AnonymousFunctionNode.php | 2 +- src/Analyser/ClassNode.php | 57 +------------------ src/Analyser/FunctionNode.php | 2 +- ...onLikeNodeTrait.php => NodeQueryTrait.php} | 17 ++++-- 4 files changed, 16 insertions(+), 62 deletions(-) rename src/Analyser/{FunctionLikeNodeTrait.php => NodeQueryTrait.php} (65%) diff --git a/src/Analyser/AnonymousFunctionNode.php b/src/Analyser/AnonymousFunctionNode.php index 48f99af9..ca89827e 100644 --- a/src/Analyser/AnonymousFunctionNode.php +++ b/src/Analyser/AnonymousFunctionNode.php @@ -18,7 +18,7 @@ */ final readonly class AnonymousFunctionNode { - use FunctionLikeNodeTrait; + use NodeQueryTrait; /** * Scope label reported by {@see enclosingScopeName()} for an anonymous diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index 825fc443..13913283 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -5,9 +5,7 @@ namespace Boundwize\StructArmed\Analyser; use function array_filter; -use function in_array; use function preg_match; -use function rtrim; use function str_ends_with; use function str_starts_with; use function strcasecmp; @@ -16,6 +14,8 @@ final class ClassNode { + use NodeQueryTrait; + /** @var list */ public readonly array $layers; @@ -163,11 +163,6 @@ public function shortName(): string : substr($this->className, $position + 1); } - public function isInLayer(string $layer): bool - { - return in_array($layer, $this->layers, true); - } - public function isClass(): bool { return ! $this->isInterface && ! $this->isTrait && ! $this->isEnum; @@ -188,24 +183,6 @@ public function nameMatches(string $pattern, bool $isFullName = false): bool return (bool) preg_match($pattern, $isFullName ? $this->className : $this->shortName()); } - public function dependsOn(string $class): bool - { - return in_array($class, $this->dependencies, true); - } - - public function dependsOnNamespace(string $namespace): bool - { - $prefix = rtrim($namespace, '\\') . '\\'; - - foreach ($this->dependencies as $dependency) { - if (str_starts_with($dependency, $prefix)) { - return true; - } - } - - return false; - } - /** * Implemented directly, extended directly (for interfaces), or via any parent class or interface. */ @@ -242,36 +219,6 @@ private function matchesAnyClassLike(string $needle, array $classLikes): bool return false; } - public function callsFunction(string $function): bool - { - foreach ($this->functionCalls as $functionCall) { - if (strcasecmp($functionCall, $function) === 0) { - return true; - } - } - - return false; - } - - public function usesLanguageConstruct(string $construct): bool - { - if (in_array($construct, $this->languageConstructs, true)) { - return true; - } - - // `die` is a pure alias of `exit`, so banning either spelling catches both. - return match ($construct) { - 'exit' => in_array('die', $this->languageConstructs, true), - 'die' => in_array('exit', $this->languageConstructs, true), - default => false, - }; - } - - public function accessesSuperglobals(): bool - { - return $this->superglobals !== []; - } - public function constructorParamCount(): int { foreach ($this->methods as $method) { diff --git a/src/Analyser/FunctionNode.php b/src/Analyser/FunctionNode.php index 6c0e1bde..d852b32d 100644 --- a/src/Analyser/FunctionNode.php +++ b/src/Analyser/FunctionNode.php @@ -18,7 +18,7 @@ */ final readonly class FunctionNode { - use FunctionLikeNodeTrait; + use NodeQueryTrait; /** @var list */ public array $layers; diff --git a/src/Analyser/FunctionLikeNodeTrait.php b/src/Analyser/NodeQueryTrait.php similarity index 65% rename from src/Analyser/FunctionLikeNodeTrait.php rename to src/Analyser/NodeQueryTrait.php index a601b72c..4c58608a 100644 --- a/src/Analyser/FunctionLikeNodeTrait.php +++ b/src/Analyser/NodeQueryTrait.php @@ -10,14 +10,21 @@ use function strcasecmp; /** - * Query helpers shared by {@see FunctionNode} and {@see AnonymousFunctionNode}. - * Both nodes carry the same body-level facts as a ClassNode — dependencies, - * function calls, superglobals, language constructs — so rules can ask the - * same questions of a function body that they ask of a class-like. + * Query helpers shared by {@see ClassNode}, {@see FunctionNode}, and + * {@see AnonymousFunctionNode}. All three nodes carry the same body-level + * facts — layers, dependencies, function calls, superglobals, language + * constructs — so rules can ask the same questions of a function body that + * they ask of a class-like. * * @internal + * + * @property list $layers All layer names this node belongs to; assigned once in each node's constructor + * @property-read list $dependencies Fully-qualified class, function, or constant dependencies + * @property-read string[] $functionCalls Functions called within this node + * @property-read string[] $superglobals Superglobals accessed ($_GET, $_POST, etc.) + * @property-read string[] $languageConstructs Language constructs used (exit, die, etc.) */ -trait FunctionLikeNodeTrait +trait NodeQueryTrait { public function isInLayer(string $layer): bool { From 1cc385733cab62f022e7b5361a252695892ae87b Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 1 Sep 2026 19:08:01 +0700 Subject: [PATCH 045/104] clean up config ruleset --- structarmed.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structarmed.php b/structarmed.php index e7549f57..6a93f3b8 100644 --- a/structarmed.php +++ b/structarmed.php @@ -27,7 +27,7 @@ ->layer('Rule', 'src/Rule/') ->layer('Util', 'src/Util/') ->ruleset([ - 'Analyser' => ['+Cache', 'Composer', 'LayerResolver', 'Progress', 'Util'], + 'Analyser' => ['+Cache', 'Composer', 'LayerResolver', 'Progress'], 'Baseline' => ['Core', 'Rule', 'Util'], 'Cache' => ['Analyser', 'Composer', 'Core', 'Rule', 'Util'], 'Cli' => ['Baseline', '+Cache', 'Config', 'Progress', 'Report', 'Util'], From eabdb84dedd3bedb815628f0dc8f743abf390262 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 1 Sep 2026 19:39:47 +0700 Subject: [PATCH 046/104] perf: clean up useless copy config on ComposerJsonProvider::decode() --- src/Composer/ComposerJsonProvider.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Composer/ComposerJsonProvider.php b/src/Composer/ComposerJsonProvider.php index 149daf10..1bb17689 100644 --- a/src/Composer/ComposerJsonProvider.php +++ b/src/Composer/ComposerJsonProvider.php @@ -58,16 +58,12 @@ private function decode(string $contents): ?array return null; } - $config = []; - - foreach ($composer as $key => $value) { + foreach ($composer as $key => $_) { if (is_int($key)) { return null; } - - $config[$key] = $value; } - return $config; + return $composer; } } From 0fbb6b9cffa8e7b948e6def8ac49b351a88e945e Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 1 Sep 2026 19:41:17 +0700 Subject: [PATCH 047/104] fix merge conflict --- docs/assets/no-violation.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/assets/no-violation.svg b/docs/assets/no-violation.svg index 05787388..4111d1ee 100644 --- a/docs/assets/no-violation.svg +++ b/docs/assets/no-violation.svg @@ -8,7 +8,7 @@ .header { fill: #cccccc; font-weight: bold; } .version { fill: #cccccc; } - + prj-ddd vendor/bin/structarmed analyze @@ -21,7 +21,7 @@ ✅ No violations found. (0.00s) - + prj-ddd From 0710b5afec6036f49fde7427294d7ccdf187c076 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 1 Sep 2026 19:42:21 +0700 Subject: [PATCH 048/104] cs --- src/Composer/ComposerJsonProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Composer/ComposerJsonProvider.php b/src/Composer/ComposerJsonProvider.php index 1bb17689..53404bfe 100644 --- a/src/Composer/ComposerJsonProvider.php +++ b/src/Composer/ComposerJsonProvider.php @@ -58,7 +58,7 @@ private function decode(string $contents): ?array return null; } - foreach ($composer as $key => $_) { + foreach ($composer as $key => $value) { if (is_int($key)) { return null; } From 03afa1c9631dc60b1cf3ccab9041672dc71859f8 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 1 Sep 2026 20:02:16 +0700 Subject: [PATCH 049/104] rename nodes to classNodes on AnalysisNodeCollector --- src/Analyser/AnalysisNodeCollector.php | 8 ++++---- src/Analyser/AnalysisNodeExtractor.php | 2 +- tests/Analyser/AnalysisNodeCollectorTest.php | 6 +++--- tests/Analyser/FunctionLikeCollectionTest.php | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index b18cae60..d8cb1ba7 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -237,7 +237,7 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract ]; /** @var list */ - private array $nodes = []; + private array $classNodes = []; /** @var list */ private array $anonymousClassNodes = []; @@ -384,9 +384,9 @@ public function setCurrentFile(string $file): void } /** @return list */ - public function getNodes(): array + public function getClassNodes(): array { - return $this->nodes; + return $this->classNodes; } /** @return list */ @@ -1278,7 +1278,7 @@ private function collectClassLike(ClassLike $classLike): void $implements = $this->collectImplements($classLike); $interfaceExtends = $this->collectInterfaceExtends($classLike); - $this->nodes[] = new ClassNode( + $this->classNodes[] = new ClassNode( className: $className, file: $this->currentFile, line: $classLike->getStartLine(), diff --git a/src/Analyser/AnalysisNodeExtractor.php b/src/Analyser/AnalysisNodeExtractor.php index 5754be70..5619553d 100644 --- a/src/Analyser/AnalysisNodeExtractor.php +++ b/src/Analyser/AnalysisNodeExtractor.php @@ -64,7 +64,7 @@ public function extract( } $extractionResult = new ExtractionResult( - $analysisNodeCollector->getNodes(), + $analysisNodeCollector->getClassNodes(), $fileAnalyses, $analysisNodeCollector->getAnonymousClassNodes(), $analysisNodeCollector->getFileReferences(), diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index 8dcfa40c..7704e0dc 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -40,7 +40,7 @@ private function collect(string $code): ClassNode /** @return ClassNode[] */ private function collectNodes(string $code): array { - return $this->makeCollector($code)->getNodes(); + return $this->makeCollector($code)->getClassNodes(); } /** @return list */ @@ -703,7 +703,7 @@ public function testCollectsEachClassMethodOnce(): void $this->assertSame( ['__construct', 'bar'], - array_column($analysisNodeCollector->getNodes()[0]->methods, 'name'), + array_column($analysisNodeCollector->getClassNodes()[0]->methods, 'name'), ); } @@ -1836,6 +1836,6 @@ public function testIgnoresClassMethodNodesOutsideTrackedClassLike(): void $analysisNodeCollector->enterNode($classMethod); $analysisNodeCollector->leaveNode($classMethod); - $this->assertSame([], $analysisNodeCollector->getNodes()); + $this->assertSame([], $analysisNodeCollector->getClassNodes()); } } diff --git a/tests/Analyser/FunctionLikeCollectionTest.php b/tests/Analyser/FunctionLikeCollectionTest.php index d7be3e51..2035890b 100644 --- a/tests/Analyser/FunctionLikeCollectionTest.php +++ b/tests/Analyser/FunctionLikeCollectionTest.php @@ -206,7 +206,7 @@ public function testFunctionNodesAreCollectedAfterClassNodesInSourceOrder(): voi . 'function second(): void {}' ); - $this->assertSame(['App\Domain\Foo'], [$analysisNodeCollector->getNodes()[0]->className]); + $this->assertSame(['App\Domain\Foo'], [$analysisNodeCollector->getClassNodes()[0]->className]); $this->assertSame( ['App\Domain\first', 'App\Domain\second'], [ @@ -265,7 +265,7 @@ public function testRecordsEnclosingClassAndCountsClosureBodyOnBothNodes(): void ); $anonymousFunctionNode = $analysisNodeCollector->getAnonymousFunctionNodes()[0]; - $classNode = $analysisNodeCollector->getNodes()[0]; + $classNode = $analysisNodeCollector->getClassNodes()[0]; $this->assertSame('App\Domain\Handler', $anonymousFunctionNode->enclosingClassName); $this->assertNull($anonymousFunctionNode->enclosingFunctionName); @@ -405,7 +405,7 @@ public function testMethodComplexityStillAggregatesNestedClosureBranches(): void 'getNodes()[0]; + )->getClassNodes()[0]; $this->assertSame(3, $classNode->methods[0]->cyclomaticComplexity); } From 183c2cf6fa57c857f233512551284acaeaffe80a Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 1 Sep 2026 20:31:36 +0700 Subject: [PATCH 050/104] feat: add Preset::PER() extending PSR-12 with EnumCaseNameMustBePascalCaseRule --- docs/available-rules.md | 1 + docs/presets.md | 3 + src/Cli/InitCommand.php | 6 +- src/Cli/Usage.php | 2 +- src/Preset/Preset.php | 13 ++ src/Preset/Presets/PerPreset.php | 49 ++++++++ .../EnumCaseNameMustBePascalCaseRule.php | 64 ++++++++++ tests/Cli/InitCommandTest.php | 8 +- ...ructArmedApplicationCommandRoutingTest.php | 2 +- tests/Cli/StructArmedApplicationTest.php | 8 +- tests/Preset/PresetTest.php | 36 ++++++ .../EnumCaseNameMustBePascalCaseRuleTest.php | 114 ++++++++++++++++++ 12 files changed, 300 insertions(+), 6 deletions(-) create mode 100644 src/Preset/Presets/PerPreset.php create mode 100644 src/Rule/Rules/Class_/EnumCaseNameMustBePascalCaseRule.php create mode 100644 tests/Rule/Class_/EnumCaseNameMustBePascalCaseRuleTest.php diff --git a/docs/available-rules.md b/docs/available-rules.md index 3c8e6830..7e64a678 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -78,6 +78,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`. | `ClassNameMustBeStudlyCapsRule` | `new ClassNameMustBeStudlyCapsRule(layer: 'Source')` | Class names use StudlyCaps. | | `ClassNameMustHaveSuffixRule` | `new ClassNameMustHaveSuffixRule(layer: 'Controller', suffix: 'Controller')` | Classes in a layer have the required suffix. | | `ClassNameMustNotHavePrefixRule` | `new ClassNameMustNotHavePrefixRule(layer: 'Model', prefix: 'Model')` | Classes in a layer do not use a forbidden prefix. | +| `EnumCaseNameMustBePascalCaseRule` | `new EnumCaseNameMustBePascalCaseRule(layer: 'Source')` | Enum case names use PascalCase, per [PER Coding Style](https://www.php-fig.org/per/coding-style/#9-enumerations). | | `ExtendedClassMustBeAbstractOrInstantiatedRule` | `new ExtendedClassMustBeAbstractOrInstantiatedRule(layer: 'Source')` | Classes another scanned class extends are declared `abstract` unless they are also instantiated (`new X`, a `new self`/`new static`/`new parent` resolving to them, a constant class expression such as `new (X::class)` or `new ('App\X')`, or a chained `(new ReflectionClass(X::class))->newInstance*()`). Type hints, `instanceof`, and `::class` keep working on an abstract class, so they do not count. Runtime-fed construction (`new $class` from a parameter, `unserialize()`, container factories) is outside the scanned-code boundary — exclude such factories' targets with rule-scoped `skip()` or `skipRule()`. Supports `--fix` by adding the `abstract` modifier. | | `MaxDependencyCountRule` | `new MaxDependencyCountRule(layer: 'Controller', maxCount: 5)` | Constructor dependency count stays below the configured limit. | | `MayNotExtendClassRule` | `new MayNotExtendClassRule(layer: 'Domain', class: 'Illuminate\\Database\\Eloquent\\Model')` | Classes in a layer do not extend a forbidden class, directly or through any parent class. | diff --git a/docs/presets.md b/docs/presets.md index efea35ef..859196b1 100644 --- a/docs/presets.md +++ b/docs/presets.md @@ -21,6 +21,7 @@ StructArmed ships with presets for common PHP standards and architecture styles. |---|---| | `Preset::PSR1()` | Basic Coding Standard checks: PHP tags, valid UTF-8, UTF-8 without BOM, symbols vs side effects, PSR-4 class placement, StudlyCaps class names, upper-case class constants, camelCase methods | | `Preset::PSR12()` | Extends PSR-1: all methods, constants, and properties must declare explicit visibility | +| `Preset::PER()` | [PER Coding Style](https://www.php-fig.org/per/coding-style/): extends PSR-12 (and, through it, PSR-1) and adds PascalCase enum case names | | `Preset::PSR15()` | `*Middleware` classes must implement PSR-15 `MiddlewareInterface`; `*Handler` classes must implement PSR-15 `RequestHandlerInterface`; StructArmed also enforces matching `Middleware`/`Handler` suffixes for implementations of those interfaces | | `Preset::PSR4()` | Verifies configured source paths exist in composer.json `autoload` or `autoload-dev` PSR-4 mappings | | `Preset::DDD()` | Layer isolation, entity/VO/repository/event/service conventions, including keeping Doctrine ORM repository inheritance out of the Domain layer | @@ -33,6 +34,7 @@ StructArmed ships with presets for common PHP standards and architecture styles. vendor/bin/structarmed init --preset=psr4 vendor/bin/structarmed init --preset=psr1 vendor/bin/structarmed init --preset=psr12 +vendor/bin/structarmed init --preset=per vendor/bin/structarmed init --preset=psr15 vendor/bin/structarmed init --preset=mvc vendor/bin/structarmed init --preset=ddd @@ -48,6 +50,7 @@ return Architecture::define() Preset::PSR4(), Preset::PSR1(), Preset::PSR12(), + Preset::PER(), Preset::PSR15(), Preset::MVC(), Preset::DDD(), diff --git a/src/Cli/InitCommand.php b/src/Cli/InitCommand.php index be1ae3c7..b361948c 100644 --- a/src/Cli/InitCommand.php +++ b/src/Cli/InitCommand.php @@ -83,16 +83,18 @@ private function presetConfig(string $preset): ?string return match ($preset) { 'ddd' => ' ->withPreset(Preset::DDD());', 'mvc' => ' ->withPreset(Preset::MVC());', + 'psr4' => ' ->withPreset(Preset::PSR4());', 'psr1' => ' ->withPreset(Preset::PSR1());', 'psr12' => ' ->withPreset(Preset::PSR12());', + 'per' => ' ->withPreset(Preset::PER());', 'psr15' => ' ->withPreset(Preset::PSR15());', - 'psr4' => ' ->withPreset(Preset::PSR4());', 'yagni' => ' ->withPreset(Preset::YAGNI());', 'all' => " ->withPresets(\n" + . " Preset::PSR4(),\n" . " Preset::PSR1(),\n" . " Preset::PSR12(),\n" + . " Preset::PER(),\n" . " Preset::PSR15(),\n" - . " Preset::PSR4(),\n" . " Preset::DDD(),\n" . " Preset::MVC(),\n" . " Preset::YAGNI()\n" diff --git a/src/Cli/Usage.php b/src/Cli/Usage.php index 1752c038..70cbbfa1 100644 --- a/src/Cli/Usage.php +++ b/src/Cli/Usage.php @@ -11,7 +11,7 @@ public static function render(): string return <<<'TXT' Usage: structarmed --version - structarmed init [--preset=ddd|mvc|psr1|psr12|psr15|psr4|yagni|all] + structarmed init [--preset=ddd|mvc|psr4|psr1|psr12|per|psr15|yagni|all] structarmed analyse|analyze [path ...] [--config=path/to/structarmed.php] [--report=console|json] [--no-progress] [--clear-cache] [--disable-parallel] [--fix] [--generate-baseline=structarmed-baseline.php] diff --git a/src/Preset/Preset.php b/src/Preset/Preset.php index 5124f601..8c385c0e 100644 --- a/src/Preset/Preset.php +++ b/src/Preset/Preset.php @@ -6,6 +6,7 @@ use Boundwize\StructArmed\Preset\Presets\DddPreset; use Boundwize\StructArmed\Preset\Presets\MvcPreset; +use Boundwize\StructArmed\Preset\Presets\PerPreset; use Boundwize\StructArmed\Preset\Presets\Psr12Preset; use Boundwize\StructArmed\Preset\Presets\Psr15Preset; use Boundwize\StructArmed\Preset\Presets\Psr1Preset; @@ -21,6 +22,7 @@ * ->withPreset(Preset::PSR1()) * ->withPreset(Preset::PSR4()) * ->withPreset(Preset::PSR12()) + * ->withPreset(Preset::PER()) * ->withPreset(Preset::PSR15()) * ->withPreset(Preset::YAGNI()) * ->withPresets(Preset::DDD(), Preset::MVC()) @@ -60,6 +62,17 @@ public static function PSR12( ); } + /** + * @param list|null $sourcePaths + */ + public static function PER( + ?array $sourcePaths = null, + ): PerPreset { + return new PerPreset( + sourcePaths: $sourcePaths, + ); + } + /** * @param list|null $sourcePaths */ diff --git a/src/Preset/Presets/PerPreset.php b/src/Preset/Presets/PerPreset.php new file mode 100644 index 00000000..0ebac561 --- /dev/null +++ b/src/Preset/Presets/PerPreset.php @@ -0,0 +1,49 @@ +|null $sourcePaths + */ + public function __construct( + private ?array $sourcePaths = null, + ) { + } + + public function apply(Architecture $architecture): void + { + $sourcePathsForPer = $architecture->registerPresetSourcePaths(self::class, $this->sourcePaths); + $sourcePathsForPsr12 = $architecture->registerPresetSourcePaths(Psr12Preset::class, $this->sourcePaths); + + $psr12Preset = new Psr12Preset($sourcePathsForPsr12); + $psr12Preset->apply($architecture); + + // PER rules use only paths accumulated for PER. Paths registered + // exclusively for PSR-1, PSR-4, or PSR-12 must not broaden PER + // enforcement. + $layerName = $this->resolveLayerName($architecture, $sourcePathsForPer); + $architecture->layer($layerName, $sourcePathsForPer ?? []); + + $architecture->rule( + self::ENUM_CASES_MUST_BE_PASCAL_CASE, + new EnumCaseNameMustBePascalCaseRule($layerName) + ); + } +} diff --git a/src/Rule/Rules/Class_/EnumCaseNameMustBePascalCaseRule.php b/src/Rule/Rules/Class_/EnumCaseNameMustBePascalCaseRule.php new file mode 100644 index 00000000..d9be2e96 --- /dev/null +++ b/src/Rule/Rules/Class_/EnumCaseNameMustBePascalCaseRule.php @@ -0,0 +1,64 @@ +isInLayer($this->layer) + && $classNode->isEnum; + } + + public function evaluate(ClassNode $classNode): ?RuleViolation + { + return $this->evaluateAll($classNode)[0] ?? null; + } + + /** + * @return list + */ + public function evaluateAll(ClassNode $classNode): array + { + $violations = []; + + foreach ($classNode->enumCases as $enumCase) { + if ((bool) preg_match('/^[A-Z][A-Za-z0-9]*$/', $enumCase->name)) { + continue; + } + + $violations[] = new RuleViolation( + message: sprintf( + 'Enum case [%s::%s] must be declared in PascalCase', + $classNode->className, + $enumCase->name + ), + file: $classNode->file, + line: $enumCase->line !== 0 ? $enumCase->line : $classNode->line, + className: $classNode->className, + layer: $classNode->layer, + ); + } + + return $violations; + } +} diff --git a/tests/Cli/InitCommandTest.php b/tests/Cli/InitCommandTest.php index 2411e8b7..33a35dda 100644 --- a/tests/Cli/InitCommandTest.php +++ b/tests/Cli/InitCommandTest.php @@ -49,6 +49,11 @@ public static function presetProvider(): iterable ' ->withPreset(Preset::MVC());', ]; + yield 'per' => [ + ['--preset=per'], + ' ->withPreset(Preset::PER());', + ]; + yield 'psr1' => [ ['--preset=psr1'], ' ->withPreset(Preset::PSR1());', @@ -77,10 +82,11 @@ public static function presetProvider(): iterable yield 'all' => [ ['--preset=all'], " ->withPresets(\n" + . " Preset::PSR4(),\n" . " Preset::PSR1(),\n" . " Preset::PSR12(),\n" + . " Preset::PER(),\n" . " Preset::PSR15(),\n" - . " Preset::PSR4(),\n" . " Preset::DDD(),\n" . " Preset::MVC(),\n" . " Preset::YAGNI()\n" diff --git a/tests/Cli/StructArmedApplicationCommandRoutingTest.php b/tests/Cli/StructArmedApplicationCommandRoutingTest.php index b1df5d94..396c51a7 100644 --- a/tests/Cli/StructArmedApplicationCommandRoutingTest.php +++ b/tests/Cli/StructArmedApplicationCommandRoutingTest.php @@ -35,7 +35,7 @@ public function testApplicationPrintsUsageWithoutCommand(): void $this->assertSame(0, $exitCode); $this->assertStringContainsString('structarmed --version', $output); $this->assertStringContainsString( - 'structarmed init [--preset=ddd|mvc|psr1|psr12|psr15|psr4|yagni|all]', + 'structarmed init [--preset=ddd|mvc|psr4|psr1|psr12|per|psr15|yagni|all]', $output ); $this->assertStringContainsString('structarmed analyse|analyze', $output); diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 654ae5b7..2cd75a46 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -152,6 +152,11 @@ public static function presetProvider(): iterable ' ->withPreset(Preset::MVC());', ]; + yield 'per' => [ + ['--preset=per'], + ' ->withPreset(Preset::PER());', + ]; + yield 'psr1' => [ ['--preset=psr1'], ' ->withPreset(Preset::PSR1());', @@ -180,10 +185,11 @@ public static function presetProvider(): iterable yield 'all' => [ ['--preset=all'], " ->withPresets(\n" + . " Preset::PSR4(),\n" . " Preset::PSR1(),\n" . " Preset::PSR12(),\n" + . " Preset::PER(),\n" . " Preset::PSR15(),\n" - . " Preset::PSR4(),\n" . " Preset::DDD(),\n" . " Preset::MVC(),\n" . " Preset::YAGNI()\n" diff --git a/tests/Preset/PresetTest.php b/tests/Preset/PresetTest.php index b71b95dc..a4174ee1 100644 --- a/tests/Preset/PresetTest.php +++ b/tests/Preset/PresetTest.php @@ -8,6 +8,7 @@ use Boundwize\StructArmed\Preset\Preset; use Boundwize\StructArmed\Preset\Presets\DddPreset; use Boundwize\StructArmed\Preset\Presets\MvcPreset; +use Boundwize\StructArmed\Preset\Presets\PerPreset; use Boundwize\StructArmed\Preset\Presets\Psr12Preset; use Boundwize\StructArmed\Preset\Presets\Psr15Preset; use Boundwize\StructArmed\Preset\Presets\Psr1Preset; @@ -25,6 +26,7 @@ #[CoversClass(Preset::class)] #[CoversClass(DddPreset::class)] #[CoversClass(MvcPreset::class)] +#[CoversClass(PerPreset::class)] #[CoversClass(Psr1Preset::class)] #[CoversClass(Psr12Preset::class)] #[CoversClass(Psr15Preset::class)] @@ -121,6 +123,40 @@ public function testPsr12PresetAppliesPsr1RulesAndAddsVisibilityRules(): void $this->assertArrayHasKey(Psr12Preset::PROPERTIES_MUST_DECLARE_VISIBILITY, $rules); } + public function testPerPresetAppliesPsr12RulesAndAddsEnumCaseRule(): void + { + $architecture = Architecture::define(); + + Preset::PER( + sourcePaths: ['src/', 'tests/'], + )->apply($architecture); + + $this->assertSame(['Source' => ['src/', 'tests/']], $architecture->getLayers()); + + $rules = $architecture->getRules(); + $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_TAGS, $rules); + $this->assertArrayHasKey(Psr1Preset::CLASSES_MUST_BE_STUDLY_CAPS, $rules); + $this->assertArrayHasKey(Psr1Preset::CLASS_CONSTANTS_MUST_BE_UPPER_CASE, $rules); + $this->assertArrayHasKey(Psr1Preset::METHODS_MUST_BE_CAMEL_CASE, $rules); + $this->assertArrayHasKey(Psr12Preset::METHODS_MUST_DECLARE_VISIBILITY, $rules); + $this->assertArrayHasKey(Psr12Preset::CONSTANTS_MUST_DECLARE_VISIBILITY, $rules); + $this->assertArrayHasKey(Psr12Preset::PROPERTIES_MUST_DECLARE_VISIBILITY, $rules); + $this->assertArrayHasKey(PerPreset::ENUM_CASES_MUST_BE_PASCAL_CASE, $rules); + } + + public function testPerPresetUsesComposerSourcePathsByDefault(): void + { + $architecture = Architecture::define(); + + Preset::PER()->apply($architecture); + + $this->assertSame(['Source' => []], $architecture->getLayers()); + $this->assertArrayHasKey( + PerPreset::ENUM_CASES_MUST_BE_PASCAL_CASE, + $architecture->getRules() + ); + } + public function testPsr4PresetRegistersSourceLayerAndRules(): void { $architecture = Architecture::define(); diff --git a/tests/Rule/Class_/EnumCaseNameMustBePascalCaseRuleTest.php b/tests/Rule/Class_/EnumCaseNameMustBePascalCaseRuleTest.php new file mode 100644 index 00000000..8803fb51 --- /dev/null +++ b/tests/Rule/Class_/EnumCaseNameMustBePascalCaseRuleTest.php @@ -0,0 +1,114 @@ +assertTrue($enumCaseNameMustBePascalCaseRule->appliesTo($this->makeNode([], 'Source'))); + $this->assertFalse($enumCaseNameMustBePascalCaseRule->appliesTo($this->makeNode([], 'Other'))); + $this->assertFalse($enumCaseNameMustBePascalCaseRule->appliesTo( + $this->makeNode([], 'Source', isEnum: false) + )); + } + + public function testEvaluateReturnsFirstViolation(): void + { + $enumCaseNameMustBePascalCaseRule = new EnumCaseNameMustBePascalCaseRule('Source'); + + $violation = $enumCaseNameMustBePascalCaseRule->evaluate( + $this->makeNode([new EnumCaseNode('draft'), new EnumCaseNode('published')]) + ); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame(1, $violation->line); + } + + #[DataProvider('pascalCaseNameProvider')] + public function testPassesPascalCaseNames(string $name): void + { + $enumCaseNameMustBePascalCaseRule = new EnumCaseNameMustBePascalCaseRule('Source'); + + $this->assertSame( + [], + $enumCaseNameMustBePascalCaseRule->evaluateAll( + $this->makeNode([new EnumCaseNode($name)]) + ) + ); + } + + /** @return iterable */ + public static function pascalCaseNameProvider(): iterable + { + yield 'single word' => ['Draft']; + yield 'multi word' => ['PendingReview']; + yield 'with digits' => ['Http404']; + yield 'abbreviation' => ['XmlExport']; + } + + #[DataProvider('nonPascalCaseNameProvider')] + public function testViolatesNonPascalCaseNames(string $name): void + { + $enumCaseNameMustBePascalCaseRule = new EnumCaseNameMustBePascalCaseRule('Source'); + + $violations = $enumCaseNameMustBePascalCaseRule->evaluateAll( + $this->makeNode([new EnumCaseNode(name: $name, line: 7)]) + ); + + $this->assertCount(1, $violations); + $this->assertInstanceOf(RuleViolation::class, $violations[0]); + $this->assertSame(7, $violations[0]->line); + $this->assertSame( + 'Enum case [App\\Status::' . $name . '] must be declared in PascalCase', + $violations[0]->message + ); + } + + /** @return iterable */ + public static function nonPascalCaseNameProvider(): iterable + { + yield 'camelCase' => ['pendingReview']; + yield 'lower case' => ['draft']; + yield 'UPPER_CASE' => ['PENDING_REVIEW']; + yield 'snake_case' => ['pending_review']; + yield 'underscored' => ['Pending_Review']; + } + + /** + * @param list $enumCases + */ + private function makeNode( + array $enumCases, + string $layer = 'Source', + bool $isEnum = true, + ): ClassNode { + return new ClassNode( + className: 'App\\Status', + file: '/fake.php', + line: 1, + layer: $layer, + extends: null, + isAbstract: false, + isFinal: false, + isInterface: false, + isReadonly: false, + isEnum: $isEnum, + enumCases: $enumCases, + ); + } +} From 82d8b632e9d8ec7a92e120a0c6304aa801e33797 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 1 Sep 2026 20:33:43 +0700 Subject: [PATCH 051/104] psr4 first per order --- src/Preset/Preset.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Preset/Preset.php b/src/Preset/Preset.php index 8c385c0e..2462e0ca 100644 --- a/src/Preset/Preset.php +++ b/src/Preset/Preset.php @@ -19,8 +19,8 @@ * Usage: * ->withPreset(Preset::DDD()) * ->withPreset(Preset::DDD(maxComplexity: 3)) - * ->withPreset(Preset::PSR1()) * ->withPreset(Preset::PSR4()) + * ->withPreset(Preset::PSR1()) * ->withPreset(Preset::PSR12()) * ->withPreset(Preset::PER()) * ->withPreset(Preset::PSR15()) From 211a540fa8425d5bc820e752cb15fbb56cea51bc Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 1 Sep 2026 23:21:05 +0700 Subject: [PATCH 052/104] feat: add fixable EnumMethodMayNotBeProtectedRule and EnumConstantMayNotBeProtectedRule to PER preset --- docs/available-rules.md | 2 + docs/presets.md | 2 +- src/Preset/Presets/PerPreset.php | 16 ++ ...hangeProtectedConstantToPrivateVisitor.php | 58 ++++++ .../ChangeProtectedMethodToPrivateVisitor.php | 44 +++++ .../EnumConstantMayNotBeProtectedRule.php | 79 ++++++++ .../EnumMethodMayNotBeProtectedRule.php | 78 ++++++++ tests/Preset/PresetTest.php | 10 + .../EnumConstantMayNotBeProtectedRuleTest.php | 157 ++++++++++++++++ .../EnumMethodMayNotBeProtectedRuleTest.php | 175 ++++++++++++++++++ 10 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 src/Rule/Fixer/PhpParser/ClassConst/ChangeProtectedConstantToPrivateVisitor.php create mode 100644 src/Rule/Fixer/PhpParser/ClassMethod/ChangeProtectedMethodToPrivateVisitor.php create mode 100644 src/Rule/Rules/Class_/EnumConstantMayNotBeProtectedRule.php create mode 100644 src/Rule/Rules/Class_/EnumMethodMayNotBeProtectedRule.php create mode 100644 tests/Rule/Class_/EnumConstantMayNotBeProtectedRuleTest.php create mode 100644 tests/Rule/Class_/EnumMethodMayNotBeProtectedRuleTest.php diff --git a/docs/available-rules.md b/docs/available-rules.md index 7e64a678..151b6459 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -79,6 +79,8 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`. | `ClassNameMustHaveSuffixRule` | `new ClassNameMustHaveSuffixRule(layer: 'Controller', suffix: 'Controller')` | Classes in a layer have the required suffix. | | `ClassNameMustNotHavePrefixRule` | `new ClassNameMustNotHavePrefixRule(layer: 'Model', prefix: 'Model')` | Classes in a layer do not use a forbidden prefix. | | `EnumCaseNameMustBePascalCaseRule` | `new EnumCaseNameMustBePascalCaseRule(layer: 'Source')` | Enum case names use PascalCase, per [PER Coding Style](https://www.php-fig.org/per/coding-style/#9-enumerations). | +| `EnumConstantMayNotBeProtectedRule` | `new EnumConstantMayNotBeProtectedRule(layer: 'Source')` | Enum constants are not declared `protected` — enums cannot be extended, so `private` is used instead, per [PER Coding Style](https://www.php-fig.org/per/coding-style/#9-enumerations). Supports `--fix` by changing `protected` to `private`. | +| `EnumMethodMayNotBeProtectedRule` | `new EnumMethodMayNotBeProtectedRule(layer: 'Source')` | Enum methods are not declared `protected` — enums cannot be extended, so `private` is used instead, per [PER Coding Style](https://www.php-fig.org/per/coding-style/#9-enumerations). Supports `--fix` by changing `protected` to `private`. | | `ExtendedClassMustBeAbstractOrInstantiatedRule` | `new ExtendedClassMustBeAbstractOrInstantiatedRule(layer: 'Source')` | Classes another scanned class extends are declared `abstract` unless they are also instantiated (`new X`, a `new self`/`new static`/`new parent` resolving to them, a constant class expression such as `new (X::class)` or `new ('App\X')`, or a chained `(new ReflectionClass(X::class))->newInstance*()`). Type hints, `instanceof`, and `::class` keep working on an abstract class, so they do not count. Runtime-fed construction (`new $class` from a parameter, `unserialize()`, container factories) is outside the scanned-code boundary — exclude such factories' targets with rule-scoped `skip()` or `skipRule()`. Supports `--fix` by adding the `abstract` modifier. | | `MaxDependencyCountRule` | `new MaxDependencyCountRule(layer: 'Controller', maxCount: 5)` | Constructor dependency count stays below the configured limit. | | `MayNotExtendClassRule` | `new MayNotExtendClassRule(layer: 'Domain', class: 'Illuminate\\Database\\Eloquent\\Model')` | Classes in a layer do not extend a forbidden class, directly or through any parent class. | diff --git a/docs/presets.md b/docs/presets.md index 859196b1..707896eb 100644 --- a/docs/presets.md +++ b/docs/presets.md @@ -21,7 +21,7 @@ StructArmed ships with presets for common PHP standards and architecture styles. |---|---| | `Preset::PSR1()` | Basic Coding Standard checks: PHP tags, valid UTF-8, UTF-8 without BOM, symbols vs side effects, PSR-4 class placement, StudlyCaps class names, upper-case class constants, camelCase methods | | `Preset::PSR12()` | Extends PSR-1: all methods, constants, and properties must declare explicit visibility | -| `Preset::PER()` | [PER Coding Style](https://www.php-fig.org/per/coding-style/): extends PSR-12 (and, through it, PSR-1) and adds PascalCase enum case names | +| `Preset::PER()` | [PER Coding Style](https://www.php-fig.org/per/coding-style/): extends PSR-12 (and, through it, PSR-1) and adds PascalCase enum case names and no `protected` enum methods or constants | | `Preset::PSR15()` | `*Middleware` classes must implement PSR-15 `MiddlewareInterface`; `*Handler` classes must implement PSR-15 `RequestHandlerInterface`; StructArmed also enforces matching `Middleware`/`Handler` suffixes for implementations of those interfaces | | `Preset::PSR4()` | Verifies configured source paths exist in composer.json `autoload` or `autoload-dev` PSR-4 mappings | | `Preset::DDD()` | Layer isolation, entity/VO/repository/event/service conventions, including keeping Doctrine ORM repository inheritance out of the Domain layer | diff --git a/src/Preset/Presets/PerPreset.php b/src/Preset/Presets/PerPreset.php index 0ebac561..4235a1e4 100644 --- a/src/Preset/Presets/PerPreset.php +++ b/src/Preset/Presets/PerPreset.php @@ -7,6 +7,8 @@ use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Preset\PresetInterface; use Boundwize\StructArmed\Rule\Rules\Class_\EnumCaseNameMustBePascalCaseRule; +use Boundwize\StructArmed\Rule\Rules\Class_\EnumConstantMayNotBeProtectedRule; +use Boundwize\StructArmed\Rule\Rules\Class_\EnumMethodMayNotBeProtectedRule; /** * PER Coding Style: extends PSR-12 (which in turn requires PSR-1). @@ -19,6 +21,10 @@ public const ENUM_CASES_MUST_BE_PASCAL_CASE = 'per.enum_cases.must_be_pascal_case'; + public const ENUM_METHODS_MAY_NOT_BE_PROTECTED = 'per.enum_methods.may_not_be_protected'; + + public const ENUM_CONSTANTS_MAY_NOT_BE_PROTECTED = 'per.enum_constants.may_not_be_protected'; + /** * @param list|null $sourcePaths */ @@ -45,5 +51,15 @@ public function apply(Architecture $architecture): void self::ENUM_CASES_MUST_BE_PASCAL_CASE, new EnumCaseNameMustBePascalCaseRule($layerName) ); + + $architecture->rule( + self::ENUM_METHODS_MAY_NOT_BE_PROTECTED, + new EnumMethodMayNotBeProtectedRule($layerName) + ); + + $architecture->rule( + self::ENUM_CONSTANTS_MAY_NOT_BE_PROTECTED, + new EnumConstantMayNotBeProtectedRule($layerName) + ); } } diff --git a/src/Rule/Fixer/PhpParser/ClassConst/ChangeProtectedConstantToPrivateVisitor.php b/src/Rule/Fixer/PhpParser/ClassConst/ChangeProtectedConstantToPrivateVisitor.php new file mode 100644 index 00000000..43952428 --- /dev/null +++ b/src/Rule/Fixer/PhpParser/ClassConst/ChangeProtectedConstantToPrivateVisitor.php @@ -0,0 +1,58 @@ +namespacedName?->toString() !== $this->className) { + return null; + } + + foreach ($node->getConstants() as $classConstant) { + if (! $this->containsConstant($classConstant)) { + continue; + } + + if (($classConstant->flags & Modifiers::PROTECTED) === 0) { + return null; + } + + $classConstant->flags = ($classConstant->flags & ~Modifiers::PROTECTED) | Modifiers::PRIVATE; + + return $node; + } + + return null; + } + + private function containsConstant(ClassConst $classConst): bool + { + foreach ($classConst->consts as $constant) { + if ($constant->name->toString() === $this->constantName) { + return true; + } + } + + return false; + } +} diff --git a/src/Rule/Fixer/PhpParser/ClassMethod/ChangeProtectedMethodToPrivateVisitor.php b/src/Rule/Fixer/PhpParser/ClassMethod/ChangeProtectedMethodToPrivateVisitor.php new file mode 100644 index 00000000..2b1d7f87 --- /dev/null +++ b/src/Rule/Fixer/PhpParser/ClassMethod/ChangeProtectedMethodToPrivateVisitor.php @@ -0,0 +1,44 @@ +namespacedName?->toString() !== $this->className) { + return null; + } + + $classMethod = $node->getMethod($this->methodName); + if (! $classMethod instanceof ClassMethod) { + return null; + } + + if (($classMethod->flags & Modifiers::PROTECTED) === 0) { + return null; + } + + $classMethod->flags = ($classMethod->flags & ~Modifiers::PROTECTED) | Modifiers::PRIVATE; + + return $node; + } +} diff --git a/src/Rule/Rules/Class_/EnumConstantMayNotBeProtectedRule.php b/src/Rule/Rules/Class_/EnumConstantMayNotBeProtectedRule.php new file mode 100644 index 00000000..ae87108c --- /dev/null +++ b/src/Rule/Rules/Class_/EnumConstantMayNotBeProtectedRule.php @@ -0,0 +1,79 @@ +isInLayer($this->layer) + && $classNode->isEnum; + } + + public function evaluate(ClassNode $classNode): ?RuleViolation + { + return $this->evaluateAll($classNode)[0] ?? null; + } + + /** + * @return list + */ + public function evaluateAll(ClassNode $classNode): array + { + $violations = []; + + foreach ($classNode->constants as $constant) { + if ($constant->visibility !== 'protected') { + continue; + } + + $violations[] = new RuleViolation( + message: sprintf( + 'Enum constant [%s::%s] may not be declared protected, use private instead', + $classNode->className, + $constant->name + ), + file: $classNode->file, + line: $constant->line !== 0 ? $constant->line : $classNode->line, + className: $classNode->className, + layer: $classNode->layer, + constantName: $constant->name, + ); + } + + return $violations; + } + + protected function createFixerVisitor(RuleViolation $ruleViolation): ChangeProtectedConstantToPrivateVisitor + { + /** @var string $constantName */ + $constantName = $ruleViolation->constantName; + + return new ChangeProtectedConstantToPrivateVisitor( + $ruleViolation->className, + $constantName + ); + } +} diff --git a/src/Rule/Rules/Class_/EnumMethodMayNotBeProtectedRule.php b/src/Rule/Rules/Class_/EnumMethodMayNotBeProtectedRule.php new file mode 100644 index 00000000..def4034c --- /dev/null +++ b/src/Rule/Rules/Class_/EnumMethodMayNotBeProtectedRule.php @@ -0,0 +1,78 @@ +isInLayer($this->layer) + && $classNode->isEnum; + } + + public function evaluate(ClassNode $classNode): ?RuleViolation + { + return $this->evaluateAll($classNode)[0] ?? null; + } + + /** + * @return list + */ + public function evaluateAll(ClassNode $classNode): array + { + $violations = []; + + foreach ($classNode->methods as $method) { + if ($method->visibility !== 'protected') { + continue; + } + + $violations[] = new RuleViolation( + message: sprintf( + 'Enum method [%s::%s] may not be declared protected, use private instead', + $classNode->className, + $method->name + ), + file: $classNode->file, + line: $method->line !== 0 ? $method->line : $classNode->line, + className: $classNode->className, + layer: $classNode->layer, + methodName: $method->name, + ); + } + + return $violations; + } + + protected function createFixerVisitor(RuleViolation $ruleViolation): ChangeProtectedMethodToPrivateVisitor + { + /** @var string $methodName */ + $methodName = $ruleViolation->methodName; + + return new ChangeProtectedMethodToPrivateVisitor( + $ruleViolation->className, + $methodName + ); + } +} diff --git a/tests/Preset/PresetTest.php b/tests/Preset/PresetTest.php index a4174ee1..76df0b9e 100644 --- a/tests/Preset/PresetTest.php +++ b/tests/Preset/PresetTest.php @@ -142,6 +142,8 @@ public function testPerPresetAppliesPsr12RulesAndAddsEnumCaseRule(): void $this->assertArrayHasKey(Psr12Preset::CONSTANTS_MUST_DECLARE_VISIBILITY, $rules); $this->assertArrayHasKey(Psr12Preset::PROPERTIES_MUST_DECLARE_VISIBILITY, $rules); $this->assertArrayHasKey(PerPreset::ENUM_CASES_MUST_BE_PASCAL_CASE, $rules); + $this->assertArrayHasKey(PerPreset::ENUM_METHODS_MAY_NOT_BE_PROTECTED, $rules); + $this->assertArrayHasKey(PerPreset::ENUM_CONSTANTS_MAY_NOT_BE_PROTECTED, $rules); } public function testPerPresetUsesComposerSourcePathsByDefault(): void @@ -155,6 +157,14 @@ public function testPerPresetUsesComposerSourcePathsByDefault(): void PerPreset::ENUM_CASES_MUST_BE_PASCAL_CASE, $architecture->getRules() ); + $this->assertArrayHasKey( + PerPreset::ENUM_METHODS_MAY_NOT_BE_PROTECTED, + $architecture->getRules() + ); + $this->assertArrayHasKey( + PerPreset::ENUM_CONSTANTS_MAY_NOT_BE_PROTECTED, + $architecture->getRules() + ); } public function testPsr4PresetRegistersSourceLayerAndRules(): void diff --git a/tests/Rule/Class_/EnumConstantMayNotBeProtectedRuleTest.php b/tests/Rule/Class_/EnumConstantMayNotBeProtectedRuleTest.php new file mode 100644 index 00000000..904e8049 --- /dev/null +++ b/tests/Rule/Class_/EnumConstantMayNotBeProtectedRuleTest.php @@ -0,0 +1,157 @@ +assertTrue($enumConstantMayNotBeProtectedRule->appliesTo($this->makeNode([], 'Source'))); + $this->assertFalse($enumConstantMayNotBeProtectedRule->appliesTo($this->makeNode([], 'Other'))); + $this->assertFalse($enumConstantMayNotBeProtectedRule->appliesTo( + $this->makeNode([], 'Source', isEnum: false) + )); + } + + public function testEvaluateReturnsFirstViolation(): void + { + $enumConstantMayNotBeProtectedRule = new EnumConstantMayNotBeProtectedRule('Source'); + + $violation = $enumConstantMayNotBeProtectedRule->evaluate( + $this->makeNode([ + new ConstantNode('Grey', 'protected', hasExplicitVisibility: true), + new ConstantNode('Blue', 'protected', hasExplicitVisibility: true), + ]) + ); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame(1, $violation->line); + } + + #[DataProvider('allowedVisibilityProvider')] + public function testPassesNonProtectedConstants(string $visibility): void + { + $enumConstantMayNotBeProtectedRule = new EnumConstantMayNotBeProtectedRule('Source'); + + $this->assertSame( + [], + $enumConstantMayNotBeProtectedRule->evaluateAll( + $this->makeNode([new ConstantNode('Grey', $visibility, hasExplicitVisibility: true)]) + ) + ); + } + + /** @return iterable */ + public static function allowedVisibilityProvider(): iterable + { + yield 'public' => ['public']; + yield 'private' => ['private']; + } + + public function testViolatesProtectedConstants(): void + { + $enumConstantMayNotBeProtectedRule = new EnumConstantMayNotBeProtectedRule('Source'); + + $violations = $enumConstantMayNotBeProtectedRule->evaluateAll( + $this->makeNode([ + new ConstantNode('Grey', 'public', hasExplicitVisibility: true, line: 5), + new ConstantNode('Blue', 'protected', hasExplicitVisibility: true, line: 9), + new ConstantNode('Red', 'protected', hasExplicitVisibility: true, line: 13), + ]) + ); + + $this->assertCount(2, $violations); + $this->assertInstanceOf(RuleViolation::class, $violations[0]); + $this->assertSame(9, $violations[0]->line); + $this->assertSame( + 'Enum constant [App\\Status::Blue] may not be declared protected, use private instead', + $violations[0]->message + ); + $this->assertSame('Blue', $violations[0]->constantName); + $this->assertSame(13, $violations[1]->line); + } + + public function testFixChangesProtectedConstantToPrivate(): void + { + $file = tempnam(sys_get_temp_dir(), 'structarmed-enum-constant-'); + $this->assertIsString($file); + + file_put_contents($file, <<<'PHP' +assertTrue($enumConstantMayNotBeProtectedRule->fix(new RuleViolation( + message: 'Enum constant [Status::Grey] may not be declared protected, use private instead', + file: $file, + line: 5, + className: 'Status', + constantName: 'Grey', + ))); + + $this->assertStringContainsString( + " private const Grey = 'grey';", + (string) file_get_contents($file) + ); + } finally { + unlink($file); + } + } + + /** + * @param list $constants + */ + private function makeNode( + array $constants, + string $layer = 'Source', + bool $isEnum = true, + ): ClassNode { + return new ClassNode( + className: 'App\\Status', + file: '/fake.php', + line: 1, + layer: $layer, + extends: null, + isAbstract: false, + isFinal: false, + isInterface: false, + isReadonly: false, + constants: $constants, + isEnum: $isEnum, + ); + } +} diff --git a/tests/Rule/Class_/EnumMethodMayNotBeProtectedRuleTest.php b/tests/Rule/Class_/EnumMethodMayNotBeProtectedRuleTest.php new file mode 100644 index 00000000..c10315bb --- /dev/null +++ b/tests/Rule/Class_/EnumMethodMayNotBeProtectedRuleTest.php @@ -0,0 +1,175 @@ +assertTrue($enumMethodMayNotBeProtectedRule->appliesTo($this->makeNode([], 'Source'))); + $this->assertFalse($enumMethodMayNotBeProtectedRule->appliesTo($this->makeNode([], 'Other'))); + $this->assertFalse($enumMethodMayNotBeProtectedRule->appliesTo( + $this->makeNode([], 'Source', isEnum: false) + )); + } + + public function testEvaluateReturnsFirstViolation(): void + { + $enumMethodMayNotBeProtectedRule = new EnumMethodMayNotBeProtectedRule('Source'); + + $violation = $enumMethodMayNotBeProtectedRule->evaluate( + $this->makeNode([ + $this->makeMethod('label', 'protected'), + $this->makeMethod('color', 'protected'), + ]) + ); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame(1, $violation->line); + } + + #[DataProvider('allowedVisibilityProvider')] + public function testPassesNonProtectedMethods(string $visibility): void + { + $enumMethodMayNotBeProtectedRule = new EnumMethodMayNotBeProtectedRule('Source'); + + $this->assertSame( + [], + $enumMethodMayNotBeProtectedRule->evaluateAll( + $this->makeNode([$this->makeMethod('label', $visibility)]) + ) + ); + } + + /** @return iterable */ + public static function allowedVisibilityProvider(): iterable + { + yield 'public' => ['public']; + yield 'private' => ['private']; + } + + public function testViolatesProtectedMethods(): void + { + $enumMethodMayNotBeProtectedRule = new EnumMethodMayNotBeProtectedRule('Source'); + + $violations = $enumMethodMayNotBeProtectedRule->evaluateAll( + $this->makeNode([ + $this->makeMethod('label', 'public', line: 5), + $this->makeMethod('color', 'protected', line: 9), + $this->makeMethod('icon', 'protected', line: 13), + ]) + ); + + $this->assertCount(2, $violations); + $this->assertInstanceOf(RuleViolation::class, $violations[0]); + $this->assertSame(9, $violations[0]->line); + $this->assertSame( + 'Enum method [App\\Status::color] may not be declared protected, use private instead', + $violations[0]->message + ); + $this->assertSame('color', $violations[0]->methodName); + $this->assertSame(13, $violations[1]->line); + } + + public function testFixChangesProtectedMethodToPrivate(): void + { + $file = tempnam(sys_get_temp_dir(), 'structarmed-enum-method-'); + $this->assertIsString($file); + + file_put_contents($file, <<<'PHP' +assertTrue($enumMethodMayNotBeProtectedRule->fix(new RuleViolation( + message: 'Enum method [Status::color] may not be declared protected, use private instead', + file: $file, + line: 7, + className: 'Status', + methodName: 'color', + ))); + + $this->assertStringContainsString( + ' private static function color(): string', + (string) file_get_contents($file) + ); + } finally { + unlink($file); + } + } + + private function makeMethod(string $name, string $visibility, int $line = 0): MethodNode + { + return new MethodNode( + name: $name, + visibility: $visibility, + hasReturnType: true, + isStatic: false, + paramCount: 0, + cyclomaticComplexity: 1, + lineCount: 3, + hasExplicitVisibility: true, + line: $line, + ); + } + + /** + * @param list $methods + */ + private function makeNode( + array $methods, + string $layer = 'Source', + bool $isEnum = true, + ): ClassNode { + return new ClassNode( + className: 'App\\Status', + file: '/fake.php', + line: 1, + layer: $layer, + extends: null, + isAbstract: false, + isFinal: false, + isInterface: false, + isReadonly: false, + methods: $methods, + isEnum: $isEnum, + ); + } +} From 481efd805afaabd5a82fe98d6a6e91c971f0be50 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 1 Sep 2026 23:48:11 +0700 Subject: [PATCH 053/104] add more tests --- docs/presets.md | 2 +- tests/Analyser/AnalyserTest.php | 36 ++++++++ ...eProtectedConstantToPrivateVisitorTest.php | 92 +++++++++++++++++++ ...ngeProtectedMethodToPrivateVisitorTest.php | 85 +++++++++++++++++ 4 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 tests/Rule/Fixer/PhpParser/ClassConst/ChangeProtectedConstantToPrivateVisitorTest.php create mode 100644 tests/Rule/Fixer/PhpParser/ClassMethod/ChangeProtectedMethodToPrivateVisitorTest.php diff --git a/docs/presets.md b/docs/presets.md index 707896eb..13dfc041 100644 --- a/docs/presets.md +++ b/docs/presets.md @@ -19,11 +19,11 @@ StructArmed ships with presets for common PHP standards and architecture styles. | Preset | Rules | |---|---| +| `Preset::PSR4()` | Verifies configured source paths exist in composer.json `autoload` or `autoload-dev` PSR-4 mappings | | `Preset::PSR1()` | Basic Coding Standard checks: PHP tags, valid UTF-8, UTF-8 without BOM, symbols vs side effects, PSR-4 class placement, StudlyCaps class names, upper-case class constants, camelCase methods | | `Preset::PSR12()` | Extends PSR-1: all methods, constants, and properties must declare explicit visibility | | `Preset::PER()` | [PER Coding Style](https://www.php-fig.org/per/coding-style/): extends PSR-12 (and, through it, PSR-1) and adds PascalCase enum case names and no `protected` enum methods or constants | | `Preset::PSR15()` | `*Middleware` classes must implement PSR-15 `MiddlewareInterface`; `*Handler` classes must implement PSR-15 `RequestHandlerInterface`; StructArmed also enforces matching `Middleware`/`Handler` suffixes for implementations of those interfaces | -| `Preset::PSR4()` | Verifies configured source paths exist in composer.json `autoload` or `autoload-dev` PSR-4 mappings | | `Preset::DDD()` | Layer isolation, entity/VO/repository/event/service conventions, including keeping Doctrine ORM repository inheritance out of the Domain layer | | `Preset::MVC()` | Layer isolation, thin controllers, model/view/service rules, return types for helper functions | | `Preset::YAGNI()` | Speculative-abstraction cleanup: interfaces must be implemented by a class or extended by another interface, abstract classes must be extended, traits must be used, and extended classes that are never instantiated must be abstract — a dependency reference (type hint, `instanceof`, `::class`, static call, a class-name string, ...) also counts as usage within the scanned paths, while only instantiation (`new X`, `new self`/`static`/`parent`, or a constant class expression such as `new (X::class)`) keeps an extended class concrete. All rules support `--fix`, removing the unused declaration or adding the `abstract` modifier | diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index 0b675014..67923888 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -3682,6 +3682,42 @@ public function __construct(private QueryBuilder $db) {} ); } + public function testAnalyserRulesetSkipsGloballySkippedFileFromPreResolvedList(): void + { + $basePath = $this->makeTempProject([ + 'src/HTTP/Request.php' => <<<'PHP' + layerPattern('HTTP', '/^App\\\\HTTP\\\\.*$/') + ->layerPattern('Database', '/^App\\\\Database\\\\.*$/') + ->skip(['src/HTTP/']) + ->ruleset([ + 'HTTP' => [], // Database NOT allowed + ]); + + // A pre-resolved file list bypasses filesForAnalysis(), so the ruleset + // loop itself must honour the global skip paths. + $ruleViolationCollection = (new Analyser($basePath))->analyse( + $architecture, + ['src/'], + files: [$basePath . '/src/HTTP/Request.php'] + ); + + $this->assertFalse($ruleViolationCollection->hasViolations()); + } + #[DataProvider('rulesetClassLikeKindProvider')] public function testAnalyserRulesetViolationMessageNamesTheClassLikeKind(string $expectedKind, string $source): void { diff --git a/tests/Rule/Fixer/PhpParser/ClassConst/ChangeProtectedConstantToPrivateVisitorTest.php b/tests/Rule/Fixer/PhpParser/ClassConst/ChangeProtectedConstantToPrivateVisitorTest.php new file mode 100644 index 00000000..61e86b49 --- /dev/null +++ b/tests/Rule/Fixer/PhpParser/ClassConst/ChangeProtectedConstantToPrivateVisitorTest.php @@ -0,0 +1,92 @@ +makeClassConst('Grey', $flags); + $enum = new Enum_('Status', ['stmts' => [$classConst]]); + $changeProtectedConstantToPrivateVisitor = new ChangeProtectedConstantToPrivateVisitor('App\\Status', 'Grey'); + + $enum->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedConstantToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PRIVATE | Modifiers::FINAL, $classConst->flags); + } + + public function testDoesNotChangeConstantInNonEnumClassLike(): void + { + $classConst = $this->makeClassConst('Grey', Modifiers::PROTECTED); + $class = new Class_('Status', ['stmts' => [$classConst]]); + $changeProtectedConstantToPrivateVisitor = new ChangeProtectedConstantToPrivateVisitor('App\\Status', 'Grey'); + + $class->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedConstantToPrivateVisitor))->traverse([$class]); + + $this->assertSame(Modifiers::PROTECTED, $classConst->flags); + } + + public function testDoesNotChangeConstantInDifferentEnum(): void + { + $classConst = $this->makeClassConst('Grey', Modifiers::PROTECTED); + $enum = new Enum_('Suit', ['stmts' => [$classConst]]); + $changeProtectedConstantToPrivateVisitor = new ChangeProtectedConstantToPrivateVisitor('App\\Status', 'Grey'); + + $enum->namespacedName = new Name('App\\Suit'); + + (new NodeTraverser($changeProtectedConstantToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PROTECTED, $classConst->flags); + } + + public function testDoesNotChangeDifferentConstant(): void + { + $classConst = $this->makeClassConst('Blue', Modifiers::PROTECTED); + $enum = new Enum_('Status', ['stmts' => [$classConst]]); + $changeProtectedConstantToPrivateVisitor = new ChangeProtectedConstantToPrivateVisitor('App\\Status', 'Grey'); + + $enum->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedConstantToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PROTECTED, $classConst->flags); + } + + public function testDoesNotChangeNonProtectedConstant(): void + { + $classConst = $this->makeClassConst('Grey', Modifiers::PRIVATE); + $enum = new Enum_('Status', ['stmts' => [$classConst]]); + $changeProtectedConstantToPrivateVisitor = new ChangeProtectedConstantToPrivateVisitor('App\\Status', 'Grey'); + + $enum->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedConstantToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PRIVATE, $classConst->flags); + } + + private function makeClassConst(string $constantName, int $flags): ClassConst + { + return new ClassConst([new Const_($constantName, new Int_(1))], $flags); + } +} diff --git a/tests/Rule/Fixer/PhpParser/ClassMethod/ChangeProtectedMethodToPrivateVisitorTest.php b/tests/Rule/Fixer/PhpParser/ClassMethod/ChangeProtectedMethodToPrivateVisitorTest.php new file mode 100644 index 00000000..546ec9db --- /dev/null +++ b/tests/Rule/Fixer/PhpParser/ClassMethod/ChangeProtectedMethodToPrivateVisitorTest.php @@ -0,0 +1,85 @@ + $flags]); + $enum = new Enum_('Status', ['stmts' => [$classMethod]]); + $changeProtectedMethodToPrivateVisitor = new ChangeProtectedMethodToPrivateVisitor('App\\Status', 'color'); + + $enum->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedMethodToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PRIVATE | Modifiers::STATIC, $classMethod->flags); + } + + public function testDoesNotChangeMethodInNonEnumClassLike(): void + { + $classMethod = new ClassMethod('color', ['flags' => Modifiers::PROTECTED]); + $class = new Class_('Status', ['stmts' => [$classMethod]]); + $changeProtectedMethodToPrivateVisitor = new ChangeProtectedMethodToPrivateVisitor('App\\Status', 'color'); + + $class->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedMethodToPrivateVisitor))->traverse([$class]); + + $this->assertSame(Modifiers::PROTECTED, $classMethod->flags); + } + + public function testDoesNotChangeMethodInDifferentEnum(): void + { + $classMethod = new ClassMethod('color', ['flags' => Modifiers::PROTECTED]); + $enum = new Enum_('Suit', ['stmts' => [$classMethod]]); + $changeProtectedMethodToPrivateVisitor = new ChangeProtectedMethodToPrivateVisitor('App\\Status', 'color'); + + $enum->namespacedName = new Name('App\\Suit'); + + (new NodeTraverser($changeProtectedMethodToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PROTECTED, $classMethod->flags); + } + + public function testDoesNotChangeDifferentMethod(): void + { + $classMethod = new ClassMethod('label', ['flags' => Modifiers::PROTECTED]); + $enum = new Enum_('Status', ['stmts' => [$classMethod]]); + $changeProtectedMethodToPrivateVisitor = new ChangeProtectedMethodToPrivateVisitor('App\\Status', 'color'); + + $enum->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedMethodToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PROTECTED, $classMethod->flags); + } + + public function testDoesNotChangeNonProtectedMethod(): void + { + $classMethod = new ClassMethod('color', ['flags' => Modifiers::PRIVATE]); + $enum = new Enum_('Status', ['stmts' => [$classMethod]]); + $changeProtectedMethodToPrivateVisitor = new ChangeProtectedMethodToPrivateVisitor('App\\Status', 'color'); + + $enum->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedMethodToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PRIVATE, $classMethod->flags); + } +} From 6d50d056a72b58b227aabc53e65e689645e1aa3d Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 08:45:46 +0700 Subject: [PATCH 054/104] feat: add fixable MustUseLowercaseKeywordConstantRule for true/false/null spelling --- docs/available-rules.md | 3 +- src/Analyser/AnalysisNodeCollector.php | 60 +++ src/Analyser/AnalysisNodeExtractor.php | 25 +- src/Analyser/FileAnalysis.php | 8 + src/Analyser/FileAnalysisProvider.php | 9 +- src/Cache/AnalysisResultCache.php | 46 +- .../LowercaseKeywordConstantVisitor.php | 65 +++ .../MustUseLowercaseKeywordConstantRule.php | 114 +++++ tests/Analyser/AnalysisNodeCollectorTest.php | 57 +++ tests/Analyser/AnalysisNodeExtractorTest.php | 17 + tests/Cache/AnalysisResultCacheTest.php | 38 +- ...ustUseLowercaseKeywordConstantRuleTest.php | 429 ++++++++++++++++++ .../LowercaseKeywordConstantVisitorTest.php | 84 ++++ 13 files changed, 927 insertions(+), 28 deletions(-) create mode 100644 src/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitor.php create mode 100644 src/Rule/Rules/File/MustUseLowercaseKeywordConstantRule.php create mode 100644 tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php create mode 100644 tests/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitorTest.php diff --git a/docs/available-rules.md b/docs/available-rules.md index 151b6459..115624e9 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -63,6 +63,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\File`. | `Psr1SymbolsOrSideEffectsRule` | `new Psr1SymbolsOrSideEffectsRule(sourcePaths: ['src/'])` | A file declares symbols or causes side effects, but does not do both. | | `Psr1ValidUtf8Rule` | `new Psr1ValidUtf8Rule(sourcePaths: ['src/'])` | PHP files use valid UTF-8 encoding. | | `Psr1Utf8WithoutBomRule` | `new Psr1Utf8WithoutBomRule(sourcePaths: ['src/'])` | PHP files do not start with a byte order mark. Supports `--fix`. | +| `MustUseLowercaseKeywordConstantRule` | `new MustUseLowercaseKeywordConstantRule(sourcePaths: ['src/'])` | PHP's special keyword constants `true`, `false`, and `null` use their canonical lowercase spelling. Fully qualified forms such as `\TRUE` are preserved as `\true`. Supports `--fix`. | {: .rule-table } Pass `sourcePaths: null` or omit it to let the rule read PSR-4 paths from `composer.json`. @@ -99,7 +100,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`. `classNamePattern` and `excludePattern` are regular expressions matched against the fully-qualified class name. -`Psr4DirectoryExistsRule`, `Psr1PhpTagsRule`, `Psr1Utf8WithoutBomRule`, `ExtendedClassMustBeAbstractOrInstantiatedRule`, `MustBeFinalRule`, `MustBeUsedInterfaceRule`, `MustBeUsedAbstractClassRule`, `MustBeUsedTraitRule`, `MustDeclareConstantVisibilityRule`, `MustDeclareMethodVisibilityRule`, and `MustDeclarePropertyVisibilityRule` implement `Boundwize\StructArmed\Rule\FixableInterface`, so StructArmed can automatically remove PSR-4 mappings for missing directories, normalize invalid PHP opening tags, remove UTF-8 byte order marks, add the `final` or `abstract` class modifier, remove unused interfaces, abstract classes, and traits (deleting their file when only `declare`/`namespace`/`use` boilerplate remains), and add missing constant, method, or property visibility modifiers when you run `vendor/bin/structarmed analyse --fix`. +`Psr4DirectoryExistsRule`, `Psr1PhpTagsRule`, `Psr1Utf8WithoutBomRule`, `MustUseLowercaseKeywordConstantRule`, `ExtendedClassMustBeAbstractOrInstantiatedRule`, `MustBeFinalRule`, `MustBeUsedInterfaceRule`, `MustBeUsedAbstractClassRule`, `MustBeUsedTraitRule`, `MustDeclareConstantVisibilityRule`, `MustDeclareMethodVisibilityRule`, and `MustDeclarePropertyVisibilityRule` implement `Boundwize\StructArmed\Rule\FixableInterface`, so StructArmed can automatically remove PSR-4 mappings for missing directories, normalize invalid PHP opening tags, remove UTF-8 byte order marks, lowercase `TRUE`/`FALSE`/`NULL` keyword constants, add the `final` or `abstract` class modifier, remove unused interfaces, abstract classes, and traits (deleting their file when only `declare`/`namespace`/`use` boilerplate remains), and add missing constant, method, or property visibility modifiers when you run `vendor/bin/structarmed analyse --fix`. ## Function Rules diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index d8cb1ba7..f9afbf01 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -21,6 +21,7 @@ use PhpParser\Node\Expr\BinaryOp\LogicalOr; use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Expr\Closure; +use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\Empty_; use PhpParser\Node\Expr\Eval_; use PhpParser\Node\Expr\Exit_; @@ -84,6 +85,7 @@ use function spl_object_id; use function str_starts_with; use function strcasecmp; +use function strlen; use function strpos; use function strtolower; use function substr; @@ -212,6 +214,7 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract ArrowFunction::class => true, String_::class => true, FullyQualified::class => true, + ConstFetch::class => true, Variable::class => true, FuncCall::class => true, Exit_::class => true, @@ -272,6 +275,16 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract /** @var array */ private array $currentFileInstantiations = []; + /** + * `true`, `false`, and `null` fetches of the current file whose spelling is + * not the canonical lowercase, as [line, spelling as written]; a leading + * `\` marks a fully qualified form. Reset per file instead of in + * afterTraverse() so the extractor can read them once traversal finishes. + * + * @var list + */ + private array $nonCanonicalKeywordConstants = []; + private readonly ConstExprEvaluator $constExprEvaluator; /** @@ -370,6 +383,7 @@ public function setCurrentFile(string $file): void $this->currentFile = $file; $this->currentFileReferences = []; $this->currentFileInstantiations = []; + $this->nonCanonicalKeywordConstants = []; $this->currentNamespaceUses = []; $this->fileClassLikes = []; $this->fileFunctions = []; @@ -419,6 +433,17 @@ public function getFileReferences(): array return $this->fileReferences; } + /** + * Keyword constants of the file traversed last that are not spelled in + * lowercase, see MustUseLowercaseKeywordConstantRule. + * + * @return list + */ + public function getNonCanonicalKeywordConstants(): array + { + return $this->nonCanonicalKeywordConstants; + } + /** * Class-like instantiations (`new X`, with self/static/parent resolved to * the class names they target), per file. `new` on an abstract class is @@ -932,6 +957,12 @@ private function collectNodeAnalysis(Node $node): void return; } + if ($node instanceof ConstFetch) { + $this->collectKeywordConstant($node->name); + + return; + } + if ($this->activeClassLikeAnalyses === [] && $this->activeFunctionLikeAnalyses === []) { return; } @@ -1015,6 +1046,35 @@ private function collectNodeAnalysis(Node $node): void } } + /** + * Records a `true`, `false`, or `null` fetch whose spelling is not the + * canonical lowercase. The NameResolver has already replaced an unqualified + * name outside a namespace with a FullyQualified clone, so whether the `\` + * was written is read from the source span instead: it is one character + * longer than the name. Other spellings, such as `namespace\TRUE`, are + * out of scope. + */ + private function collectKeywordConstant(Name $name): void + { + $spelling = $name->toString(); + $keyword = strtolower($spelling); + + if (! isset(self::KEYWORD_CONSTANTS[$keyword]) || $spelling === $keyword) { + return; + } + + $spanLength = $name->getEndFilePos() - $name->getStartFilePos() + 1; + $nameLength = strlen($spelling); + + if ($spanLength === $nameLength + 1) { + $spelling = '\\' . $spelling; + } elseif ($spanLength !== $nameLength) { + return; + } + + $this->nonCanonicalKeywordConstants[] = [$name->getStartLine(), $spelling]; + } + private function collectInstantiation(New_ $new): void { $class = $new->class; diff --git a/src/Analyser/AnalysisNodeExtractor.php b/src/Analyser/AnalysisNodeExtractor.php index 5619553d..24d7b5f3 100644 --- a/src/Analyser/AnalysisNodeExtractor.php +++ b/src/Analyser/AnalysisNodeExtractor.php @@ -5,6 +5,7 @@ namespace Boundwize\StructArmed\Analyser; use Boundwize\StructArmed\Cache\AnalysisResultCache; +use Boundwize\StructArmed\LayerResolver\ChainLayerResolver; use Boundwize\StructArmed\LayerResolver\LayerResolverInterface; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; use PhpParser\NodeTraverser; @@ -18,11 +19,14 @@ private FileAnalysisProvider $fileAnalysisProvider; /** + * @param LayerResolverInterface $layerResolver Resolves no layers by default, which is enough + * for rules that only need the file facts and + * run outside the analyser. * @param AnalysisResultCache|null $analysisResultCache When given, every extracted file's nodes * are stored under $analysisNodeCacheNamespace. */ public function __construct( - private LayerResolverInterface $layerResolver, + private LayerResolverInterface $layerResolver = new ChainLayerResolver(), ?FileAnalysisProvider $fileAnalysisProvider = null, private ?AnalysisResultCache $analysisResultCache = null, private string $analysisNodeCacheNamespace = '', @@ -42,18 +46,21 @@ public function extract( foreach ($files as $file) { try { - $ast = $this->fileAnalysisProvider->ast($file, $withFileAnalysis); + $ast = $this->fileAnalysisProvider->ast($file, $withFileAnalysis); + $nonCanonicalKeywordConstants = []; - if ($withFileAnalysis) { - $fileAnalyses[$file] = $this->fileAnalysisProvider->analyse($file); - } + if ($ast !== null && $ast !== []) { + $analysisNodeCollector->setCurrentFile($file); + $nodeTraverser->traverse($ast); - if ($ast === null || $ast === []) { - continue; + $nonCanonicalKeywordConstants = $analysisNodeCollector->getNonCanonicalKeywordConstants(); } - $analysisNodeCollector->setCurrentFile($file); - $nodeTraverser->traverse($ast); + // Analysed after the traversal so the facts only the collector + // records reach the file analysis without a second AST walk. + if ($withFileAnalysis) { + $fileAnalyses[$file] = $this->fileAnalysisProvider->analyse($file, $nonCanonicalKeywordConstants); + } } finally { if ($withFileAnalysis) { $this->fileAnalysisProvider->releaseAst($file); diff --git a/src/Analyser/FileAnalysis.php b/src/Analyser/FileAnalysis.php index 241a3dfe..7f15ae8f 100644 --- a/src/Analyser/FileAnalysis.php +++ b/src/Analyser/FileAnalysis.php @@ -6,6 +6,13 @@ final readonly class FileAnalysis { + /** + * @param list $nonCanonicalKeywordConstants `true`, `false`, and `null` + * fetches not spelled in lowercase, + * as [line, spelling as written]; a + * leading `\` marks a fully + * qualified form such as `\TRUE`. + */ public function __construct( public string $file, public bool $hasUtf8Bom, @@ -15,6 +22,7 @@ public function __construct( public bool $declaresSymbols, public bool $hasSideEffects, public int $sideEffectLine, + public array $nonCanonicalKeywordConstants = [], ) { } } diff --git a/src/Analyser/FileAnalysisProvider.php b/src/Analyser/FileAnalysisProvider.php index 2c1598c5..5bbb18d2 100644 --- a/src/Analyser/FileAnalysisProvider.php +++ b/src/Analyser/FileAnalysisProvider.php @@ -131,7 +131,13 @@ private static function normaliseAnalyses(array $analyses): array return $normalisedAnalyses; } - public function analyse(string $file): FileAnalysis + /** + * @param list $nonCanonicalKeywordConstants Keyword constant spellings the + * analysis-node traversal recorded + * for the file; the provider never + * walks the AST for them itself. + */ + public function analyse(string $file, array $nonCanonicalKeywordConstants = []): FileAnalysis { $file = Path::normalise($file, canonicalise: true); @@ -157,6 +163,7 @@ public function analyse(string $file): FileAnalysis declaresSymbols: $fileState['declaresSymbols'], hasSideEffects: $fileState['hasSideEffects'], sideEffectLine: $fileState['sideEffectLine'], + nonCanonicalKeywordConstants: $nonCanonicalKeywordConstants, ); $this->analyses[$file] = $fileAnalysis; diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 020ccdb2..5959e541 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -19,10 +19,12 @@ use Boundwize\StructArmed\Rule\RuleViolationCollection; use function array_fill_keys; +use function array_is_list; use function array_key_exists; use function array_keys; use function array_map; use function array_values; +use function count; use function file_exists; use function file_get_contents; use function file_put_contents; @@ -63,7 +65,7 @@ final class AnalysisResultCache * their shape or naming changes: it is recorded in the metadata marker, * so a cache written by an older format is cleared on its next use. */ - public const FORMAT_VERSION = 1; + public const FORMAT_VERSION = 2; private readonly string $cacheDirectory; @@ -1229,14 +1231,15 @@ private function enumCaseNodeFromArray(array $enumCase): ?EnumCaseNode private function fileAnalysisToArray(FileAnalysis $fileAnalysis): array { return [ - 'file' => $fileAnalysis->file, - 'hasUtf8Bom' => $fileAnalysis->hasUtf8Bom, - 'hasValidUtf8' => $fileAnalysis->hasValidUtf8, - 'invalidPhpTagLine' => $fileAnalysis->invalidPhpTagLine, - 'hasValidAst' => $fileAnalysis->hasValidAst, - 'declaresSymbols' => $fileAnalysis->declaresSymbols, - 'hasSideEffects' => $fileAnalysis->hasSideEffects, - 'sideEffectLine' => $fileAnalysis->sideEffectLine, + 'file' => $fileAnalysis->file, + 'hasUtf8Bom' => $fileAnalysis->hasUtf8Bom, + 'hasValidUtf8' => $fileAnalysis->hasValidUtf8, + 'invalidPhpTagLine' => $fileAnalysis->invalidPhpTagLine, + 'hasValidAst' => $fileAnalysis->hasValidAst, + 'declaresSymbols' => $fileAnalysis->declaresSymbols, + 'hasSideEffects' => $fileAnalysis->hasSideEffects, + 'sideEffectLine' => $fileAnalysis->sideEffectLine, + 'nonCanonicalKeywordConstants' => $fileAnalysis->nonCanonicalKeywordConstants, ]; } @@ -1253,6 +1256,7 @@ private function fileAnalysisFromArray(array $analysis): ?FileAnalysis || ! is_bool($analysis['declaresSymbols'] ?? null) || ! is_bool($analysis['hasSideEffects'] ?? null) || ! is_int($analysis['sideEffectLine'] ?? null) + || ! $this->isKeywordConstantList($analysis['nonCanonicalKeywordConstants'] ?? null) ) { return null; } @@ -1266,9 +1270,33 @@ private function fileAnalysisFromArray(array $analysis): ?FileAnalysis declaresSymbols: $analysis['declaresSymbols'], hasSideEffects: $analysis['hasSideEffects'], sideEffectLine: $analysis['sideEffectLine'], + nonCanonicalKeywordConstants: $analysis['nonCanonicalKeywordConstants'], ); } + /** + * @phpstan-assert-if-true list $value + */ + private function isKeywordConstantList(mixed $value): bool + { + if (! is_array($value) || ! array_is_list($value)) { + return false; + } + + foreach ($value as $keywordConstant) { + if ( + ! is_array($keywordConstant) + || count($keywordConstant) !== 2 + || ! is_int($keywordConstant[0] ?? null) + || ! is_string($keywordConstant[1] ?? null) + ) { + return false; + } + } + + return true; + } + /** * @param array $array * @phpstan-assert-if-true array $array diff --git a/src/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitor.php b/src/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitor.php new file mode 100644 index 00000000..4cedaea2 --- /dev/null +++ b/src/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitor.php @@ -0,0 +1,65 @@ + true, + 'false' => true, + 'null' => true, + ]; + + /** + * @param string|null $spelling The spelling as written without a leading `\`, e.g. `TRUE`; + * null lowercases every keyword constant on the line. + */ + public function __construct( + private readonly int $line, + private readonly ?string $spelling = null, + ) { + } + + public function enterNode(Node $node): ?Node + { + if (! $node instanceof ConstFetch || $node->getStartLine() !== $this->line) { + return null; + } + + $name = $node->name; + $spelling = $name->toString(); + $keyword = strtolower($spelling); + + if ( + ! isset(self::KEYWORD_CONSTANTS[$keyword]) + || $spelling === $keyword + || ($this->spelling !== null && $spelling !== $this->spelling) + ) { + return null; + } + + $node->name = match (true) { + $name instanceof FullyQualified => new FullyQualified($keyword, $name->getAttributes()), + $name instanceof Relative => new Relative($keyword, $name->getAttributes()), + default => new Name($keyword, $name->getAttributes()), + }; + + return $node; + } +} diff --git a/src/Rule/Rules/File/MustUseLowercaseKeywordConstantRule.php b/src/Rule/Rules/File/MustUseLowercaseKeywordConstantRule.php new file mode 100644 index 00000000..9c852238 --- /dev/null +++ b/src/Rule/Rules/File/MustUseLowercaseKeywordConstantRule.php @@ -0,0 +1,114 @@ +|null $sourcePaths + */ + public function __construct( + ?array $sourcePaths = null, + ?PhpFileFinder $phpFileFinder = null, + ) { + $this->phpFileFinder = $phpFileFinder ?? new PhpFileFinder($sourcePaths); + } + + public function evaluateProject(string $basePath, Architecture $architecture, array $skipPaths = []): ?RuleViolation + { + return $this->evaluateProjectAll($basePath, $architecture, $skipPaths)[0] ?? null; + } + + /** + * @param list $skipPaths + * @return RuleViolation[] + */ + public function evaluateProjectAll(string $basePath, Architecture $architecture, array $skipPaths = []): array + { + $files = $this->phpFileFinder->files($basePath, $skipPaths); + + // Outside the analyser the spellings come from the same traversal it + // runs, so there is a single place that recognises them. + $extractionResult = (new AnalysisNodeExtractor())->extract($files); + + return $this->evaluateFiles($files, new FileAnalysisProvider($extractionResult->fileAnalyses)); + } + + /** + * @param list $skipPaths + * @return RuleViolation[] + */ + public function evaluateProjectAllWithProvider( + string $basePath, + Architecture $architecture, + FileAnalysisProvider $fileAnalysisProvider, + array $skipPaths = [], + ): array { + return $this->evaluateFiles( + $this->phpFileFinder->filesFromScope( + $basePath, + $fileAnalysisProvider->scopeFiles(), + $skipPaths, + ), + $fileAnalysisProvider, + ); + } + + protected function createFixerVisitor(RuleViolation $ruleViolation): NodeVisitor + { + return new LowercaseKeywordConstantVisitor($ruleViolation->line, $ruleViolation->constantName); + } + + /** + * @param list $files + * @return list + */ + private function evaluateFiles(array $files, FileAnalysisProvider $fileAnalysisProvider): array + { + $violations = []; + + foreach ($files as $file) { + $fileAnalysis = $fileAnalysisProvider->analyse($file); + $fileAnalysisProvider->releaseAst($file); + + foreach ($fileAnalysis->nonCanonicalKeywordConstants as [$line, $spelling]) { + $violations[] = new RuleViolation( + message: sprintf( + 'Keyword constant [%s] must use lowercase [%s]', + $spelling, + strtolower($spelling), + ), + file: $file, + line: $line, + className: '', + constantName: ltrim($spelling, '\\'), + ); + } + } + + return $violations; + } +} diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index 7704e0dc..207a2ef7 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -29,6 +29,63 @@ final class AnalysisNodeCollectorTest extends TestCase { private const BASE_PATH = '/structarmed-test-project'; + public function testCollectsNonCanonicalKeywordConstantSpellings(): void + { + $analysisNodeCollector = $this->makeCollector(<<<'PHP' +assertSame( + [[3, 'TRUE'], [4, '\\NULL'], [11, 'False'], [11, '\\tRuE']], + $analysisNodeCollector->getNonCanonicalKeywordConstants() + ); + } + + public function testDistinguishesUnqualifiedFromFullyQualifiedKeywordConstantInsideNamespace(): void + { + $analysisNodeCollector = $this->makeCollector(<<<'PHP' +assertSame([[5, 'NULL'], [6, '\\NULL']], $analysisNodeCollector->getNonCanonicalKeywordConstants()); + } + + public function testIgnoresRelativeKeywordConstantSpellingOutsideNamespace(): void + { + $analysisNodeCollector = $this->makeCollector('assertSame([], $analysisNodeCollector->getNonCanonicalKeywordConstants()); + } + + public function testResetsKeywordConstantSpellingsPerFile(): void + { + $analysisNodeCollector = $this->makeCollector('assertSame([[1, 'TRUE']], $analysisNodeCollector->getNonCanonicalKeywordConstants()); + + $analysisNodeCollector->setCurrentFile('/fake/path/Bar.php'); + + $this->assertSame([], $analysisNodeCollector->getNonCanonicalKeywordConstants()); + } + private function collect(string $code): ClassNode { $nodes = $this->collectNodes($code); diff --git a/tests/Analyser/AnalysisNodeExtractorTest.php b/tests/Analyser/AnalysisNodeExtractorTest.php index 7448f4b5..8e7ef7a5 100644 --- a/tests/Analyser/AnalysisNodeExtractorTest.php +++ b/tests/Analyser/AnalysisNodeExtractorTest.php @@ -57,6 +57,23 @@ final class Foo $this->assertSame('App\\Domain\\Foo', $extractionResult->classNodes[0]->className); } + public function testExtractRecordsKeywordConstantSpellingsInFileAnalysis(): void + { + $dir = $this->makeTemporaryDirectory('structarmed-extractor-test'); + $file = $dir . '/Foo.php'; + + file_put_contents($file, " 'App\\Domain'], $dir); + $extractionResult = (new AnalysisNodeExtractor($namespaceLayerResolver))->extract([$file]); + + $this->assertSame( + [[3, 'TRUE'], [3, '\\NULL']], + $extractionResult->fileAnalyses[$file]->nonCanonicalKeywordConstants + ); + $this->assertTrue($extractionResult->fileAnalyses[$file]->hasSideEffects); + } + public function testExtractSkipsFilesWithParseErrors(): void { $dir = $this->makeTemporaryDirectory('structarmed-extractor-test'); diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index 50d17dfa..82b64ec6 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -1536,6 +1536,7 @@ public function testStoresAndLoadsClassNodesWithFileAnalysis(): void declaresSymbols: true, hasSideEffects: false, sideEffectLine: 1, + nonCanonicalKeywordConstants: [[3, 'TRUE'], [5, '\\NULL']], ); file_put_contents($sourceFile, ' __FILE__, - 'hasUtf8Bom' => false, - 'hasValidUtf8' => true, - 'invalidPhpTagLine' => null, - 'hasValidAst' => true, - 'declaresSymbols' => true, - 'hasSideEffects' => false, - 'sideEffectLine' => 1, + 'file' => __FILE__, + 'hasUtf8Bom' => false, + 'hasValidUtf8' => true, + 'invalidPhpTagLine' => null, + 'hasValidAst' => true, + 'declaresSymbols' => true, + 'hasSideEffects' => false, + 'sideEffectLine' => 1, + 'nonCanonicalKeywordConstants' => [], ]; yield 'numeric keys' => [[0 => 'bad']]; @@ -1609,6 +1611,26 @@ public static function malformedFileAnalysisProvider(): iterable yield 'invalid declaration flag' => [[...$valid, 'declaresSymbols' => 'bad']]; yield 'invalid side-effects flag' => [[...$valid, 'hasSideEffects' => 'bad']]; yield 'invalid side-effect line' => [[...$valid, 'sideEffectLine' => 'bad']]; + yield 'missing keyword constants' => [ + [ + 'file' => __FILE__, + 'hasUtf8Bom' => false, + 'hasValidUtf8' => true, + 'invalidPhpTagLine' => null, + 'hasValidAst' => true, + 'declaresSymbols' => true, + 'hasSideEffects' => false, + 'sideEffectLine' => 1, + ], + ]; + yield 'invalid keyword constants type' => [[...$valid, 'nonCanonicalKeywordConstants' => 'bad']]; + yield 'keyword constants not a list' => [[...$valid, 'nonCanonicalKeywordConstants' => ['a' => [1, 'TRUE']]]]; + yield 'keyword constant not a pair' => [[...$valid, 'nonCanonicalKeywordConstants' => [[1]]]]; + yield 'keyword constant with extra entry' => [ + [...$valid, 'nonCanonicalKeywordConstants' => [[1, 'TRUE', 'extra']]], + ]; + yield 'keyword constant with invalid line' => [[...$valid, 'nonCanonicalKeywordConstants' => [['1', 'TRUE']]]]; + yield 'keyword constant with invalid spelling' => [[...$valid, 'nonCanonicalKeywordConstants' => [[1, 1]]]]; } /** @param array $fileAnalysis */ diff --git a/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php b/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php new file mode 100644 index 00000000..dafb4160 --- /dev/null +++ b/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php @@ -0,0 +1,429 @@ +assertInstanceOf(FixableInterface::class, new MustUseLowercaseKeywordConstantRule(['src/'])); + } + + #[DataProvider('unqualifiedSpellingProvider')] + public function testViolatesAndFixesUnqualifiedSpelling(string $spelling): void + { + $basePath = $this->makeProject("evaluateProjectAll($basePath, Architecture::define()); + + $this->assertCount(1, $violations); + $this->assertSame(3, $violations[0]->line); + $this->assertSame($basePath . '/src/Foo.php', $violations[0]->file); + $this->assertSame( + 'Keyword constant [' . $spelling . '] must use lowercase [' . strtolower($spelling) . ']', + $violations[0]->message + ); + $this->assertSame($spelling, $violations[0]->constantName); + + $this->assertTrue($mustUseLowercaseKeywordConstantRule->fix($violations[0])); + $this->assertSame( + "assertSame( + [], + $mustUseLowercaseKeywordConstantRule->evaluateProjectAll($basePath, Architecture::define()) + ); + } + + /** @return iterable */ + public static function unqualifiedSpellingProvider(): iterable + { + foreach (['TRUE', 'True', 'tRuE', 'FALSE', 'False', 'fAlSe', 'NULL', 'Null', 'nUlL'] as $spelling) { + yield $spelling => [$spelling]; + } + } + + #[DataProvider('unqualifiedSpellingProvider')] + public function testViolatesAndFixesFullyQualifiedSpellingKeepingTheLeadingBackslash(string $spelling): void + { + $basePath = $this->makeProject("evaluateProjectAll($basePath, Architecture::define()); + + $this->assertCount(1, $violations); + $this->assertSame(3, $violations[0]->line); + $this->assertSame( + 'Keyword constant [\\' . $spelling . '] must use lowercase [\\' . strtolower($spelling) . ']', + $violations[0]->message + ); + $this->assertSame($spelling, $violations[0]->constantName); + + $this->assertTrue($mustUseLowercaseKeywordConstantRule->fix($violations[0])); + $this->assertSame( + "makeProject(<<<'PHP' +assertSame( + [], + $mustUseLowercaseKeywordConstantRule->evaluateProjectAll($basePath, Architecture::define()) + ); + $this->assertNotInstanceOf( + RuleViolation::class, + $mustUseLowercaseKeywordConstantRule->evaluateProject($basePath, Architecture::define()) + ); + } + + public function testIgnoresUnrelatedConstants(): void + { + $basePath = $this->makeProject(<<<'PHP' +evaluateProjectAll($basePath, Architecture::define()); + + $this->assertSame([], $violations); + $this->assertStringContainsString('SomeClass::FOO;', (string) file_get_contents($basePath . '/src/Foo.php')); + } + + public function testReportsUnqualifiedSpellingInsideNamespace(): void + { + $basePath = $this->makeProject(<<<'PHP' +evaluateProjectAll($basePath, Architecture::define()); + + $this->assertCount(1, $violations); + $this->assertSame(7, $violations[0]->line); + $this->assertSame('Keyword constant [TRUE] must use lowercase [true]', $violations[0]->message); + } + + public function testFixesNestedExpressionsPreservingSurroundingCode(): void + { + $basePath = $this->makeProject(<<<'PHP' +evaluateProjectAll( + $basePath, + Architecture::define() + ); + + $this->assertSame( + [ + 'Keyword constant [NULL] must use lowercase [null]', + 'Keyword constant [FALSE] must use lowercase [false]', + 'Keyword constant [\\TRUE] must use lowercase [\\true]', + 'Keyword constant [\\NULL] must use lowercase [\\null]', + 'Keyword constant [\\FALSE] must use lowercase [\\false]', + 'Keyword constant [TRUE] must use lowercase [true]', + ], + array_map(static fn (RuleViolation $ruleViolation): string => $ruleViolation->message, $violations) + ); + $this->assertSame([6, 8, 10, 10, 10, 12], array_map( + static fn (RuleViolation $ruleViolation): int => $ruleViolation->line, + $violations + )); + + foreach ($violations as $violation) { + $this->assertTrue($mustUseLowercaseKeywordConstantRule->fix($violation)); + } + + $this->assertSame(<<<'PHP' +makeProject(<<<'PHP' +evaluateProjectAll( + $basePath, + Architecture::define() + ); + + $this->assertCount(3, $violations); + + foreach ($violations as $violation) { + $this->assertTrue($mustUseLowercaseKeywordConstantRule->fix($violation)); + } + + $this->assertSame(<<<'PHP' +assertSame( + [], + $mustUseLowercaseKeywordConstantRule->evaluateProjectAll($basePath, Architecture::define()) + ); + } + + public function testFixesIdenticalSpellingsOnTheSameLineInOnePass(): void + { + $basePath = $this->makeProject("evaluateProjectAll( + $basePath, + Architecture::define() + ); + + $this->assertCount(2, $violations); + $this->assertTrue($mustUseLowercaseKeywordConstantRule->fix($violations[0])); + $this->assertSame("assertFalse($mustUseLowercaseKeywordConstantRule->fix($violations[1])); + } + + public function testSkipsFilesWithParseErrors(): void + { + $basePath = $this->makeProject('evaluateProjectAll($basePath, Architecture::define()); + + $this->assertSame([], $violations); + } + + public function testEvaluatesProvidedFileAnalysesWithinSourcePaths(): void + { + $basePath = $this->makeProject(" $this->makeFileAnalysis($sourceFile, [[3, '\\TRUE'], [9, 'Null']]), + $testFile => $this->makeFileAnalysis($testFile, [[3, 'NULL']]), + ], + [$sourceFile, $testFile], + ); + + $violations = (new MustUseLowercaseKeywordConstantRule(['src/']))->evaluateProjectAllWithProvider( + $basePath, + Architecture::define(), + $fileAnalysisProvider, + ); + + $this->assertCount(2, $violations); + $this->assertSame($sourceFile, $violations[0]->file); + $this->assertSame(3, $violations[0]->line); + $this->assertSame('Keyword constant [\\TRUE] must use lowercase [\\true]', $violations[0]->message); + $this->assertSame('TRUE', $violations[0]->constantName); + $this->assertSame(9, $violations[1]->line); + $this->assertSame('Null', $violations[1]->constantName); + } + + public function testFixWithoutConstantNameLowercasesFirstKeywordConstantOnLine(): void + { + $basePath = $this->makeProject("assertTrue($mustUseLowercaseKeywordConstantRule->fix(new RuleViolation( + message: 'Keyword constant [Null] must use lowercase [null]', + file: $basePath . '/src/Foo.php', + line: 3, + className: '', + ))); + $this->assertSame("assertFalse($mustUseLowercaseKeywordConstantRule->fix(new RuleViolation( + message: 'Keyword constant [TRUE] must use lowercase [true]', + file: '/missing/Foo.php', + line: 1, + className: '', + ))); + } + + public function testAnalyserReportsAndFixesThroughTheCollectedFileAnalysis(): void + { + $basePath = $this->makeProject(<<<'PHP' +layer('Source', 'src/') + ->rule('keyword.lowercase', $mustUseLowercaseKeywordConstantRule); + + foreach ([AnalyserOptions::sequential(), AnalyserOptions::parallel(2)] as $analyserOptions) { + $violations = array_values(iterator_to_array( + (new Analyser($basePath))->analyse($architecture, [], null, $analyserOptions) + )); + + $this->assertCount(2, $violations); + $this->assertSame('keyword.lowercase', $violations[0]->ruleKey); + $this->assertTrue($violations[0]->fixable); + $this->assertSame(9, $violations[0]->line); + $this->assertSame('Keyword constant [\\TRUE] must use lowercase [\\true]', $violations[0]->message); + $this->assertSame('Keyword constant [Null] must use lowercase [null]', $violations[1]->message); + } + + foreach ($violations as $violation) { + $this->assertTrue($mustUseLowercaseKeywordConstantRule->fix($violation)); + } + + $this->assertStringContainsString( + 'return \true ? null : false;', + (string) file_get_contents($basePath . '/src/Foo.php') + ); + $this->assertCount( + 0, + (new Analyser($basePath))->analyse($architecture, [], null, AnalyserOptions::sequential()) + ); + } + + private function makeProject(string $code): string + { + $basePath = $this->makeTemporaryDirectory('structarmed-keyword-constant'); + mkdir($basePath . '/src'); + file_put_contents($basePath . '/src/Foo.php', $code); + + // Violations carry canonical paths, which differ from the temporary directory on macOS. + $realBasePath = realpath($basePath); + $this->assertIsString($realBasePath); + + return $realBasePath; + } + + /** @param list $nonCanonicalKeywordConstants */ + private function makeFileAnalysis(string $file, array $nonCanonicalKeywordConstants): FileAnalysis + { + return new FileAnalysis( + file: $file, + hasUtf8Bom: false, + hasValidUtf8: true, + invalidPhpTagLine: null, + hasValidAst: true, + declaresSymbols: false, + hasSideEffects: true, + sideEffectLine: 3, + nonCanonicalKeywordConstants: $nonCanonicalKeywordConstants, + ); + } +} diff --git a/tests/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitorTest.php b/tests/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitorTest.php new file mode 100644 index 00000000..82e380ee --- /dev/null +++ b/tests/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitorTest.php @@ -0,0 +1,84 @@ +assertSame( + "\$a = FOO;\n\$b = true;", + $this->apply("assertSame( + 'return \null;', + $this->apply('assertSame( + 'return namespace\false;', + $this->apply('assertSame( + "\$a = TRUE;\n\$b = FALSE;", + $this->apply("assertSame( + '$a = FOO ?? \BAR ?? Foo\BAZ ?? Some::TRUE ?? true ?? \null;', + $this->apply($code, new LowercaseKeywordConstantVisitor(1)) + ); + } + + public function testMatchesOnlyTheGivenSpelling(): void + { + $this->assertSame( + '$a = True && true;', + $this->apply('assertSame( + '$a = true && true;', + $this->apply('createForNewestSupportedVersion()->parse($code); + $this->assertNotNull($statements); + + $statements = (new NodeTraverser($lowercaseKeywordConstantVisitor))->traverse($statements); + + return (new Standard())->prettyPrint($statements); + } +} From 2315ec4f09b75db9e7577e51ac3921bcb8e4d747 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 08:54:32 +0700 Subject: [PATCH 055/104] fix spelling --- .../LowercaseKeywordConstantVisitor.php | 19 +++++++++-------- ...ustUseLowercaseKeywordConstantRuleTest.php | 21 ++++++++++++------- .../LowercaseKeywordConstantVisitorTest.php | 16 ++++++++++---- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitor.php b/src/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitor.php index 4cedaea2..e3e46eb7 100644 --- a/src/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitor.php +++ b/src/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitor.php @@ -27,18 +27,23 @@ final class LowercaseKeywordConstantVisitor extends NodeVisitorAbstract ]; /** - * @param string|null $spelling The spelling as written without a leading `\`, e.g. `TRUE`; - * null lowercases every keyword constant on the line. + * @param string|null $spelling The spelling as written without a leading `\`, e.g. `TRUE`. + * A violation without it does not identify an occurrence, so + * the visitor then changes nothing rather than widening the fix. */ public function __construct( private readonly int $line, - private readonly ?string $spelling = null, + private readonly ?string $spelling, ) { } public function enterNode(Node $node): ?Node { - if (! $node instanceof ConstFetch || $node->getStartLine() !== $this->line) { + if ( + $this->spelling === null + || ! $node instanceof ConstFetch + || $node->getStartLine() !== $this->line + ) { return null; } @@ -46,11 +51,7 @@ public function enterNode(Node $node): ?Node $spelling = $name->toString(); $keyword = strtolower($spelling); - if ( - ! isset(self::KEYWORD_CONSTANTS[$keyword]) - || $spelling === $keyword - || ($this->spelling !== null && $spelling !== $this->spelling) - ) { + if (! isset(self::KEYWORD_CONSTANTS[$keyword]) || $spelling === $keyword || $spelling !== $this->spelling) { return null; } diff --git a/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php b/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php index dafb4160..1edea582 100644 --- a/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php +++ b/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php @@ -16,6 +16,7 @@ use Boundwize\StructArmed\Rule\Rules\File\MustUseLowercaseKeywordConstantRule; use Boundwize\StructArmed\Rule\RuleViolation; use Boundwize\StructArmed\Tests\Support\TemporaryDirectoryCleanupTrait; +use Boundwize\StructArmed\Util\Path; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -324,18 +325,23 @@ public function testEvaluatesProvidedFileAnalysesWithinSourcePaths(): void $this->assertSame('Null', $violations[1]->constantName); } - public function testFixWithoutConstantNameLowercasesFirstKeywordConstantOnLine(): void + public function testFixWithoutConstantNameDoesNothing(): void { - $basePath = $this->makeProject("makeProject("assertTrue($mustUseLowercaseKeywordConstantRule->fix(new RuleViolation( - message: 'Keyword constant [Null] must use lowercase [null]', + // Without the spelling the violation does not identify an occurrence, + // so the fixer must not widen the change to every keyword on the line. + $this->assertFalse($mustUseLowercaseKeywordConstantRule->fix(new RuleViolation( + message: 'Keyword constant [TRUE] must use lowercase [true]', file: $basePath . '/src/Foo.php', line: 3, className: '', ))); - $this->assertSame("assertSame( + "assertIsString($realBasePath); - return $realBasePath; + return Path::normalise($realBasePath, canonicalise: true); } /** @param list $nonCanonicalKeywordConstants */ diff --git a/tests/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitorTest.php b/tests/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitorTest.php index 82e380ee..7623f83c 100644 --- a/tests/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitorTest.php +++ b/tests/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitorTest.php @@ -18,7 +18,7 @@ public function testLowercasesUnqualifiedKeywordConstantOnLine(): void { $this->assertSame( "\$a = FOO;\n\$b = true;", - $this->apply("apply("assertSame( 'return namespace\false;', - $this->apply('apply('assertSame( "\$a = TRUE;\n\$b = FALSE;", - $this->apply("apply("assertSame( '$a = FOO ?? \BAR ?? Foo\BAZ ?? Some::TRUE ?? true ?? \null;', - $this->apply($code, new LowercaseKeywordConstantVisitor(1)) + $this->apply($code, new LowercaseKeywordConstantVisitor(1, 'TRUE')) ); } @@ -64,6 +64,14 @@ public function testMatchesOnlyTheGivenSpelling(): void ); } + public function testDoesNothingWithoutSpelling(): void + { + $this->assertSame( + '$a = TRUE ? FALSE : NULL;', + $this->apply('assertSame( From 02eaae719b20cf5f843cb16f380e918b1ac972b8 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 08:58:27 +0700 Subject: [PATCH 056/104] fix relative --- .../LowercaseKeywordConstantVisitor.php | 13 +++++---- ...ustUseLowercaseKeywordConstantRuleTest.php | 29 +++++++++++++++++++ .../LowercaseKeywordConstantVisitorTest.php | 15 ++++++++-- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitor.php b/src/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitor.php index e3e46eb7..78d841c2 100644 --- a/src/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitor.php +++ b/src/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitor.php @@ -16,7 +16,9 @@ /** * Lowercases every `true`, `false`, or `null` fetch on a line that is not yet * spelled in lowercase. The Name is replaced by one of the same kind, so a - * fully qualified `\TRUE` keeps its leading `\` and becomes `\true`. + * fully qualified `\TRUE` keeps its leading `\` and becomes `\true`. A + * relative `namespace\TRUE` names the case-sensitive constant `Ns\TRUE`, not + * the keyword, so it is left alone. */ final class LowercaseKeywordConstantVisitor extends NodeVisitorAbstract { @@ -43,6 +45,7 @@ public function enterNode(Node $node): ?Node $this->spelling === null || ! $node instanceof ConstFetch || $node->getStartLine() !== $this->line + || $node->name instanceof Relative ) { return null; } @@ -55,11 +58,9 @@ public function enterNode(Node $node): ?Node return null; } - $node->name = match (true) { - $name instanceof FullyQualified => new FullyQualified($keyword, $name->getAttributes()), - $name instanceof Relative => new Relative($keyword, $name->getAttributes()), - default => new Name($keyword, $name->getAttributes()), - }; + $node->name = $name instanceof FullyQualified + ? new FullyQualified($keyword, $name->getAttributes()) + : new Name($keyword, $name->getAttributes()); return $node; } diff --git a/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php b/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php index 1edea582..325bc0e4 100644 --- a/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php +++ b/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php @@ -168,6 +168,35 @@ function foo() $this->assertSame('Keyword constant [TRUE] must use lowercase [true]', $violations[0]->message); } + public function testLeavesRelativeKeywordLikeConstantAloneWhenFixingTheSameLine(): void + { + // namespace\TRUE names the case-sensitive constant Foo\TRUE, not the keyword. + $basePath = $this->makeProject(<<<'PHP' +evaluateProjectAll( + $basePath, + Architecture::define() + ); + + $this->assertCount(1, $violations); + $this->assertSame('Keyword constant [TRUE] must use lowercase [true]', $violations[0]->message); + $this->assertTrue($mustUseLowercaseKeywordConstantRule->fix($violations[0])); + $this->assertSame(<<<'PHP' +makeProject(<<<'PHP' diff --git a/tests/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitorTest.php b/tests/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitorTest.php index 7623f83c..35ebd2cb 100644 --- a/tests/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitorTest.php +++ b/tests/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitorTest.php @@ -30,14 +30,25 @@ public function testKeepsLeadingBackslashOfFullyQualifiedKeywordConstant(): void ); } - public function testKeepsRelativeSpelling(): void + public function testIgnoresRelativeKeywordLikeConstant(): void { $this->assertSame( - 'return namespace\false;', + 'return namespace\FALSE;', $this->apply('assertSame( + '$value = true ? namespace\TRUE : false;', + $this->apply( + 'assertSame( From 3d39bdaa77a1fee3ebfa8e4a2c15fb135ff7cb4a Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 09:03:29 +0700 Subject: [PATCH 057/104] add more test for relative --- ...ustUseLowercaseKeywordConstantRuleTest.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php b/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php index 325bc0e4..dc598952 100644 --- a/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php +++ b/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php @@ -168,6 +168,29 @@ function foo() $this->assertSame('Keyword constant [TRUE] must use lowercase [true]', $violations[0]->message); } + public function testSkipsRelativeKeywordLikeConstant(): void + { + $basePath = $this->makeProject(<<<'PHP' +assertSame( + [], + $mustUseLowercaseKeywordConstantRule->evaluateProjectAll($basePath, Architecture::define()) + ); + } + public function testLeavesRelativeKeywordLikeConstantAloneWhenFixingTheSameLine(): void { // namespace\TRUE names the case-sensitive constant Foo\TRUE, not the keyword. From ce515a91309343fa3107389dccd5a6de2d195a21 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 11:15:09 +0700 Subject: [PATCH 058/104] perf: Optimize keyword constant collection hot path --- src/Analyser/AnalysisNodeCollector.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index f9afbf01..41f034b8 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -1057,7 +1057,13 @@ private function collectNodeAnalysis(Node $node): void private function collectKeywordConstant(Name $name): void { $spelling = $name->toString(); - $keyword = strtolower($spelling); + $length = strlen($spelling); + + if ($length !== 4 && $length !== 5) { + return; + } + + $keyword = strtolower($spelling); if (! isset(self::KEYWORD_CONSTANTS[$keyword]) || $spelling === $keyword) { return; From e128ff2150d7f788655cad7bbd20087f9c19eec1 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 11:19:09 +0700 Subject: [PATCH 059/104] fix early check --- src/Analyser/AnalysisNodeCollector.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index 41f034b8..a0ea03e8 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -1057,7 +1057,12 @@ private function collectNodeAnalysis(Node $node): void private function collectKeywordConstant(Name $name): void { $spelling = $name->toString(); - $length = strlen($spelling); + + if (isset(self::KEYWORD_CONSTANTS[$spelling])) { + return; + } + + $length = strlen($spelling); if ($length !== 4 && $length !== 5) { return; @@ -1065,16 +1070,15 @@ private function collectKeywordConstant(Name $name): void $keyword = strtolower($spelling); - if (! isset(self::KEYWORD_CONSTANTS[$keyword]) || $spelling === $keyword) { + if (! isset(self::KEYWORD_CONSTANTS[$keyword])) { return; } $spanLength = $name->getEndFilePos() - $name->getStartFilePos() + 1; - $nameLength = strlen($spelling); - if ($spanLength === $nameLength + 1) { + if ($spanLength === $length + 1) { $spelling = '\\' . $spelling; - } elseif ($spanLength !== $nameLength) { + } elseif ($spanLength !== $length) { return; } From 8ef45e8dfb8beae9d7527f5a90f8705328caff2a Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 11:30:57 +0700 Subject: [PATCH 060/104] add more test --- tests/Analyser/AnalysisNodeCollectorTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index 207a2ef7..9b9e2aec 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -42,7 +42,7 @@ final class Foo { public function bar(): bool { - return False ?? \tRuE ?? true ?? \null ?? FOO ?? \BAR; + return False ?? \tRuE ?? true ?? \null ?? FOO ?? \BAR ?? M_PI ?? \E_ALL; } } PHP); From 5365197b282dcfabe33a48be901fbe3179f11ca5 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 17:53:22 +0700 Subject: [PATCH 061/104] perf: Reduce parallel worker cache payload size --- .../ParallelAnalysisNodeExtractor.php | 2 +- src/Cache/AnalysisResultCache.php | 13 +++++- src/Cache/FileHashProvider.php | 13 ++++++ .../ParallelAnalysisNodeExtractorTest.php | 41 +++++++++++++++++++ tests/Cache/AnalysisResultCacheTest.php | 36 ++++++++++++++++ tests/Cache/FileHashProviderTest.php | 25 +++++++++++ 6 files changed, 128 insertions(+), 2 deletions(-) diff --git a/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php b/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php index 0ad46a9f..3e29e473 100644 --- a/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php +++ b/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php @@ -104,7 +104,7 @@ public function extract( 'files' => $chunk, 'emitProgress' => $emitProgress, 'withFileAnalysis' => $withFileAnalysis, - 'cache' => $this->analysisResultCache, + 'cache' => $this->analysisResultCache?->forFiles($chunk), 'cacheNamespace' => $this->analysisNodeCacheNamespace, ])); diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 5959e541..ff177f5e 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -76,7 +76,7 @@ final class AnalysisResultCache public function __construct( string $basePath, - private readonly FileHashProvider $fileHashProvider, + private FileHashProvider $fileHashProvider, ?string $cacheDirectory = null, private readonly string $configHash = '', private readonly string $composerGeneratedVersionHash = '', @@ -165,6 +165,17 @@ public function getCacheDirectory(): string return $this->cacheDirectory; } + /** + * @param list $files + */ + public function forFiles(array $files): self + { + $cache = clone $this; + $cache->fileHashProvider = $this->fileHashProvider->forFiles($files); + + return $cache; + } + /** * Compares against the single metadata marker instead of scanning every * payload, so the check stays O(1) regardless of cache size. A populated diff --git a/src/Cache/FileHashProvider.php b/src/Cache/FileHashProvider.php index 52d3aa82..7a9b0802 100644 --- a/src/Cache/FileHashProvider.php +++ b/src/Cache/FileHashProvider.php @@ -4,6 +4,8 @@ namespace Boundwize\StructArmed\Cache; +use function array_fill_keys; +use function array_intersect_key; use function hash_file; /** @@ -30,6 +32,17 @@ public function hash(string $file): string return $this->hashes[$file] = $hash; } + /** + * @param list $files + */ + public function forFiles(array $files): self + { + $provider = new self(); + $provider->hashes = array_intersect_key($this->hashes, array_fill_keys($files, true)); + + return $provider; + } + public function clear(): void { $this->hashes = []; diff --git a/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php b/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php index ee6f75c2..d1e298b6 100644 --- a/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php +++ b/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php @@ -6,6 +6,8 @@ use Boundwize\StructArmed\Analyser\ClassNode; use Boundwize\StructArmed\Analyser\Parallel\ParallelAnalysisNodeExtractor; +use Boundwize\StructArmed\Cache\AnalysisResultCache; +use Boundwize\StructArmed\Cache\FileHashProvider; use Boundwize\StructArmed\Tests\Support\TemporaryDirectoryCleanupTrait; use Iterator; use PHPUnit\Framework\Attributes\CoversClass; @@ -160,6 +162,45 @@ final class Baz $this->assertSame('App\\Domain\\Baz', $extractionResult->classNodes[0]->className); } + public function testWorkersStoreValidAnalysisNodeCacheEntriesWithScopedFileHashes(): void + { + $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); + $cacheDir = $this->makeTemporaryDirectory('structarmed-parallel-cache'); + $fooFile = $dir . '/Foo.php'; + $barFile = $dir . '/Bar.php'; + + file_put_contents($fooFile, 'hash($fooFile); + $fileHashProvider->hash($barFile); + + $analysisResultCache = new AnalysisResultCache($dir, $fileHashProvider, $cacheDir); + + (new ParallelAnalysisNodeExtractor( + basePath: $dir, + layers: [], + layerPatterns: [], + workerCount: 2, + cacheDirectory: $cacheDir, + analysisResultCache: $analysisResultCache, + analysisNodeCacheNamespace: 'config', + ))->extract([$fooFile, $barFile]); + + $freshCache = new AnalysisResultCache($dir, new FileHashProvider(), $cacheDir); + + $this->assertIsArray($freshCache->loadAnalysisNodes($fooFile, 'config')); + $this->assertIsArray($freshCache->loadAnalysisNodes($barFile, 'config')); + + file_put_contents($fooFile, 'assertNull($changedFileCache->loadAnalysisNodes($fooFile, 'config')); + $this->assertIsArray($changedFileCache->loadAnalysisNodes($barFile, 'config')); + } + public function testExtractWithLayerPatternsUsesChainResolver(): void { $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index 82b64ec6..c0e13f60 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -69,6 +69,42 @@ public function testGetCacheDirectoryReturnsConfiguredDirectory(): void } } + public function testForFilesUsesOnlyRequestedMemoisedHashes(): void + { + $directory = $this->createTempDirectory(); + $cacheDir = $this->createTempDirectory(); + $retainedFile = $directory . '/Retained.php'; + $unrelatedFile = $directory . '/Unrelated.php'; + + file_put_contents($retainedFile, 'hash($retainedFile); + $fileHashProvider->hash($unrelatedFile); + + $cacheForFile = (new AnalysisResultCache($directory, $fileHashProvider, $cacheDir)) + ->forFiles([$retainedFile]); + + file_put_contents($retainedFile, 'storeAnalysisNodes($retainedFile, 'config', []); + $cacheForFile->storeAnalysisNodes($unrelatedFile, 'config', []); + + $analysisResultCache = new AnalysisResultCache($directory, new FileHashProvider(), $cacheDir); + + $this->assertNull($analysisResultCache->loadAnalysisNodes($retainedFile, 'config')); + $this->assertIsArray($analysisResultCache->loadAnalysisNodes($unrelatedFile, 'config')); + } finally { + unlink($retainedFile); + unlink($unrelatedFile); + $this->removeTempDirectory($directory); + $this->removeTempDirectory($cacheDir); + } + } + public function testStoresAndLoadsViolationCollection(): void { $cacheDirectory = $this->createTempDirectory(); diff --git a/tests/Cache/FileHashProviderTest.php b/tests/Cache/FileHashProviderTest.php index dd49cd66..e9d44ee0 100644 --- a/tests/Cache/FileHashProviderTest.php +++ b/tests/Cache/FileHashProviderTest.php @@ -55,4 +55,29 @@ public function testDoesNotMemoiseFailedHash(): void $this->assertSame(hash_file('xxh128', $file), $fileHashProvider->hash($file)); } + + public function testForFilesCopiesOnlyRequestedMemoisedHashes(): void + { + $retainedFile = $this->makeTemporaryFile('structarmed-file-hash'); + $unrelatedFile = $this->makeTemporaryFile('structarmed-file-hash'); + $missingFile = $this->makeTemporaryFile('structarmed-file-hash'); + + file_put_contents($retainedFile, 'hash($retainedFile); + $fileHashProvider->hash($unrelatedFile); + + $providerForFiles = $fileHashProvider->forFiles([$retainedFile, $missingFile]); + + file_put_contents($retainedFile, 'assertSame($retainedHash, $providerForFiles->hash($retainedFile)); + $this->assertSame(hash_file('xxh128', $unrelatedFile), $providerForFiles->hash($unrelatedFile)); + $this->assertSame(hash_file('xxh128', $missingFile), $providerForFiles->hash($missingFile)); + } } From 2c479fea76eb497bee9180723c5774a6a0a80709 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 18:18:03 +0700 Subject: [PATCH 062/104] Register MustUseLowercaseKeywordConstantRule in PSR-12 preset --- docs/presets.md | 2 +- docs/quick-start.md | 2 +- src/Preset/Presets/Psr12Preset.php | 8 ++++++++ tests/Analyser/AnalyserTest.php | 15 ++++++++++++--- tests/Preset/PresetTest.php | 7 +++++-- 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/presets.md b/docs/presets.md index 13dfc041..cd8cfa53 100644 --- a/docs/presets.md +++ b/docs/presets.md @@ -21,7 +21,7 @@ StructArmed ships with presets for common PHP standards and architecture styles. |---|---| | `Preset::PSR4()` | Verifies configured source paths exist in composer.json `autoload` or `autoload-dev` PSR-4 mappings | | `Preset::PSR1()` | Basic Coding Standard checks: PHP tags, valid UTF-8, UTF-8 without BOM, symbols vs side effects, PSR-4 class placement, StudlyCaps class names, upper-case class constants, camelCase methods | -| `Preset::PSR12()` | Extends PSR-1: all methods, constants, and properties must declare explicit visibility | +| `Preset::PSR12()` | Extends PSR-1: PHP keyword constants (`true`, `false`, and `null`) must be lowercase, and all methods, constants, and properties must declare explicit visibility | | `Preset::PER()` | [PER Coding Style](https://www.php-fig.org/per/coding-style/): extends PSR-12 (and, through it, PSR-1) and adds PascalCase enum case names and no `protected` enum methods or constants | | `Preset::PSR15()` | `*Middleware` classes must implement PSR-15 `MiddlewareInterface`; `*Handler` classes must implement PSR-15 `RequestHandlerInterface`; StructArmed also enforces matching `Middleware`/`Handler` suffixes for implementations of those interfaces | | `Preset::DDD()` | Layer isolation, entity/VO/repository/event/service conventions, including keeping Doctrine ORM repository inheritance out of the Domain layer | diff --git a/docs/quick-start.md b/docs/quick-start.md index faafd712..47d67dae 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -33,7 +33,7 @@ vendor/bin/structarmed init --preset=psr4 # Enforce basic coding standard rules vendor/bin/structarmed init --preset=psr1 -# PSR-12 extends PSR-1 with explicit member visibility checks +# PSR-12 extends PSR-1 with lowercase keyword constants and explicit member visibility checks vendor/bin/structarmed init --preset=psr12 # PSR-15 middleware and request handler interface checks diff --git a/src/Preset/Presets/Psr12Preset.php b/src/Preset/Presets/Psr12Preset.php index 95a3ae54..4fa0d251 100644 --- a/src/Preset/Presets/Psr12Preset.php +++ b/src/Preset/Presets/Psr12Preset.php @@ -9,6 +9,7 @@ use Boundwize\StructArmed\Rule\Rules\Class_\MustDeclareConstantVisibilityRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustDeclareMethodVisibilityRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustDeclarePropertyVisibilityRule; +use Boundwize\StructArmed\Rule\Rules\File\MustUseLowercaseKeywordConstantRule; final readonly class Psr12Preset implements PresetInterface { @@ -20,6 +21,9 @@ public const PROPERTIES_MUST_DECLARE_VISIBILITY = 'psr12.properties.must_declare_visibility'; + public const FILES_MUST_USE_LOWERCASE_KEYWORD_CONSTANTS = + 'psr12.files.must_use_lowercase_keyword_constants'; + /** * @param list|null $sourcePaths */ @@ -41,6 +45,10 @@ public function apply(Architecture $architecture): void $layerName = $this->resolveLayerName($architecture, $sourcePathsForPsr12); $architecture->layer($layerName, $sourcePathsForPsr12 ?? []); + $architecture->rule( + self::FILES_MUST_USE_LOWERCASE_KEYWORD_CONSTANTS, + new MustUseLowercaseKeywordConstantRule($sourcePathsForPsr12) + ); $architecture->rule( self::METHODS_MUST_DECLARE_VISIBILITY, new MustDeclareMethodVisibilityRule($layerName) diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index 67923888..a07dea3b 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -2181,13 +2181,13 @@ public function testPsr4Psr1AndPsr12PreserveInheritedSourceScopesRegardlessOfPre 'composer.json' => '{"autoload":{"psr-4":{"Psr4\\\\":"psr4/",' . '"Psr1\\\\":"psr1/","Psr12\\\\":"psr12/"}}}', 'psr4/Invalid.php' => ' ' ' ' ' 'forRule(Psr12Preset::METHODS_MUST_DECLARE_VISIBILITY); $this->assertCount(1, $psr12Violations); $this->assertStringEndsWith('/psr12/Invalid.php', $this->normalisePath($psr12Violations[0]->file)); + + $keywordConstantViolations = $result->forRule( + Psr12Preset::FILES_MUST_USE_LOWERCASE_KEYWORD_CONSTANTS + ); + $this->assertCount(1, $keywordConstantViolations); + $this->assertStringEndsWith( + '/psr12/Invalid.php', + $this->normalisePath($keywordConstantViolations[0]->file) + ); } } diff --git a/tests/Preset/PresetTest.php b/tests/Preset/PresetTest.php index 76df0b9e..b985148d 100644 --- a/tests/Preset/PresetTest.php +++ b/tests/Preset/PresetTest.php @@ -97,7 +97,7 @@ public function testPsr1PresetRegistersSourceLayerAndRules(): void $this->assertArrayHasKey(Psr1Preset::METHODS_MUST_BE_CAMEL_CASE, $rules); } - public function testPsr12PresetAppliesPsr1RulesAndAddsVisibilityRules(): void + public function testPsr12PresetAppliesPsr1RulesAndAddsPsr12Rules(): void { $architecture = Architecture::define(); @@ -118,6 +118,7 @@ public function testPsr12PresetAppliesPsr1RulesAndAddsVisibilityRules(): void $this->assertArrayHasKey(Psr1Preset::CLASSES_MUST_BE_STUDLY_CAPS, $rules); $this->assertArrayHasKey(Psr1Preset::CLASS_CONSTANTS_MUST_BE_UPPER_CASE, $rules); $this->assertArrayHasKey(Psr1Preset::METHODS_MUST_BE_CAMEL_CASE, $rules); + $this->assertArrayHasKey(Psr12Preset::FILES_MUST_USE_LOWERCASE_KEYWORD_CONSTANTS, $rules); $this->assertArrayHasKey(Psr12Preset::METHODS_MUST_DECLARE_VISIBILITY, $rules); $this->assertArrayHasKey(Psr12Preset::CONSTANTS_MUST_DECLARE_VISIBILITY, $rules); $this->assertArrayHasKey(Psr12Preset::PROPERTIES_MUST_DECLARE_VISIBILITY, $rules); @@ -138,6 +139,7 @@ public function testPerPresetAppliesPsr12RulesAndAddsEnumCaseRule(): void $this->assertArrayHasKey(Psr1Preset::CLASSES_MUST_BE_STUDLY_CAPS, $rules); $this->assertArrayHasKey(Psr1Preset::CLASS_CONSTANTS_MUST_BE_UPPER_CASE, $rules); $this->assertArrayHasKey(Psr1Preset::METHODS_MUST_BE_CAMEL_CASE, $rules); + $this->assertArrayHasKey(Psr12Preset::FILES_MUST_USE_LOWERCASE_KEYWORD_CONSTANTS, $rules); $this->assertArrayHasKey(Psr12Preset::METHODS_MUST_DECLARE_VISIBILITY, $rules); $this->assertArrayHasKey(Psr12Preset::CONSTANTS_MUST_DECLARE_VISIBILITY, $rules); $this->assertArrayHasKey(Psr12Preset::PROPERTIES_MUST_DECLARE_VISIBILITY, $rules); @@ -318,7 +320,7 @@ public function testPsr1AndPsr12BothEnabledDoNotDuplicatePsr1Rules(): void $rules = $architecture->getRules(); - $this->assertCount(15, $rules); + $this->assertCount(16, $rules); $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_TAGS, $rules); $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_UTF8, $rules); @@ -332,6 +334,7 @@ public function testPsr1AndPsr12BothEnabledDoNotDuplicatePsr1Rules(): void $this->assertArrayHasKey(Psr1Preset::CLASSES_MUST_BE_STUDLY_CAPS, $rules); $this->assertArrayHasKey(Psr1Preset::CLASS_CONSTANTS_MUST_BE_UPPER_CASE, $rules); $this->assertArrayHasKey(Psr1Preset::METHODS_MUST_BE_CAMEL_CASE, $rules); + $this->assertArrayHasKey(Psr12Preset::FILES_MUST_USE_LOWERCASE_KEYWORD_CONSTANTS, $rules); $this->assertArrayHasKey(Psr12Preset::METHODS_MUST_DECLARE_VISIBILITY, $rules); $this->assertArrayHasKey(Psr12Preset::CONSTANTS_MUST_DECLARE_VISIBILITY, $rules); $this->assertArrayHasKey(Psr12Preset::PROPERTIES_MUST_DECLARE_VISIBILITY, $rules); From 650c9961e1fe235c4bc9226e2ff26c5fd98bcdc6 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 19:28:41 +0700 Subject: [PATCH 063/104] perf: stream analysis cache file hashing --- src/Cache/AnalysisCacheMetadataFactory.php | 17 +++++++++++------ tests/Cache/AnalysisResultCacheTest.php | 10 +++++++++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Cache/AnalysisCacheMetadataFactory.php b/src/Cache/AnalysisCacheMetadataFactory.php index 727d50e9..d87fa043 100644 --- a/src/Cache/AnalysisCacheMetadataFactory.php +++ b/src/Cache/AnalysisCacheMetadataFactory.php @@ -7,9 +7,11 @@ use Boundwize\StructArmed\Version; use Composer\InstalledVersions; -use function array_map; use function file_exists; use function hash; +use function hash_final; +use function hash_init; +use function hash_update; use function json_encode; use function rtrim; use function sort; @@ -36,7 +38,7 @@ public function metadata(string $basePath, string $configPath, array $scanPaths, sort($files); return [ - 'version' => 4, + 'version' => 5, 'basePath' => $basePath, 'configPath' => $configPath, 'configHash' => $this->fileHash($configPath), @@ -81,10 +83,13 @@ public function analysisNodeCacheNamespace(string $basePath, string $configHash) */ private function filesHash(array $files): string { - return hash('xxh128', json_encode(array_map(fn(string $file): array => [ - 'file' => $file, - 'hash' => $this->fileHashProvider->hash($file), - ], $files), JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR)); + $context = hash_init('xxh128'); + + foreach ($files as $file) { + hash_update($context, $file . "\0" . $this->fileHashProvider->hash($file) . "\0"); + } + + return hash_final($context); } private function composerHash(string $basePath): string diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index c0e13f60..f249f26d 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -38,6 +38,9 @@ use function glob; use function hash; use function hash_file; +use function hash_final; +use function hash_init; +use function hash_update; use function is_dir; use function json_decode; use function json_encode; @@ -2609,9 +2612,14 @@ public function testMetadataIncludesConfigAndAnalysedFiles(): void $this->assertSame($directory, $metadata['basePath']); $this->assertSame($config, $metadata['configPath']); $this->assertSame(['src'], $metadata['scanPaths']); + $this->assertSame(5, $metadata['version']); $this->assertIsString($metadata['configHash']); $this->assertIsString($metadata['composerGeneratedVersionHash']); - $this->assertIsString($metadata['filesHash']); + + $filesHashContext = hash_init('xxh128'); + hash_update($filesHashContext, $source . "\0" . hash_file('xxh128', $source) . "\0"); + + $this->assertSame(hash_final($filesHashContext), $metadata['filesHash']); $this->assertSame( (new AnalysisCacheMetadataFactory(new FileHashProvider()))->key($metadata), (new AnalysisCacheMetadataFactory(new FileHashProvider()))->key($metadata) From d4ef0899e176abb95ef61ffc157cd2aaf0588a07 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 19:31:44 +0700 Subject: [PATCH 064/104] rectify --- src/Cache/AnalysisCacheMetadataFactory.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Cache/AnalysisCacheMetadataFactory.php b/src/Cache/AnalysisCacheMetadataFactory.php index d87fa043..a76833c8 100644 --- a/src/Cache/AnalysisCacheMetadataFactory.php +++ b/src/Cache/AnalysisCacheMetadataFactory.php @@ -83,13 +83,13 @@ public function analysisNodeCacheNamespace(string $basePath, string $configHash) */ private function filesHash(array $files): string { - $context = hash_init('xxh128'); + $hashContext = hash_init('xxh128'); foreach ($files as $file) { - hash_update($context, $file . "\0" . $this->fileHashProvider->hash($file) . "\0"); + hash_update($hashContext, $file . "\0" . $this->fileHashProvider->hash($file) . "\0"); } - return hash_final($context); + return hash_final($hashContext); } private function composerHash(string $basePath): string From 47d17e99c81a0e9177cc6526790a104b112d929b Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 20:01:23 +0700 Subject: [PATCH 065/104] chore: Make use of Node 24 on github workflows --- .github/workflows/ci.yml | 6 +++--- .github/workflows/pages.yml | 6 +++--- .github/workflows/typos.yml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb69819c..55ec15a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -37,7 +37,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -68,7 +68,7 @@ jobs: - name: Upload coverage to Codecov if: matrix.coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 90d3ad1a..40f0e7ed 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Ruby uses: ruby/setup-ruby@v1 @@ -29,14 +29,14 @@ jobs: - name: Configure Pages id: pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Build documentation working-directory: docs run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}" - name: Upload artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: name: github-pages path: docs/_site diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index 980d805b..9abb7eb6 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -13,7 +13,7 @@ jobs: runs-on: "ubuntu-latest" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: "Check for typos" uses: "crate-ci/typos@v1.46.0" From 249dd3c9fd8ee886a1278ed81af4ae8f4b06f168 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 22:57:17 +0700 Subject: [PATCH 066/104] Add LargeNumericLiteralMustUseSeparatorRule --- src/Analyser/Analyser.php | 2 + src/Analyser/AnalysisNodeCollector.php | 43 ++++- src/Analyser/AnalysisNodeExtractor.php | 8 +- src/Analyser/FileAnalysis.php | 4 + src/Analyser/FileAnalysisProvider.php | 10 +- src/Cache/AnalysisResultCache.php | 55 ++++-- .../PhpParser/PhpParserFixerProcessor.php | 21 +- .../AddNumericLiteralSeparatorsVisitor.php | 50 +++++ src/Rule/RuleViolation.php | 5 + ...argeNumericLiteralMustUseSeparatorRule.php | 161 ++++++++++++++++ tests/Analyser/AnalysisNodeCollectorTest.php | 28 +++ tests/Analyser/AnalysisNodeExtractorTest.php | 16 ++ tests/Cache/AnalysisResultCacheTest.php | 9 + ...NumericLiteralMustUseSeparatorRuleTest.php | 132 +++++++++++++ ...ricLiteralMustUseSeparatorRuleUnitTest.php | 181 ++++++++++++++++++ 15 files changed, 708 insertions(+), 17 deletions(-) create mode 100644 src/Rule/Fixer/PhpParser/Scalar/AddNumericLiteralSeparatorsVisitor.php create mode 100644 src/Rule/Rules/File/LargeNumericLiteralMustUseSeparatorRule.php create mode 100644 tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleTest.php create mode 100644 tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleUnitTest.php diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 95d04376..b5e48214 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -213,6 +213,7 @@ className: $violation->className, constantName: $violation->constantName, propertyName: $violation->propertyName, functionName: $violation->functionName, + numericLiteral: $violation->numericLiteral, )); } } @@ -436,6 +437,7 @@ className: $ruleViolation->className, constantName: $ruleViolation->constantName, propertyName: $ruleViolation->propertyName, functionName: $ruleViolation->functionName, + numericLiteral: $ruleViolation->numericLiteral, ); } diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index a0ea03e8..6410e144 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -42,6 +42,8 @@ use PhpParser\Node\Name; use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Param; +use PhpParser\Node\Scalar\Float_; +use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; use PhpParser\Node\Stmt\Case_; @@ -79,6 +81,7 @@ use function count; use function end; use function in_array; +use function is_finite; use function is_int; use function is_string; use function preg_match; @@ -195,7 +198,7 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract * function-likes, and everything collectNodeAnalysis() records. The * parser only ever instantiates these exact classes, so a single ::class * hash lookup lets the large majority of nodes (identifiers, arguments, - * scalars, assignments, ...) return before any instanceof check. + * most scalars, assignments, ...) return before any instanceof check. */ private const ENTER_NODES = self::COMPLEXITY_BRANCH_NODES + self::LANGUAGE_CONSTRUCT_NODES @@ -213,6 +216,8 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract Closure::class => true, ArrowFunction::class => true, String_::class => true, + Int_::class => true, + Float_::class => true, FullyQualified::class => true, ConstFetch::class => true, Variable::class => true, @@ -285,6 +290,15 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract */ private array $nonCanonicalKeywordConstants = []; + /** + * Numeric literals of the current file, as [line, spelling as written, + * evaluated value]. Reset per file so the extractor can read them once + * traversal finishes. + * + * @var list + */ + private array $numericLiterals = []; + private readonly ConstExprEvaluator $constExprEvaluator; /** @@ -384,6 +398,7 @@ public function setCurrentFile(string $file): void $this->currentFileReferences = []; $this->currentFileInstantiations = []; $this->nonCanonicalKeywordConstants = []; + $this->numericLiterals = []; $this->currentNamespaceUses = []; $this->fileClassLikes = []; $this->fileFunctions = []; @@ -444,6 +459,17 @@ public function getNonCanonicalKeywordConstants(): array return $this->nonCanonicalKeywordConstants; } + /** + * Numeric literals of the file traversed last, as [line, spelling as + * written, evaluated value]. + * + * @return list + */ + public function getNumericLiterals(): array + { + return $this->numericLiterals; + } + /** * Class-like instantiations (`new X`, with self/static/parent resolved to * the class names they target), per file. `new` on an abstract class is @@ -917,6 +943,21 @@ private function finishFunctionLikeAnalysis(): void private function collectNodeAnalysis(Node $node): void { + if ($node instanceof Int_ || $node instanceof Float_) { + $rawValue = $node->getAttribute('rawValue'); + + // Parser-created scalar nodes always carry rawValue. Programmatic + // nodes may not, and non-finite floats cannot be cache-serialized. + if ( + is_string($rawValue) + && ($node instanceof Int_ || is_finite($node->value)) + ) { + $this->numericLiterals[] = [$node->getStartLine(), $rawValue, $node->value]; + } + + return; + } + // A class-name-shaped string literal may feed `new $class`, // `$obj instanceof $class`, class_exists(), container ids, and so on. // Whether it appears inside a class-like or in procedural code, treat diff --git a/src/Analyser/AnalysisNodeExtractor.php b/src/Analyser/AnalysisNodeExtractor.php index 24d7b5f3..56ec98d5 100644 --- a/src/Analyser/AnalysisNodeExtractor.php +++ b/src/Analyser/AnalysisNodeExtractor.php @@ -48,18 +48,24 @@ public function extract( try { $ast = $this->fileAnalysisProvider->ast($file, $withFileAnalysis); $nonCanonicalKeywordConstants = []; + $numericLiterals = []; if ($ast !== null && $ast !== []) { $analysisNodeCollector->setCurrentFile($file); $nodeTraverser->traverse($ast); $nonCanonicalKeywordConstants = $analysisNodeCollector->getNonCanonicalKeywordConstants(); + $numericLiterals = $analysisNodeCollector->getNumericLiterals(); } // Analysed after the traversal so the facts only the collector // records reach the file analysis without a second AST walk. if ($withFileAnalysis) { - $fileAnalyses[$file] = $this->fileAnalysisProvider->analyse($file, $nonCanonicalKeywordConstants); + $fileAnalyses[$file] = $this->fileAnalysisProvider->analyse( + $file, + $nonCanonicalKeywordConstants, + $numericLiterals, + ); } } finally { if ($withFileAnalysis) { diff --git a/src/Analyser/FileAnalysis.php b/src/Analyser/FileAnalysis.php index 7f15ae8f..3297a14d 100644 --- a/src/Analyser/FileAnalysis.php +++ b/src/Analyser/FileAnalysis.php @@ -12,6 +12,9 @@ * as [line, spelling as written]; a * leading `\` marks a fully * qualified form such as `\TRUE`. + * @param list $numericLiterals Numeric literals as + * [line, spelling as written, + * evaluated value]. */ public function __construct( public string $file, @@ -23,6 +26,7 @@ public function __construct( public bool $hasSideEffects, public int $sideEffectLine, public array $nonCanonicalKeywordConstants = [], + public array $numericLiterals = [], ) { } } diff --git a/src/Analyser/FileAnalysisProvider.php b/src/Analyser/FileAnalysisProvider.php index 5bbb18d2..b6afa900 100644 --- a/src/Analyser/FileAnalysisProvider.php +++ b/src/Analyser/FileAnalysisProvider.php @@ -136,9 +136,14 @@ private static function normaliseAnalyses(array $analyses): array * analysis-node traversal recorded * for the file; the provider never * walks the AST for them itself. + * @param list $numericLiterals Numeric literals recorded by the + * same analysis-node traversal. */ - public function analyse(string $file, array $nonCanonicalKeywordConstants = []): FileAnalysis - { + public function analyse( + string $file, + array $nonCanonicalKeywordConstants = [], + array $numericLiterals = [], + ): FileAnalysis { $file = Path::normalise($file, canonicalise: true); if (isset($this->analyses[$file])) { @@ -164,6 +169,7 @@ public function analyse(string $file, array $nonCanonicalKeywordConstants = []): hasSideEffects: $fileState['hasSideEffects'], sideEffectLine: $fileState['sideEffectLine'], nonCanonicalKeywordConstants: $nonCanonicalKeywordConstants, + numericLiterals: $numericLiterals, ); $this->analyses[$file] = $fileAnalysis; diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index ff177f5e..9312872e 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -33,6 +33,7 @@ use function is_array; use function is_bool; use function is_dir; +use function is_float; use function is_int; use function is_string; use function json_decode; @@ -65,7 +66,7 @@ final class AnalysisResultCache * their shape or naming changes: it is recorded in the metadata marker, * so a cache written by an older format is cleared on its next use. */ - public const FORMAT_VERSION = 2; + public const FORMAT_VERSION = 3; private readonly string $cacheDirectory; @@ -493,17 +494,18 @@ private function readPath(string $path): ?array */ private function ruleViolationFromArray(array $violation): ?RuleViolation { - $ruleKey = $violation['rule'] ?? null; - $message = $violation['message'] ?? null; - $file = $violation['file'] ?? null; - $line = $violation['line'] ?? null; - $className = $violation['class'] ?? null; - $layer = $violation['layer'] ?? null; - $method = $violation['method'] ?? null; - $constant = $violation['constant'] ?? null; - $property = $violation['property'] ?? null; - $function = $violation['function'] ?? null; - $fixable = $violation['fixable'] ?? false; + $ruleKey = $violation['rule'] ?? null; + $message = $violation['message'] ?? null; + $file = $violation['file'] ?? null; + $line = $violation['line'] ?? null; + $className = $violation['class'] ?? null; + $layer = $violation['layer'] ?? null; + $method = $violation['method'] ?? null; + $constant = $violation['constant'] ?? null; + $property = $violation['property'] ?? null; + $function = $violation['function'] ?? null; + $numericLiteral = $violation['numericLiteral'] ?? null; + $fixable = $violation['fixable'] ?? false; if ( ! is_string($ruleKey) @@ -516,6 +518,7 @@ private function ruleViolationFromArray(array $violation): ?RuleViolation || ($constant !== null && ! is_string($constant)) || ($property !== null && ! is_string($property)) || ($function !== null && ! is_string($function)) + || ($numericLiteral !== null && ! is_string($numericLiteral)) || ! is_bool($fixable) ) { return null; @@ -533,6 +536,7 @@ className: $className, constantName: $constant, propertyName: $property, functionName: $function, + numericLiteral: $numericLiteral, ); } @@ -1251,6 +1255,7 @@ private function fileAnalysisToArray(FileAnalysis $fileAnalysis): array 'hasSideEffects' => $fileAnalysis->hasSideEffects, 'sideEffectLine' => $fileAnalysis->sideEffectLine, 'nonCanonicalKeywordConstants' => $fileAnalysis->nonCanonicalKeywordConstants, + 'numericLiterals' => $fileAnalysis->numericLiterals, ]; } @@ -1268,6 +1273,7 @@ private function fileAnalysisFromArray(array $analysis): ?FileAnalysis || ! is_bool($analysis['hasSideEffects'] ?? null) || ! is_int($analysis['sideEffectLine'] ?? null) || ! $this->isKeywordConstantList($analysis['nonCanonicalKeywordConstants'] ?? null) + || ! $this->isNumericLiteralList($analysis['numericLiterals'] ?? null) ) { return null; } @@ -1282,6 +1288,7 @@ private function fileAnalysisFromArray(array $analysis): ?FileAnalysis hasSideEffects: $analysis['hasSideEffects'], sideEffectLine: $analysis['sideEffectLine'], nonCanonicalKeywordConstants: $analysis['nonCanonicalKeywordConstants'], + numericLiterals: $analysis['numericLiterals'], ); } @@ -1308,6 +1315,30 @@ private function isKeywordConstantList(mixed $value): bool return true; } + /** + * @phpstan-assert-if-true list $value + */ + private function isNumericLiteralList(mixed $value): bool + { + if (! is_array($value) || ! array_is_list($value)) { + return false; + } + + foreach ($value as $numericLiteral) { + if ( + ! is_array($numericLiteral) + || count($numericLiteral) !== 3 + || ! is_int($numericLiteral[0] ?? null) + || ! is_string($numericLiteral[1] ?? null) + || (! is_int($numericLiteral[2] ?? null) && ! is_float($numericLiteral[2] ?? null)) + ) { + return false; + } + } + + return true; + } + /** * @param array $array * @phpstan-assert-if-true array $array diff --git a/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php b/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php index e69db504..1eb3169a 100644 --- a/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php +++ b/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php @@ -6,6 +6,7 @@ use PhpParser\Error; use PhpParser\Node; +use PhpParser\Node\Scalar\Float_; use PhpParser\Node\Stmt\Declare_; use PhpParser\Node\Stmt\GroupUse; use PhpParser\Node\Stmt\Namespace_; @@ -21,6 +22,7 @@ use function file_get_contents; use function file_put_contents; use function is_file; +use function is_string; use function unlink; final readonly class PhpParserFixerProcessor @@ -56,7 +58,24 @@ public function process(string $file, NodeVisitor $nodeVisitor, bool $removeFile return unlink($file); } - $fixedCode = (new Standard())->printFormatPreserving($statements, $originalStatements, $parser->getTokens()); + $prettyPrinter = new class extends Standard { + // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps -- PHP-Parser extension hook. + protected function pScalar_Float(Float_ $node): string + { + $rawValue = $node->getAttribute('rawValue'); + + if ($node->getAttribute('shouldPrintRawValue') === true && is_string($rawValue)) { + return $rawValue; + } + + return parent::pScalar_Float($node); + } + }; + $fixedCode = $prettyPrinter->printFormatPreserving( + $statements, + $originalStatements, + $parser->getTokens(), + ); return $fixedCode !== $code && file_put_contents($file, $fixedCode) !== false; } diff --git a/src/Rule/Fixer/PhpParser/Scalar/AddNumericLiteralSeparatorsVisitor.php b/src/Rule/Fixer/PhpParser/Scalar/AddNumericLiteralSeparatorsVisitor.php new file mode 100644 index 00000000..30d208bf --- /dev/null +++ b/src/Rule/Fixer/PhpParser/Scalar/AddNumericLiteralSeparatorsVisitor.php @@ -0,0 +1,50 @@ +literal === null + || $this->replacement === null + || (! $node instanceof Int_ && ! $node instanceof Float_) + || $node->getStartLine() !== $this->line + || ($node instanceof Int_ && $node->getAttribute('kind') !== Int_::KIND_DEC) + || $node->getAttribute('rawValue') !== $this->literal + || str_replace('_', '', $this->replacement) !== $this->literal + ) { + return null; + } + + $attributes = $node->getAttributes(); + unset($attributes['origNode']); + $attributes['rawValue'] = $this->replacement; + $attributes['shouldPrintRawValue'] = true; + + return $node instanceof Int_ + ? new Int_($node->value, $attributes) + : new Float_($node->value, $attributes); + } +} diff --git a/src/Rule/RuleViolation.php b/src/Rule/RuleViolation.php index 9c7ca67b..f0a95e44 100644 --- a/src/Rule/RuleViolation.php +++ b/src/Rule/RuleViolation.php @@ -20,6 +20,7 @@ public function __construct( public ?string $constantName = null, public ?string $propertyName = null, public ?string $functionName = null, + public ?string $numericLiteral = null, ) { } @@ -66,6 +67,10 @@ public function toArray(): array $data['function'] = $this->functionName; } + if ($this->numericLiteral !== null) { + $data['numericLiteral'] = $this->numericLiteral; + } + return $data; } } diff --git a/src/Rule/Rules/File/LargeNumericLiteralMustUseSeparatorRule.php b/src/Rule/Rules/File/LargeNumericLiteralMustUseSeparatorRule.php new file mode 100644 index 00000000..9d2fb1da --- /dev/null +++ b/src/Rule/Rules/File/LargeNumericLiteralMustUseSeparatorRule.php @@ -0,0 +1,161 @@ +|null $sourcePaths + */ + public function __construct( + private int $minimum = 10_000, + ?array $sourcePaths = null, + ?PhpFileFinder $phpFileFinder = null, + ) { + if ($this->minimum < 1) { + throw new InvalidArgumentException('The minimum must be a positive integer.'); + } + + $this->phpFileFinder = $phpFileFinder ?? new PhpFileFinder($sourcePaths); + } + + public function evaluateProject(string $basePath, Architecture $architecture, array $skipPaths = []): ?RuleViolation + { + return $this->evaluateProjectAll($basePath, $architecture, $skipPaths)[0] ?? null; + } + + /** + * @param list $skipPaths + * @return list + */ + public function evaluateProjectAll(string $basePath, Architecture $architecture, array $skipPaths = []): array + { + $files = $this->phpFileFinder->files($basePath, $skipPaths); + $extractionResult = (new AnalysisNodeExtractor())->extract($files); + + return $this->evaluateFiles($files, new FileAnalysisProvider($extractionResult->fileAnalyses)); + } + + /** + * @param list $skipPaths + * @return list + */ + public function evaluateProjectAllWithProvider( + string $basePath, + Architecture $architecture, + FileAnalysisProvider $fileAnalysisProvider, + array $skipPaths = [], + ): array { + return $this->evaluateFiles( + $this->phpFileFinder->filesFromScope( + $basePath, + $fileAnalysisProvider->scopeFiles(), + $skipPaths, + ), + $fileAnalysisProvider, + ); + } + + protected function createFixerVisitor(RuleViolation $ruleViolation): NodeVisitor + { + $literal = $ruleViolation->numericLiteral; + + return new AddNumericLiteralSeparatorsVisitor( + $ruleViolation->line, + $literal, + $literal !== null ? $this->formatDecimalLiteral($literal) : null, + ); + } + + /** + * @param list $files + * @return list + */ + private function evaluateFiles(array $files, FileAnalysisProvider $fileAnalysisProvider): array + { + $violations = []; + + foreach ($files as $file) { + $fileAnalysis = $fileAnalysisProvider->analyse($file); + $fileAnalysisProvider->releaseAst($file); + + foreach ($fileAnalysis->numericLiterals as [$line, $literal, $value]) { + if ( + (! is_int($value) && ! is_float($value)) + || abs($value) < $this->minimum + || str_contains($literal, '_') + ) { + continue; + } + + $replacement = $this->formatDecimalLiteral($literal); + + if ($replacement === null || $replacement === $literal) { + continue; + } + + $violations[] = new RuleViolation( + message: sprintf( + 'Numeric literal [%s] must use separator formatting [%s]', + $literal, + $replacement, + ), + file: $file, + line: $line, + className: '', + numericLiteral: $literal, + ); + } + } + + return $violations; + } + + private function formatDecimalLiteral(string $literal): ?string + { + if (preg_match('/^(?:0|[1-9][0-9]*)$/D', $literal) === 1) { + return $this->groupDigits($literal); + } + + if (preg_match('/^([0-9]+)\.([0-9]*)$/D', $literal, $matches) !== 1) { + return null; + } + + return $this->groupDigits($matches[1]) . '.' . $matches[2]; + } + + private function groupDigits(string $digits): string + { + return strrev(implode('_', str_split(strrev($digits), 3))); + } +} diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index 9b9e2aec..e7674804 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -86,6 +86,34 @@ public function testResetsKeywordConstantSpellingsPerFile(): void $this->assertSame([], $analysisNodeCollector->getNonCanonicalKeywordConstants()); } + public function testCollectsNumericLiteralSpellingsAndValues(): void + { + $analysisNodeCollector = $this->makeCollector(<<<'PHP' +assertSame( + [ + [3, '10000', 10000], + [4, '10_000', 10000], + [5, '0xFFFFFF', 16777215], + [6, '1e10', 10000000000.0], + [7, '10000.5', 10000.5], + ], + $analysisNodeCollector->getNumericLiterals(), + ); + + $analysisNodeCollector->setCurrentFile('/fake/path/Bar.php'); + + $this->assertSame([], $analysisNodeCollector->getNumericLiterals()); + } + private function collect(string $code): ClassNode { $nodes = $this->collectNodes($code); diff --git a/tests/Analyser/AnalysisNodeExtractorTest.php b/tests/Analyser/AnalysisNodeExtractorTest.php index 8e7ef7a5..b8b9aff3 100644 --- a/tests/Analyser/AnalysisNodeExtractorTest.php +++ b/tests/Analyser/AnalysisNodeExtractorTest.php @@ -74,6 +74,22 @@ public function testExtractRecordsKeywordConstantSpellingsInFileAnalysis(): void $this->assertTrue($extractionResult->fileAnalyses[$file]->hasSideEffects); } + public function testExtractRecordsNumericLiteralsInFileAnalysis(): void + { + $dir = $this->makeTemporaryDirectory('structarmed-extractor-test'); + $file = $dir . '/Foo.php'; + + file_put_contents($file, " 'App\\Domain'], $dir); + $extractionResult = (new AnalysisNodeExtractor($namespaceLayerResolver))->extract([$file]); + + $this->assertSame( + [[3, '10000', 10000], [3, '10_000', 10000], [3, '1e10', 10000000000.0]], + $extractionResult->fileAnalyses[$file]->numericLiterals, + ); + } + public function testExtractSkipsFilesWithParseErrors(): void { $dir = $this->makeTemporaryDirectory('structarmed-extractor-test'); diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index f249f26d..7ec5169b 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -125,6 +125,7 @@ className: self::class, methodName: 'save', constantName: 'VERSION', propertyName: 'status', + numericLiteral: '10000', )); try { @@ -1576,6 +1577,7 @@ public function testStoresAndLoadsClassNodesWithFileAnalysis(): void hasSideEffects: false, sideEffectLine: 1, nonCanonicalKeywordConstants: [[3, 'TRUE'], [5, '\\NULL']], + numericLiterals: [[7, '10000', 10000], [8, '1e10', 10000000000.0]], ); file_put_contents($sourceFile, ' false, 'sideEffectLine' => 1, 'nonCanonicalKeywordConstants' => [], + 'numericLiterals' => [], ]; yield 'numeric keys' => [[0 => 'bad']]; @@ -1670,6 +1673,12 @@ public static function malformedFileAnalysisProvider(): iterable ]; yield 'keyword constant with invalid line' => [[...$valid, 'nonCanonicalKeywordConstants' => [['1', 'TRUE']]]]; yield 'keyword constant with invalid spelling' => [[...$valid, 'nonCanonicalKeywordConstants' => [[1, 1]]]]; + yield 'missing numeric literals' => [[...$valid, 'numericLiterals' => null]]; + yield 'numeric literals not a list' => [[...$valid, 'numericLiterals' => ['bad' => [1, '10000', 10000]]]]; + yield 'numeric literal not a triple' => [[...$valid, 'numericLiterals' => [[1, '10000']]]]; + yield 'numeric literal with invalid line' => [[...$valid, 'numericLiterals' => [['1', '10000', 10000]]]]; + yield 'numeric literal with invalid spelling' => [[...$valid, 'numericLiterals' => [[1, 10000, 10000]]]]; + yield 'numeric literal with invalid value' => [[...$valid, 'numericLiterals' => [[1, '10000', '10000']]]]; } /** @param array $fileAnalysis */ diff --git a/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleTest.php b/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleTest.php new file mode 100644 index 00000000..44c61a5c --- /dev/null +++ b/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleTest.php @@ -0,0 +1,132 @@ +makeProject(<<<'PHP' +evaluateProjectAll( + $basePath, + Architecture::define(), + ); + + $this->assertCount(3, $violations); + + foreach ($violations as $violation) { + $this->assertTrue($largeNumericLiteralMustUseSeparatorRule->fix($violation)); + } + + $this->assertSame(<<<'PHP' +assertSame( + [], + $largeNumericLiteralMustUseSeparatorRule->evaluateProjectAll($basePath, Architecture::define()), + ); + } + + public function testFixTargetsTheLiteralSpellingOnItsLine(): void + { + $basePath = $this->makeProject("evaluateProjectAll( + $basePath, + Architecture::define(), + ); + + $this->assertCount(2, $violations); + $this->assertTrue($largeNumericLiteralMustUseSeparatorRule->fix($violations[1])); + $this->assertSame( + "assertTrue($largeNumericLiteralMustUseSeparatorRule->fix($violations[0])); + $this->assertSame( + "makeProject("layer('Source', 'src/') + ->rule('numeric.separator', $largeNumericLiteralMustUseSeparatorRule); + + foreach ([AnalyserOptions::sequential(), AnalyserOptions::parallel(2)] as $options) { + $violations = array_values(iterator_to_array( + (new Analyser($basePath))->analyse($architecture, [], null, $options), + )); + + $this->assertCount(1, $violations); + $this->assertSame('numeric.separator', $violations[0]->ruleKey); + $this->assertTrue($violations[0]->fixable); + $this->assertSame('1000000', $violations[0]->numericLiteral); + } + + $this->assertTrue($largeNumericLiteralMustUseSeparatorRule->fix($violations[0])); + $this->assertSame("makeTemporaryDirectory('structarmed-numeric-separator'); + mkdir($basePath . '/src'); + file_put_contents($basePath . '/src/Foo.php', $code); + + $realBasePath = realpath($basePath); + $this->assertIsString($realBasePath); + + return Path::normalise($realBasePath, canonicalise: true); + } +} diff --git a/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleUnitTest.php b/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleUnitTest.php new file mode 100644 index 00000000..ea4db2b4 --- /dev/null +++ b/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleUnitTest.php @@ -0,0 +1,181 @@ +evaluate( + [[3, '10000', 10000], [4, '100000', 100000], [5, '1000000', 1000000]], + largeNumericLiteralMustUseSeparatorRule: $largeNumericLiteralMustUseSeparatorRule, + ); + + $this->assertInstanceOf(FixableInterface::class, $largeNumericLiteralMustUseSeparatorRule); + $this->assertSame( + [ + 'Numeric literal [10000] must use separator formatting [10_000]', + 'Numeric literal [100000] must use separator formatting [100_000]', + 'Numeric literal [1000000] must use separator formatting [1_000_000]', + ], + array_map(static fn (RuleViolation $ruleViolation): string => $ruleViolation->message, $violations), + ); + $this->assertSame( + [3, 4, 5], + array_map(static fn (RuleViolation $ruleViolation): int => $ruleViolation->line, $violations), + ); + $this->assertSame( + ['10000', '100000', '1000000'], + array_map( + static fn (RuleViolation $ruleViolation): ?string => $ruleViolation->numericLiteral, + $violations, + ), + ); + } + + public function testIgnoresBelowThresholdAndAlreadySeparatedIntegers(): void + { + $this->assertSame([], $this->evaluate([ + [3, '9999', 9999], + [4, '10_000', 10000], + [5, '1_000_000', 1000000], + [6, '9999.99', 9999.99], + [7, '10_000.0', 10000.0], + ])); + } + + public function testReportsPlainDecimalFloatsAndPreservesTheirFractionalParts(): void + { + $violations = $this->evaluate([ + [3, '10000.0', 10000.0], + [4, '1000000.0', 1000000.0], + [5, '1000500.001', 1000500.001], + ]); + + $this->assertSame( + [ + 'Numeric literal [10000.0] must use separator formatting [10_000.0]', + 'Numeric literal [1000000.0] must use separator formatting [1_000_000.0]', + 'Numeric literal [1000500.001] must use separator formatting [1_000_500.001]', + ], + array_map(static fn (RuleViolation $ruleViolation): string => $ruleViolation->message, $violations), + ); + } + + public function testSupportsCustomMinimum(): void + { + $violations = $this->evaluate( + [[3, '1000', 1000]], + new LargeNumericLiteralMustUseSeparatorRule(minimum: 1_000, sourcePaths: ['src/']), + ); + + $this->assertCount(1, $violations); + $this->assertSame( + 'Numeric literal [1000] must use separator formatting [1_000]', + $violations[0]->message, + ); + } + + public function testRejectsNonPositiveMinimum(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The minimum must be a positive integer.'); + + new LargeNumericLiteralMustUseSeparatorRule(minimum: 0); + } + + public function testIgnoresUnsupportedNumericSyntaxesAndFloats(): void + { + $this->assertSame([], $this->evaluate([ + [3, '0xFFFFFF', 16777215], + [4, '0b11111111', 255], + [5, '0o755', 493], + [6, '077777', 32767], + [7, '1e10', 10000000000.0], + [8, '1.2e6', 1200000.0], + ])); + } + + public function testUsesTheLiteralMagnitudeCollectedInsideAUnaryMinus(): void + { + $violations = $this->evaluate([[3, '100000', 100000]]); + + $this->assertCount(1, $violations); + $this->assertSame('100000', $violations[0]->numericLiteral); + } + + public function testVisitorWithoutLiteralPayloadDoesNothing(): void + { + $int = Int_::fromString('10000', ['startLine' => 3]); + $addNumericLiteralSeparatorsVisitor = new AddNumericLiteralSeparatorsVisitor(3, null, null); + + $this->assertNotInstanceOf(Node::class, $addNumericLiteralSeparatorsVisitor->enterNode($int)); + $this->assertSame('10000', $int->getAttribute('rawValue')); + } + + public function testDoesNotReportAValueTooShortToContainASeparator(): void + { + $this->assertSame( + [], + $this->evaluate( + [[3, '1', 1]], + new LargeNumericLiteralMustUseSeparatorRule(minimum: 1, sourcePaths: ['src/']), + ), + ); + } + + /** + * @param list $numericLiterals + * @return list + */ + private function evaluate( + array $numericLiterals, + ?LargeNumericLiteralMustUseSeparatorRule $largeNumericLiteralMustUseSeparatorRule = null, + ): array { + $fileAnalysis = new FileAnalysis( + file: self::FILE, + hasUtf8Bom: false, + hasValidUtf8: true, + invalidPhpTagLine: null, + hasValidAst: true, + declaresSymbols: false, + hasSideEffects: true, + sideEffectLine: 3, + numericLiterals: $numericLiterals, + ); + $fileAnalysisProvider = FileAnalysisProvider::forScope( + [self::FILE => $fileAnalysis], + [self::FILE], + ); + + return ($largeNumericLiteralMustUseSeparatorRule + ?? new LargeNumericLiteralMustUseSeparatorRule(sourcePaths: ['src/'])) + ->evaluateProjectAllWithProvider(self::BASE_PATH, Architecture::define(), $fileAnalysisProvider); + } +} From 90cbfbe53cc0049aa668b5c84a7b1b05dce1fe95 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 23:03:19 +0700 Subject: [PATCH 067/104] fix phpstan --- .../AddNumericLiteralSeparatorsVisitor.php | 24 ++++++++++++------- ...argeNumericLiteralMustUseSeparatorRule.php | 8 +------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Rule/Fixer/PhpParser/Scalar/AddNumericLiteralSeparatorsVisitor.php b/src/Rule/Fixer/PhpParser/Scalar/AddNumericLiteralSeparatorsVisitor.php index 30d208bf..1ed42459 100644 --- a/src/Rule/Fixer/PhpParser/Scalar/AddNumericLiteralSeparatorsVisitor.php +++ b/src/Rule/Fixer/PhpParser/Scalar/AddNumericLiteralSeparatorsVisitor.php @@ -26,15 +26,7 @@ public function __construct( public function enterNode(Node $node): ?Node { - if ( - $this->literal === null - || $this->replacement === null - || (! $node instanceof Int_ && ! $node instanceof Float_) - || $node->getStartLine() !== $this->line - || ($node instanceof Int_ && $node->getAttribute('kind') !== Int_::KIND_DEC) - || $node->getAttribute('rawValue') !== $this->literal - || str_replace('_', '', $this->replacement) !== $this->literal - ) { + if ($this->shouldSkip($node)) { return null; } @@ -47,4 +39,18 @@ public function enterNode(Node $node): ?Node ? new Int_($node->value, $attributes) : new Float_($node->value, $attributes); } + + /** + * @phpstan-assert-if-false Int_|Float_ $node + */ + private function shouldSkip(Node $node): bool + { + return $this->literal === null + || $this->replacement === null + || (! $node instanceof Int_ && ! $node instanceof Float_) + || $node->getStartLine() !== $this->line + || ($node instanceof Int_ && $node->getAttribute('kind') !== Int_::KIND_DEC) + || $node->getAttribute('rawValue') !== $this->literal + || str_replace('_', '', $this->replacement) !== $this->literal; + } } diff --git a/src/Rule/Rules/File/LargeNumericLiteralMustUseSeparatorRule.php b/src/Rule/Rules/File/LargeNumericLiteralMustUseSeparatorRule.php index 9d2fb1da..13a5f766 100644 --- a/src/Rule/Rules/File/LargeNumericLiteralMustUseSeparatorRule.php +++ b/src/Rule/Rules/File/LargeNumericLiteralMustUseSeparatorRule.php @@ -16,8 +16,6 @@ use function abs; use function implode; -use function is_float; -use function is_int; use function preg_match; use function sprintf; use function str_contains; @@ -110,11 +108,7 @@ private function evaluateFiles(array $files, FileAnalysisProvider $fileAnalysisP $fileAnalysisProvider->releaseAst($file); foreach ($fileAnalysis->numericLiterals as [$line, $literal, $value]) { - if ( - (! is_int($value) && ! is_float($value)) - || abs($value) < $this->minimum - || str_contains($literal, '_') - ) { + if (abs($value) < $this->minimum || str_contains($literal, '_')) { continue; } From a086e2ff50473aaaf7847e82d9532e90d7e22fb0 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 23:06:28 +0700 Subject: [PATCH 068/104] update docs --- docs/available-rules.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/available-rules.md b/docs/available-rules.md index 115624e9..0b8ee535 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -64,6 +64,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\File`. | `Psr1ValidUtf8Rule` | `new Psr1ValidUtf8Rule(sourcePaths: ['src/'])` | PHP files use valid UTF-8 encoding. | | `Psr1Utf8WithoutBomRule` | `new Psr1Utf8WithoutBomRule(sourcePaths: ['src/'])` | PHP files do not start with a byte order mark. Supports `--fix`. | | `MustUseLowercaseKeywordConstantRule` | `new MustUseLowercaseKeywordConstantRule(sourcePaths: ['src/'])` | PHP's special keyword constants `true`, `false`, and `null` use their canonical lowercase spelling. Fully qualified forms such as `\TRUE` are preserved as `\true`. Supports `--fix`. | +| `LargeNumericLiteralMustUseSeparatorRule` | `new LargeNumericLiteralMustUseSeparatorRule(minimum: 10_000, sourcePaths: ['src/'])` | Plain decimal integer and float literals whose magnitude is at least `minimum` (default `10_000`) group their integer digits in threes with `_` separators, so `1000500.001` becomes `1_000_500.001`. Hexadecimal, octal, binary, exponent, and already separated literals are ignored. Supports `--fix`. | {: .rule-table } Pass `sourcePaths: null` or omit it to let the rule read PSR-4 paths from `composer.json`. @@ -100,7 +101,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`. `classNamePattern` and `excludePattern` are regular expressions matched against the fully-qualified class name. -`Psr4DirectoryExistsRule`, `Psr1PhpTagsRule`, `Psr1Utf8WithoutBomRule`, `MustUseLowercaseKeywordConstantRule`, `ExtendedClassMustBeAbstractOrInstantiatedRule`, `MustBeFinalRule`, `MustBeUsedInterfaceRule`, `MustBeUsedAbstractClassRule`, `MustBeUsedTraitRule`, `MustDeclareConstantVisibilityRule`, `MustDeclareMethodVisibilityRule`, and `MustDeclarePropertyVisibilityRule` implement `Boundwize\StructArmed\Rule\FixableInterface`, so StructArmed can automatically remove PSR-4 mappings for missing directories, normalize invalid PHP opening tags, remove UTF-8 byte order marks, lowercase `TRUE`/`FALSE`/`NULL` keyword constants, add the `final` or `abstract` class modifier, remove unused interfaces, abstract classes, and traits (deleting their file when only `declare`/`namespace`/`use` boilerplate remains), and add missing constant, method, or property visibility modifiers when you run `vendor/bin/structarmed analyse --fix`. +`Psr4DirectoryExistsRule`, `Psr1PhpTagsRule`, `Psr1Utf8WithoutBomRule`, `MustUseLowercaseKeywordConstantRule`, `LargeNumericLiteralMustUseSeparatorRule`, `ExtendedClassMustBeAbstractOrInstantiatedRule`, `MustBeFinalRule`, `MustBeUsedInterfaceRule`, `MustBeUsedAbstractClassRule`, `MustBeUsedTraitRule`, `MustDeclareConstantVisibilityRule`, `MustDeclareMethodVisibilityRule`, and `MustDeclarePropertyVisibilityRule` implement `Boundwize\StructArmed\Rule\FixableInterface`, so StructArmed can automatically remove PSR-4 mappings for missing directories, normalize invalid PHP opening tags, remove UTF-8 byte order marks, lowercase `TRUE`/`FALSE`/`NULL` keyword constants, add `_` separators to large numeric literals, add the `final` or `abstract` class modifier, remove unused interfaces, abstract classes, and traits (deleting their file when only `declare`/`namespace`/`use` boilerplate remains), and add missing constant, method, or property visibility modifiers when you run `vendor/bin/structarmed analyse --fix`. ## Function Rules From 6ab8aadabeeb05debfa1fb36ba06513848c09906 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 23:16:09 +0700 Subject: [PATCH 069/104] add more test --- ...NumericLiteralMustUseSeparatorRuleTest.php | 21 +++++++++++++++++++ tests/Rule/RuleViolationTest.php | 13 ++++++++++++ 2 files changed, 34 insertions(+) diff --git a/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleTest.php b/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleTest.php index 44c61a5c..90752a87 100644 --- a/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleTest.php +++ b/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleTest.php @@ -13,6 +13,9 @@ use Boundwize\StructArmed\Rule\Rules\File\LargeNumericLiteralMustUseSeparatorRule; use Boundwize\StructArmed\Tests\Support\TemporaryDirectoryCleanupTrait; use Boundwize\StructArmed\Util\Path; +use PhpParser\Node; +use PhpParser\Node\Scalar\Float_; +use PhpParser\NodeVisitorAbstract; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -81,6 +84,10 @@ public function testFixTargetsTheLiteralSpellingOnItsLine(): void ); $this->assertCount(2, $violations); + $this->assertEquals( + $violations[0], + $largeNumericLiteralMustUseSeparatorRule->evaluateProject($basePath, Architecture::define()), + ); $this->assertTrue($largeNumericLiteralMustUseSeparatorRule->fix($violations[1])); $this->assertSame( "makeProject("assertTrue((new PhpParserFixerProcessor())->process($basePath . '/src/Foo.php', $visitor)); + $this->assertSame("makeProject("assertSame('status', $ruleViolation->toArray()['property']); } + public function testViolationSerializesNumericLiteralWhenPresent(): void + { + $ruleViolation = new RuleViolation( + message: 'Broken rule', + file: '/src/File.php', + line: 7, + className: '', + numericLiteral: '10000', + ); + + $this->assertSame('10000', $ruleViolation->toArray()['numericLiteral']); + } + public function testCollectionFiltersAndSerializesViolations(): void { $collection = new RuleViolationCollection(); From cdcec0fbc9011c18f867962211ac0133cc786386 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 23:29:45 +0700 Subject: [PATCH 070/104] Add CodeQuality preset with MustBeStaticAnonymousFunctionRule and LargeNumericLiteralMustUseSeparatorRule --- docs/cli.md | 1 + docs/configuration.md | 4 ++ docs/presets.md | 3 ++ docs/quick-start.md | 3 ++ src/Cli/InitCommand.php | 4 +- src/Cli/Usage.php | 2 +- src/Preset/Preset.php | 13 +++++ src/Preset/Presets/CodeQualityPreset.php | 47 +++++++++++++++++++ tests/Cli/InitCommandTest.php | 8 +++- ...ructArmedApplicationCommandRoutingTest.php | 2 +- tests/Cli/StructArmedApplicationTest.php | 8 +++- tests/Preset/PresetTest.php | 44 +++++++++++++++++ 12 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 src/Preset/Presets/CodeQualityPreset.php diff --git a/docs/cli.md b/docs/cli.md index 4974d51a..1ff7a925 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -26,6 +26,7 @@ vendor/bin/structarmed init --preset=psr15 vendor/bin/structarmed init --preset=mvc vendor/bin/structarmed init --preset=ddd vendor/bin/structarmed init --preset=yagni +vendor/bin/structarmed init --preset=codequality vendor/bin/structarmed init --preset=all ``` diff --git a/docs/configuration.md b/docs/configuration.md index 5462a038..5d95f6c3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -113,4 +113,8 @@ Use [Custom Rules And Presets](../custom-rules-and-presets/) when you want to ad ->withPreset(Preset::PSR4( sourcePaths: ['src/', 'tests/'], // default: read composer.json PSR-4 paths )) + +->withPreset(Preset::CODEQUALITY( + sourcePaths: ['src/', 'tests/'], // default: read composer.json PSR-4 paths +)) ``` diff --git a/docs/presets.md b/docs/presets.md index cd8cfa53..ef621c7b 100644 --- a/docs/presets.md +++ b/docs/presets.md @@ -27,6 +27,7 @@ StructArmed ships with presets for common PHP standards and architecture styles. | `Preset::DDD()` | Layer isolation, entity/VO/repository/event/service conventions, including keeping Doctrine ORM repository inheritance out of the Domain layer | | `Preset::MVC()` | Layer isolation, thin controllers, model/view/service rules, return types for helper functions | | `Preset::YAGNI()` | Speculative-abstraction cleanup: interfaces must be implemented by a class or extended by another interface, abstract classes must be extended, traits must be used, and extended classes that are never instantiated must be abstract — a dependency reference (type hint, `instanceof`, `::class`, static call, a class-name string, ...) also counts as usage within the scanned paths, while only instantiation (`new X`, `new self`/`static`/`parent`, or a constant class expression such as `new (X::class)`) keeps an extended class concrete. All rules support `--fix`, removing the unused declaration or adding the `abstract` modifier | +| `Preset::CODEQUALITY()` | General readability conventions independent of any architecture style: closures and arrow functions that do not read `$this` must be declared `static`, and plain decimal numeric literals of `10_000` or more must group their digits with `_` separators (`1000500` becomes `1_000_500`). Both rules support `--fix`. Tune the literal threshold with `replaceRule(CodeQualityPreset::LARGE_NUMERIC_LITERALS_MUST_USE_SEPARATOR, new LargeNumericLiteralMustUseSeparatorRule(minimum: 1_000))` | ## Initialize Presets @@ -39,6 +40,7 @@ vendor/bin/structarmed init --preset=psr15 vendor/bin/structarmed init --preset=mvc vendor/bin/structarmed init --preset=ddd vendor/bin/structarmed init --preset=yagni +vendor/bin/structarmed init --preset=codequality vendor/bin/structarmed init --preset=all ``` @@ -55,6 +57,7 @@ return Architecture::define() Preset::MVC(), Preset::DDD(), Preset::YAGNI(), + Preset::CODEQUALITY(), ); ``` diff --git a/docs/quick-start.md b/docs/quick-start.md index 47d67dae..81e70b46 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -48,6 +48,9 @@ vendor/bin/structarmed init --preset=ddd # Remove speculative abstractions: unimplemented interfaces, unextended abstract classes, unused traits vendor/bin/structarmed init --preset=yagni +# Static closures and digit separators in large numeric literals +vendor/bin/structarmed init --preset=codequality + # Enable every preset at once vendor/bin/structarmed init --preset=all ``` diff --git a/src/Cli/InitCommand.php b/src/Cli/InitCommand.php index b361948c..ea3b27da 100644 --- a/src/Cli/InitCommand.php +++ b/src/Cli/InitCommand.php @@ -89,6 +89,7 @@ private function presetConfig(string $preset): ?string 'per' => ' ->withPreset(Preset::PER());', 'psr15' => ' ->withPreset(Preset::PSR15());', 'yagni' => ' ->withPreset(Preset::YAGNI());', + 'codequality' => ' ->withPreset(Preset::CODEQUALITY());', 'all' => " ->withPresets(\n" . " Preset::PSR4(),\n" . " Preset::PSR1(),\n" @@ -97,7 +98,8 @@ private function presetConfig(string $preset): ?string . " Preset::PSR15(),\n" . " Preset::DDD(),\n" . " Preset::MVC(),\n" - . " Preset::YAGNI()\n" + . " Preset::YAGNI(),\n" + . " Preset::CODEQUALITY()\n" . " );", default => null, }; diff --git a/src/Cli/Usage.php b/src/Cli/Usage.php index 70cbbfa1..4e871447 100644 --- a/src/Cli/Usage.php +++ b/src/Cli/Usage.php @@ -11,7 +11,7 @@ public static function render(): string return <<<'TXT' Usage: structarmed --version - structarmed init [--preset=ddd|mvc|psr4|psr1|psr12|per|psr15|yagni|all] + structarmed init [--preset=ddd|mvc|psr4|psr1|psr12|per|psr15|yagni|codequality|all] structarmed analyse|analyze [path ...] [--config=path/to/structarmed.php] [--report=console|json] [--no-progress] [--clear-cache] [--disable-parallel] [--fix] [--generate-baseline=structarmed-baseline.php] diff --git a/src/Preset/Preset.php b/src/Preset/Preset.php index 2462e0ca..e810a360 100644 --- a/src/Preset/Preset.php +++ b/src/Preset/Preset.php @@ -4,6 +4,7 @@ namespace Boundwize\StructArmed\Preset; +use Boundwize\StructArmed\Preset\Presets\CodeQualityPreset; use Boundwize\StructArmed\Preset\Presets\DddPreset; use Boundwize\StructArmed\Preset\Presets\MvcPreset; use Boundwize\StructArmed\Preset\Presets\PerPreset; @@ -25,6 +26,7 @@ * ->withPreset(Preset::PER()) * ->withPreset(Preset::PSR15()) * ->withPreset(Preset::YAGNI()) + * ->withPreset(Preset::CODEQUALITY()) * ->withPresets(Preset::DDD(), Preset::MVC()) */ final class Preset @@ -111,6 +113,17 @@ public static function YAGNI( ); } + /** + * @param list|null $sourcePaths + */ + public static function CODEQUALITY( + ?array $sourcePaths = null, + ): CodeQualityPreset { + return new CodeQualityPreset( + sourcePaths: $sourcePaths, + ); + } + public static function MVC( int $controllerMaxComplexity = 5, int $controllerMaxMethodLength = 20, diff --git a/src/Preset/Presets/CodeQualityPreset.php b/src/Preset/Presets/CodeQualityPreset.php new file mode 100644 index 00000000..c4c96f8d --- /dev/null +++ b/src/Preset/Presets/CodeQualityPreset.php @@ -0,0 +1,47 @@ +|null $sourcePaths + */ + public function __construct( + private ?array $sourcePaths = null, + ) { + } + + public function apply(Architecture $architecture): void + { + $layerName = $this->resolveLayerName($architecture); + $architecture->layer($layerName, $this->sourcePaths ?? []); + + $architecture->rule( + self::ANONYMOUS_FUNCTIONS_MUST_BE_STATIC, + new MustBeStaticAnonymousFunctionRule($layerName) + ); + $architecture->rule( + self::LARGE_NUMERIC_LITERALS_MUST_USE_SEPARATOR, + new LargeNumericLiteralMustUseSeparatorRule(sourcePaths: $this->sourcePaths) + ); + } +} diff --git a/tests/Cli/InitCommandTest.php b/tests/Cli/InitCommandTest.php index 33a35dda..a9f5b365 100644 --- a/tests/Cli/InitCommandTest.php +++ b/tests/Cli/InitCommandTest.php @@ -79,6 +79,11 @@ public static function presetProvider(): iterable ' ->withPreset(Preset::YAGNI());', ]; + yield 'codequality' => [ + ['--preset=codequality'], + ' ->withPreset(Preset::CODEQUALITY());', + ]; + yield 'all' => [ ['--preset=all'], " ->withPresets(\n" @@ -89,7 +94,8 @@ public static function presetProvider(): iterable . " Preset::PSR15(),\n" . " Preset::DDD(),\n" . " Preset::MVC(),\n" - . " Preset::YAGNI()\n" + . " Preset::YAGNI(),\n" + . " Preset::CODEQUALITY()\n" . " );", ]; } diff --git a/tests/Cli/StructArmedApplicationCommandRoutingTest.php b/tests/Cli/StructArmedApplicationCommandRoutingTest.php index 396c51a7..fb8f6229 100644 --- a/tests/Cli/StructArmedApplicationCommandRoutingTest.php +++ b/tests/Cli/StructArmedApplicationCommandRoutingTest.php @@ -35,7 +35,7 @@ public function testApplicationPrintsUsageWithoutCommand(): void $this->assertSame(0, $exitCode); $this->assertStringContainsString('structarmed --version', $output); $this->assertStringContainsString( - 'structarmed init [--preset=ddd|mvc|psr4|psr1|psr12|per|psr15|yagni|all]', + 'structarmed init [--preset=ddd|mvc|psr4|psr1|psr12|per|psr15|yagni|codequality|all]', $output ); $this->assertStringContainsString('structarmed analyse|analyze', $output); diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 2cd75a46..219b21a4 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -182,6 +182,11 @@ public static function presetProvider(): iterable ' ->withPreset(Preset::YAGNI());', ]; + yield 'codequality' => [ + ['--preset=codequality'], + ' ->withPreset(Preset::CODEQUALITY());', + ]; + yield 'all' => [ ['--preset=all'], " ->withPresets(\n" @@ -192,7 +197,8 @@ public static function presetProvider(): iterable . " Preset::PSR15(),\n" . " Preset::DDD(),\n" . " Preset::MVC(),\n" - . " Preset::YAGNI()\n" + . " Preset::YAGNI(),\n" + . " Preset::CODEQUALITY()\n" . " );", ]; } diff --git a/tests/Preset/PresetTest.php b/tests/Preset/PresetTest.php index b985148d..26336ed9 100644 --- a/tests/Preset/PresetTest.php +++ b/tests/Preset/PresetTest.php @@ -6,6 +6,7 @@ use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Preset\Preset; +use Boundwize\StructArmed\Preset\Presets\CodeQualityPreset; use Boundwize\StructArmed\Preset\Presets\DddPreset; use Boundwize\StructArmed\Preset\Presets\MvcPreset; use Boundwize\StructArmed\Preset\Presets\PerPreset; @@ -20,10 +21,13 @@ use Boundwize\StructArmed\Rule\Rules\Class_\MustBeUsedAbstractClassRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeUsedInterfaceRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeUsedTraitRule; +use Boundwize\StructArmed\Rule\Rules\File\LargeNumericLiteralMustUseSeparatorRule; +use Boundwize\StructArmed\Rule\Rules\Function_\MustBeStaticAnonymousFunctionRule; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; #[CoversClass(Preset::class)] +#[CoversClass(CodeQualityPreset::class)] #[CoversClass(DddPreset::class)] #[CoversClass(MvcPreset::class)] #[CoversClass(PerPreset::class)] @@ -74,6 +78,46 @@ public function testYagniPresetUsesComposerSourcePathsByDefault(): void $this->assertSame(['Source' => []], $architecture->getLayers()); } + public function testCodeQualityPresetRegistersSourceLayerAndRules(): void + { + $architecture = Architecture::define(); + + Preset::CODEQUALITY( + sourcePaths: ['src/'], + )->apply($architecture); + + $this->assertSame(['Source' => ['src/']], $architecture->getLayers()); + + $rules = $architecture->getRules(); + $this->assertCount(2, $rules); + $this->assertInstanceOf( + MustBeStaticAnonymousFunctionRule::class, + $rules[CodeQualityPreset::ANONYMOUS_FUNCTIONS_MUST_BE_STATIC] ?? null + ); + $this->assertInstanceOf( + LargeNumericLiteralMustUseSeparatorRule::class, + $rules[CodeQualityPreset::LARGE_NUMERIC_LITERALS_MUST_USE_SEPARATOR] ?? null + ); + } + + public function testCodeQualityPresetUsesComposerSourcePathsByDefault(): void + { + $architecture = Architecture::define(); + + Preset::CODEQUALITY()->apply($architecture); + + // A null source path list defers to Composer-discovered PSR-4 paths. + $this->assertSame(['Source' => []], $architecture->getLayers()); + $this->assertArrayHasKey( + CodeQualityPreset::ANONYMOUS_FUNCTIONS_MUST_BE_STATIC, + $architecture->getRules() + ); + $this->assertArrayHasKey( + CodeQualityPreset::LARGE_NUMERIC_LITERALS_MUST_USE_SEPARATOR, + $architecture->getRules() + ); + } + public function testPsr1PresetRegistersSourceLayerAndRules(): void { $architecture = Architecture::define(); From 6bab9d9d8463f8c8dbadc0b722cf17f47aadb98b Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 23:33:54 +0700 Subject: [PATCH 071/104] enable CODEQUALITY preset --- structarmed.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/structarmed.php b/structarmed.php index 6a93f3b8..bc75e249 100644 --- a/structarmed.php +++ b/structarmed.php @@ -4,6 +4,7 @@ use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Preset\Preset; +use Boundwize\StructArmed\Preset\Presets\CodeQualityPreset; use Boundwize\StructArmed\Preset\Presets\Psr1Preset; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule; @@ -52,8 +53,11 @@ __DIR__ . '/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php', __DIR__ . '/tests/Analyser/Parallel/MockFunctions.php', ], + CodeQualityPreset::LARGE_NUMERIC_LITERALS_MUST_USE_SEPARATOR => [ + __DIR__ . '/tests', + ], ]) - ->withPresets(Preset::PSR1(), Preset::PSR12(), Preset::PSR4(), Preset::YAGNI()) + ->withPresets(Preset::PSR1(), Preset::PSR12(), Preset::PSR4(), Preset::YAGNI(), Preset::CODEQUALITY()) ->rule( 'source.must_be_final', new MustBeFinalRule(layer: 'Source') From 169ef6bfb2bbf69157c62277c83e37aaad4b18b5 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 23:35:00 +0700 Subject: [PATCH 072/104] cs fix --- structarmed.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/structarmed.php b/structarmed.php index bc75e249..bbd80dcf 100644 --- a/structarmed.php +++ b/structarmed.php @@ -46,10 +46,10 @@ ]) ->skip([ 'tests/Fixtures/', - Psr1Preset::METHODS_MUST_BE_CAMEL_CASE => [ + Psr1Preset::METHODS_MUST_BE_CAMEL_CASE => [ __DIR__ . '/src/Preset/Preset.php', ], - Psr1Preset::FILES_SHOULD_DECLARE_SYMBOLS_OR_SIDE_EFFECTS => [ + Psr1Preset::FILES_SHOULD_DECLARE_SYMBOLS_OR_SIDE_EFFECTS => [ __DIR__ . '/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php', __DIR__ . '/tests/Analyser/Parallel/MockFunctions.php', ], From e50cc1f451475b59fb592f3ffd7bf2c916302ca3 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 2 Sep 2026 23:41:36 +0700 Subject: [PATCH 073/104] enable PER preset: already include psr4,psr1,psr12 --- structarmed.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structarmed.php b/structarmed.php index bbd80dcf..d20dd493 100644 --- a/structarmed.php +++ b/structarmed.php @@ -57,7 +57,7 @@ __DIR__ . '/tests', ], ]) - ->withPresets(Preset::PSR1(), Preset::PSR12(), Preset::PSR4(), Preset::YAGNI(), Preset::CODEQUALITY()) + ->withPresets(Preset::PER(), Preset::YAGNI(), Preset::CODEQUALITY()) ->rule( 'source.must_be_final', new MustBeFinalRule(layer: 'Source') From c62023d553c2ba3eeec59e49e12115e5e7083e01 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 08:09:45 +0700 Subject: [PATCH 074/104] Align PSR-4 Composer rules for missing or invalid configuration --- .../Composer/Psr4DirectoryExistsRule.php | 16 ++--------- .../Rules/Composer/Psr4SourcePathsRule.php | 16 ++--------- .../Psr4ComposerFilePathNormalisationTest.php | 27 +++++++++---------- .../Composer/Psr4DirectoryExistsRuleTest.php | 24 ++++++++--------- .../Rule/Composer/Psr4SourcePathsRuleTest.php | 24 ++++++++--------- 5 files changed, 40 insertions(+), 67 deletions(-) diff --git a/src/Rule/Rules/Composer/Psr4DirectoryExistsRule.php b/src/Rule/Rules/Composer/Psr4DirectoryExistsRule.php index 633cdca0..27e32319 100644 --- a/src/Rule/Rules/Composer/Psr4DirectoryExistsRule.php +++ b/src/Rule/Rules/Composer/Psr4DirectoryExistsRule.php @@ -13,7 +13,6 @@ use Boundwize\StructArmed\Util\Path; use function dirname; -use function file_exists; use function implode; use function is_dir; use function rtrim; @@ -28,22 +27,10 @@ public function __construct( public function evaluateProject(string $basePath, Architecture $architecture, array $skipPaths = []): ?RuleViolation { - $composerFile = Path::normalise(rtrim($basePath, '/') . '/composer.json', canonicalise: true); - - if (! file_exists($composerFile)) { - return $this->violation( - 'composer.json was not found', - $composerFile - ); - } - $composer = $this->psr4PathResolver->composerConfig($basePath); if ($composer === null) { - return $this->violation( - 'composer.json is not valid JSON', - $composerFile - ); + return null; } $nonExistentPaths = []; @@ -58,6 +45,7 @@ public function evaluateProject(string $basePath, Architecture $architecture, ar return null; } + $composerFile = Path::normalise(rtrim($basePath, '/') . '/composer.json', canonicalise: true); return $this->violation( sprintf( 'PSR-4 source path(s) [%s] declared in composer.json do not exist on disk', diff --git a/src/Rule/Rules/Composer/Psr4SourcePathsRule.php b/src/Rule/Rules/Composer/Psr4SourcePathsRule.php index 8f84fce5..26f58873 100644 --- a/src/Rule/Rules/Composer/Psr4SourcePathsRule.php +++ b/src/Rule/Rules/Composer/Psr4SourcePathsRule.php @@ -11,7 +11,6 @@ use Boundwize\StructArmed\Util\Path; use function array_map; -use function file_exists; use function implode; use function in_array; use function rtrim; @@ -34,22 +33,10 @@ public function __construct( public function evaluateProject(string $basePath, Architecture $architecture, array $skipPaths = []): ?RuleViolation { - $composerFile = Path::normalise(rtrim($basePath, '/') . '/composer.json', canonicalise: true); - - if (! file_exists($composerFile)) { - return $this->violation( - 'composer.json was not found', - $composerFile - ); - } - $composer = $this->psr4PathResolver->composerConfig($basePath); if ($composer === null) { - return $this->violation( - 'composer.json is not valid JSON', - $composerFile - ); + return null; } if ($this->sourcePaths === null) { @@ -72,6 +59,7 @@ public function evaluateProject(string $basePath, Architecture $architecture, ar return null; } + $composerFile = Path::normalise(rtrim($basePath, '/') . '/composer.json', canonicalise: true); return $this->violation( sprintf( 'PSR-4 source path(s) [%s] must exist in composer.json autoload or autoload-dev', diff --git a/tests/Rule/Composer/Psr4ComposerFilePathNormalisationTest.php b/tests/Rule/Composer/Psr4ComposerFilePathNormalisationTest.php index 10929a3f..445afa42 100644 --- a/tests/Rule/Composer/Psr4ComposerFilePathNormalisationTest.php +++ b/tests/Rule/Composer/Psr4ComposerFilePathNormalisationTest.php @@ -7,7 +7,6 @@ use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Rule\Rules\Composer\Psr4DirectoryExistsRule; use Boundwize\StructArmed\Rule\Rules\Composer\Psr4SourcePathsRule; -use Boundwize\StructArmed\Rule\RuleViolation; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -17,25 +16,23 @@ final class Psr4ComposerFilePathNormalisationTest extends TestCase { private const WINDOWS_STYLE_MISSING_BASE_PATH = 'C:\structarmed-missing-fixture\app'; - public function testDirectoryExistsRuleReportsForwardSlashesForWindowsStyleBasePath(): void + public function testDirectoryExistsRulePassesWhenComposerJsonIsMissingAtWindowsStyleBasePath(): void { - $violation = (new Psr4DirectoryExistsRule())->evaluateProject( - self::WINDOWS_STYLE_MISSING_BASE_PATH, - Architecture::define() + $this->assertNull( + (new Psr4DirectoryExistsRule())->evaluateProject( + self::WINDOWS_STYLE_MISSING_BASE_PATH, + Architecture::define() + ) ); - - $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertSame('C:/structarmed-missing-fixture/app/composer.json', $violation->file); } - public function testSourcePathsRuleReportsForwardSlashesForWindowsStyleBasePath(): void + public function testSourcePathsRulePassesWhenComposerJsonIsMissingAtWindowsStyleBasePath(): void { - $violation = (new Psr4SourcePathsRule(null))->evaluateProject( - self::WINDOWS_STYLE_MISSING_BASE_PATH, - Architecture::define() + $this->assertNull( + (new Psr4SourcePathsRule(null))->evaluateProject( + self::WINDOWS_STYLE_MISSING_BASE_PATH, + Architecture::define() + ) ); - - $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertSame('C:/structarmed-missing-fixture/app/composer.json', $violation->file); } } diff --git a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php index e0dc4b82..28f8f9c2 100644 --- a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php +++ b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php @@ -153,23 +153,23 @@ public function testFailsWhenPsr4PathIsAbsoluteAndDoesNotExistOnDisk(): void $this->assertStringContainsString('do not exist on disk', $violation->message); } - public function testFailsWhenComposerJsonIsMissing(): void + public function testPassesWhenComposerJsonIsMissing(): void { - $violation = (new Psr4DirectoryExistsRule())->evaluateProject($this->makeTempDir(), Architecture::define()); - - $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertStringContainsString('composer.json was not found', $violation->message); + $this->assertNotInstanceOf( + RuleViolation::class, + (new Psr4DirectoryExistsRule())->evaluateProject($this->makeTempDir(), Architecture::define()) + ); } - public function testFailsWhenComposerJsonIsInvalid(): void + public function testPassesWhenComposerJsonIsInvalid(): void { - $violation = (new Psr4DirectoryExistsRule())->evaluateProject( - $this->makeTempProject('{not json'), - Architecture::define() + $this->assertNotInstanceOf( + RuleViolation::class, + (new Psr4DirectoryExistsRule())->evaluateProject( + $this->makeTempProject('{not json'), + Architecture::define() + ) ); - - $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertStringContainsString('composer.json is not valid JSON', $violation->message); } public function testPassesWhenNoPsr4PathsAreDeclared(): void diff --git a/tests/Rule/Composer/Psr4SourcePathsRuleTest.php b/tests/Rule/Composer/Psr4SourcePathsRuleTest.php index 34be7cd7..3ae7840a 100644 --- a/tests/Rule/Composer/Psr4SourcePathsRuleTest.php +++ b/tests/Rule/Composer/Psr4SourcePathsRuleTest.php @@ -66,27 +66,27 @@ public function testFailsWhenSourcePathIsMissingFromComposerPsr4Autoloads(): voi $this->assertStringContainsString('tests', $violation->message); } - public function testFailsWhenComposerJsonIsMissing(): void + public function testPassesWhenComposerJsonIsMissing(): void { $psr4SourcePathsRule = new Psr4SourcePathsRule(['src/']); - $violation = $psr4SourcePathsRule->evaluateProject($this->makeTempDir(), Architecture::define()); - - $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertStringContainsString('composer.json was not found', $violation->message); + $this->assertNotInstanceOf( + RuleViolation::class, + $psr4SourcePathsRule->evaluateProject($this->makeTempDir(), Architecture::define()) + ); } - public function testFailsWhenComposerJsonIsInvalid(): void + public function testPassesWhenComposerJsonIsInvalid(): void { $psr4SourcePathsRule = new Psr4SourcePathsRule(['src/']); - $violation = $psr4SourcePathsRule->evaluateProject( - $this->makeTempProject('{not json'), - Architecture::define() + $this->assertNotInstanceOf( + RuleViolation::class, + $psr4SourcePathsRule->evaluateProject( + $this->makeTempProject('{not json'), + Architecture::define() + ) ); - - $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertStringContainsString('composer.json is not valid JSON', $violation->message); } public function testPassesWhenComposerPsr4MappingUsesPathList(): void From 587ef9e53e27b0d3b8ead961926caa3838757968 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 09:13:59 +0700 Subject: [PATCH 075/104] perf: Omit empty lists and the per-node file path from analysis node cache payloads --- src/Cache/AnalysisResultCache.php | 150 ++++++++++-------- tests/Cache/AnalysisResultCacheTest.php | 14 -- .../Psr4ComposerFilePathNormalisationTest.php | 7 +- 3 files changed, 93 insertions(+), 78 deletions(-) diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 9312872e..5bb4541e 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -66,7 +66,7 @@ final class AnalysisResultCache * their shape or naming changes: it is recorded in the metadata marker, * so a cache written by an older format is cleared on its next use. */ - public const FORMAT_VERSION = 3; + public const FORMAT_VERSION = 4; private readonly string $cacheDirectory; @@ -262,7 +262,7 @@ public function loadAnalysisNodesWithFileAnalysis(string $file, string $namespac } $fileAnalysis = is_array($payload['fileAnalysis'] ?? null) - ? $this->fileAnalysisFromArray($payload['fileAnalysis']) + ? $this->fileAnalysisFromArray($payload['fileAnalysis'], $file) : null; if (! $fileAnalysis instanceof FileAnalysis) { @@ -293,7 +293,7 @@ public function loadAnalysisNodesWithFileAnalysis(string $file, string $namespac */ private function analysisNodeResultFromPayload(array $payload, string $file): ?array { - $classNodes = $this->classNodesFromPayload($payload); + $classNodes = $this->classNodesFromPayload($payload, $file); $anonymousClassNodes = $this->anonymousClassNodesFromPayload($payload); $fileReferences = $this->fileReferencesFromPayload($payload); $fileInstantiations = $this->fileInstantiationsFromPayload($payload); @@ -337,7 +337,7 @@ private function analysisNodePayload(string $file, string $namespace): ?array * @param array $payload * @return list|null */ - private function classNodesFromPayload(array $payload): ?array + private function classNodesFromPayload(array $payload, string $file): ?array { if (! is_array($payload['nodes'] ?? null)) { return null; @@ -350,7 +350,7 @@ private function classNodesFromPayload(array $payload): ?array return null; } - $classNode = $this->classNodeFromArray($node); + $classNode = $this->classNodeFromArray($node, $file); if (! $classNode instanceof ClassNode) { return null; @@ -438,23 +438,23 @@ public function storeAnalysisNodes( $this->ensureCacheInitialised(); $payload = [ - 'metadata' => $this->fileMetadata($file, $namespace), - 'nodes' => array_map($this->classNodeToArray(...), $classNodes), - 'anonymousClassNodes' => array_map($this->anonymousClassNodeToArray(...), $anonymousClassNodes), - 'fileReferences' => $fileReferences, - 'fileInstantiations' => $fileInstantiations, + 'metadata' => $this->fileMetadata($file, $namespace), + 'nodes' => array_map($this->classNodeToArray(...), $classNodes), ]; - // Most files declare no function-likes; leave their keys out entirely. - if ($functionNodes !== []) { - $payload['functionNodes'] = array_map($this->functionNodeToArray(...), $functionNodes); - } + // Most files have none of these; leave their keys out entirely. + $lists = [ + 'anonymousClassNodes' => array_map($this->anonymousClassNodeToArray(...), $anonymousClassNodes), + 'fileReferences' => $fileReferences, + 'fileInstantiations' => $fileInstantiations, + 'functionNodes' => array_map($this->functionNodeToArray(...), $functionNodes), + 'anonymousFunctionNodes' => array_map($this->anonymousFunctionNodeToArray(...), $anonymousFunctionNodes), + ]; - if ($anonymousFunctionNodes !== []) { - $payload['anonymousFunctionNodes'] = array_map( - $this->anonymousFunctionNodeToArray(...), - $anonymousFunctionNodes - ); + foreach ($lists as $key => $list) { + if ($list !== []) { + $payload[$key] = $list; + } } if ($fileAnalysis instanceof FileAnalysis) { @@ -892,22 +892,29 @@ private function functionLikeBodyFromArray(array $node, string $file): ?array } /** + * The file is not stored: the payload belongs to one file, known when + * loading. Empty lists — most of a typical class's — are left out and + * default on load. + * * @return array */ private function classNodeToArray(ClassNode $classNode): array { - return [ - 'className' => $classNode->className, - 'file' => $classNode->file, - 'line' => $classNode->line, - 'layer' => $classNode->layer, - 'extends' => $classNode->extends, - 'isAbstract' => $classNode->isAbstract, - 'isFinal' => $classNode->isFinal, - 'isInterface' => $classNode->isInterface, - 'isTrait' => $classNode->isTrait, - 'isEnum' => $classNode->isEnum, - 'isReadonly' => $classNode->isReadonly, + $node = [ + 'className' => $classNode->className, + 'line' => $classNode->line, + 'layer' => $classNode->layer, + 'extends' => $classNode->extends, + 'isAbstract' => $classNode->isAbstract, + 'isFinal' => $classNode->isFinal, + 'isInterface' => $classNode->isInterface, + 'isTrait' => $classNode->isTrait, + 'isEnum' => $classNode->isEnum, + 'isReadonly' => $classNode->isReadonly, + 'enumBackingType' => $classNode->enumBackingType, + ]; + + $lists = [ 'dependencies' => $classNode->dependencies, 'implements' => array_values($classNode->implements), 'interfaceExtends' => array_values($classNode->interfaceExtends), @@ -918,21 +925,27 @@ private function classNodeToArray(ClassNode $classNode): array 'constants' => array_map($this->constantNodeToArray(...), $classNode->constants), 'properties' => array_map($this->propertyNodeToArray(...), $classNode->properties), 'enumCases' => array_map($this->enumCaseNodeToArray(...), $classNode->enumCases), - 'enumBackingType' => $classNode->enumBackingType, 'functionCalls' => array_values($classNode->functionCalls), 'superglobals' => array_values($classNode->superglobals), 'languageConstructs' => array_values($classNode->languageConstructs), 'layers' => $classNode->layers, ]; + + foreach ($lists as $key => $list) { + if ($list !== []) { + $node[$key] = $list; + } + } + + return $node; } /** * @param array $node */ - private function classNodeFromArray(array $node): ?ClassNode + private function classNodeFromArray(array $node, string $file): ?ClassNode { $className = $node['className'] ?? null; - $file = $node['file'] ?? null; $line = $node['line'] ?? null; $layer = $node['layer'] ?? null; $extends = $node['extends'] ?? null; @@ -942,25 +955,24 @@ private function classNodeFromArray(array $node): ?ClassNode $isTrait = $node['isTrait'] ?? null; $isEnum = $node['isEnum'] ?? null; $isReadonly = $node['isReadonly'] ?? null; - $dependencies = $node['dependencies'] ?? null; - $implements = $node['implements'] ?? null; + $dependencies = $node['dependencies'] ?? []; + $implements = $node['implements'] ?? []; $interfaceExtends = $node['interfaceExtends'] ?? []; $parentClasses = $node['parentClasses'] ?? []; $parentInterfaces = $node['parentInterfaces'] ?? []; $traits = $node['traits'] ?? []; - $rawMethods = $node['methods'] ?? null; - $rawConstants = $node['constants'] ?? null; - $rawProperties = $node['properties'] ?? null; + $rawMethods = $node['methods'] ?? []; + $rawConstants = $node['constants'] ?? []; + $rawProperties = $node['properties'] ?? []; $rawEnumCases = $node['enumCases'] ?? []; $enumBackingType = $node['enumBackingType'] ?? null; - $functionCalls = $node['functionCalls'] ?? null; - $superglobals = $node['superglobals'] ?? null; + $functionCalls = $node['functionCalls'] ?? []; + $superglobals = $node['superglobals'] ?? []; $languageConstructs = $node['languageConstructs'] ?? []; $layers = $node['layers'] ?? []; if ( ! is_string($className) - || ! is_string($file) || ! is_int($line) || $layer !== null && ! is_string($layer) || $extends !== null && ! is_string($extends) @@ -1242,29 +1254,43 @@ private function enumCaseNodeFromArray(array $enumCase): ?EnumCaseNode ); } - /** @return array */ + /** + * The file is not stored: the payload belongs to one file, known when + * loading. The two lists are empty for most files and left out. + * + * @return array + */ private function fileAnalysisToArray(FileAnalysis $fileAnalysis): array { - return [ - 'file' => $fileAnalysis->file, - 'hasUtf8Bom' => $fileAnalysis->hasUtf8Bom, - 'hasValidUtf8' => $fileAnalysis->hasValidUtf8, - 'invalidPhpTagLine' => $fileAnalysis->invalidPhpTagLine, - 'hasValidAst' => $fileAnalysis->hasValidAst, - 'declaresSymbols' => $fileAnalysis->declaresSymbols, - 'hasSideEffects' => $fileAnalysis->hasSideEffects, - 'sideEffectLine' => $fileAnalysis->sideEffectLine, - 'nonCanonicalKeywordConstants' => $fileAnalysis->nonCanonicalKeywordConstants, - 'numericLiterals' => $fileAnalysis->numericLiterals, + $analysis = [ + 'hasUtf8Bom' => $fileAnalysis->hasUtf8Bom, + 'hasValidUtf8' => $fileAnalysis->hasValidUtf8, + 'invalidPhpTagLine' => $fileAnalysis->invalidPhpTagLine, + 'hasValidAst' => $fileAnalysis->hasValidAst, + 'declaresSymbols' => $fileAnalysis->declaresSymbols, + 'hasSideEffects' => $fileAnalysis->hasSideEffects, + 'sideEffectLine' => $fileAnalysis->sideEffectLine, ]; + + if ($fileAnalysis->nonCanonicalKeywordConstants !== []) { + $analysis['nonCanonicalKeywordConstants'] = $fileAnalysis->nonCanonicalKeywordConstants; + } + + if ($fileAnalysis->numericLiterals !== []) { + $analysis['numericLiterals'] = $fileAnalysis->numericLiterals; + } + + return $analysis; } /** @param array $analysis */ - private function fileAnalysisFromArray(array $analysis): ?FileAnalysis + private function fileAnalysisFromArray(array $analysis, string $file): ?FileAnalysis { + $nonCanonicalKeywordConstants = $analysis['nonCanonicalKeywordConstants'] ?? []; + $numericLiterals = $analysis['numericLiterals'] ?? []; + if ( - ! is_string($analysis['file'] ?? null) - || ! is_bool($analysis['hasUtf8Bom'] ?? null) + ! is_bool($analysis['hasUtf8Bom'] ?? null) || ! is_bool($analysis['hasValidUtf8'] ?? null) || ! array_key_exists('invalidPhpTagLine', $analysis) || ($analysis['invalidPhpTagLine'] !== null && ! is_int($analysis['invalidPhpTagLine'])) @@ -1272,14 +1298,14 @@ private function fileAnalysisFromArray(array $analysis): ?FileAnalysis || ! is_bool($analysis['declaresSymbols'] ?? null) || ! is_bool($analysis['hasSideEffects'] ?? null) || ! is_int($analysis['sideEffectLine'] ?? null) - || ! $this->isKeywordConstantList($analysis['nonCanonicalKeywordConstants'] ?? null) - || ! $this->isNumericLiteralList($analysis['numericLiterals'] ?? null) + || ! $this->isKeywordConstantList($nonCanonicalKeywordConstants) + || ! $this->isNumericLiteralList($numericLiterals) ) { return null; } return new FileAnalysis( - file: $analysis['file'], + file: $file, hasUtf8Bom: $analysis['hasUtf8Bom'], hasValidUtf8: $analysis['hasValidUtf8'], invalidPhpTagLine: $analysis['invalidPhpTagLine'], @@ -1287,8 +1313,8 @@ private function fileAnalysisFromArray(array $analysis): ?FileAnalysis declaresSymbols: $analysis['declaresSymbols'], hasSideEffects: $analysis['hasSideEffects'], sideEffectLine: $analysis['sideEffectLine'], - nonCanonicalKeywordConstants: $analysis['nonCanonicalKeywordConstants'], - numericLiterals: $analysis['numericLiterals'], + nonCanonicalKeywordConstants: $nonCanonicalKeywordConstants, + numericLiterals: $numericLiterals, ); } diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index 7ec5169b..b88fe07e 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -1634,7 +1634,6 @@ public static function malformedFileAnalysisProvider(): iterable ]; yield 'numeric keys' => [[0 => 'bad']]; - yield 'invalid file' => [[...$valid, 'file' => 1]]; yield 'invalid BOM flag' => [[...$valid, 'hasUtf8Bom' => 'bad']]; yield 'invalid UTF-8 flag' => [[...$valid, 'hasValidUtf8' => 'bad']]; yield 'missing invalid tag line' => [ @@ -1653,18 +1652,6 @@ public static function malformedFileAnalysisProvider(): iterable yield 'invalid declaration flag' => [[...$valid, 'declaresSymbols' => 'bad']]; yield 'invalid side-effects flag' => [[...$valid, 'hasSideEffects' => 'bad']]; yield 'invalid side-effect line' => [[...$valid, 'sideEffectLine' => 'bad']]; - yield 'missing keyword constants' => [ - [ - 'file' => __FILE__, - 'hasUtf8Bom' => false, - 'hasValidUtf8' => true, - 'invalidPhpTagLine' => null, - 'hasValidAst' => true, - 'declaresSymbols' => true, - 'hasSideEffects' => false, - 'sideEffectLine' => 1, - ], - ]; yield 'invalid keyword constants type' => [[...$valid, 'nonCanonicalKeywordConstants' => 'bad']]; yield 'keyword constants not a list' => [[...$valid, 'nonCanonicalKeywordConstants' => ['a' => [1, 'TRUE']]]]; yield 'keyword constant not a pair' => [[...$valid, 'nonCanonicalKeywordConstants' => [[1]]]]; @@ -1673,7 +1660,6 @@ public static function malformedFileAnalysisProvider(): iterable ]; yield 'keyword constant with invalid line' => [[...$valid, 'nonCanonicalKeywordConstants' => [['1', 'TRUE']]]]; yield 'keyword constant with invalid spelling' => [[...$valid, 'nonCanonicalKeywordConstants' => [[1, 1]]]]; - yield 'missing numeric literals' => [[...$valid, 'numericLiterals' => null]]; yield 'numeric literals not a list' => [[...$valid, 'numericLiterals' => ['bad' => [1, '10000', 10000]]]]; yield 'numeric literal not a triple' => [[...$valid, 'numericLiterals' => [[1, '10000']]]]; yield 'numeric literal with invalid line' => [[...$valid, 'numericLiterals' => [['1', '10000', 10000]]]]; diff --git a/tests/Rule/Composer/Psr4ComposerFilePathNormalisationTest.php b/tests/Rule/Composer/Psr4ComposerFilePathNormalisationTest.php index 445afa42..c7b9152f 100644 --- a/tests/Rule/Composer/Psr4ComposerFilePathNormalisationTest.php +++ b/tests/Rule/Composer/Psr4ComposerFilePathNormalisationTest.php @@ -7,6 +7,7 @@ use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Rule\Rules\Composer\Psr4DirectoryExistsRule; use Boundwize\StructArmed\Rule\Rules\Composer\Psr4SourcePathsRule; +use Boundwize\StructArmed\Rule\RuleViolation; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -18,7 +19,8 @@ final class Psr4ComposerFilePathNormalisationTest extends TestCase public function testDirectoryExistsRulePassesWhenComposerJsonIsMissingAtWindowsStyleBasePath(): void { - $this->assertNull( + $this->assertNotInstanceOf( + RuleViolation::class, (new Psr4DirectoryExistsRule())->evaluateProject( self::WINDOWS_STYLE_MISSING_BASE_PATH, Architecture::define() @@ -28,7 +30,8 @@ public function testDirectoryExistsRulePassesWhenComposerJsonIsMissingAtWindowsS public function testSourcePathsRulePassesWhenComposerJsonIsMissingAtWindowsStyleBasePath(): void { - $this->assertNull( + $this->assertNotInstanceOf( + RuleViolation::class, (new Psr4SourcePathsRule(null))->evaluateProject( self::WINDOWS_STYLE_MISSING_BASE_PATH, Architecture::define() From 6781d5b56c3d4bd264596c3fe8e7a020686ec365 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 09:34:34 +0700 Subject: [PATCH 076/104] perf: Store class members as positional tuples in analysis node cache payloads --- src/Cache/AnalysisResultCache.php | 170 +++++++++++++----------- tests/Cache/AnalysisResultCacheTest.php | 61 ++------- 2 files changed, 107 insertions(+), 124 deletions(-) diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 5bb4541e..2fad3c75 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -66,7 +66,7 @@ final class AnalysisResultCache * their shape or naming changes: it is recorded in the metadata marker, * so a cache written by an older format is cleared on its next use. */ - public const FORMAT_VERSION = 4; + public const FORMAT_VERSION = 5; private readonly string $cacheDirectory; @@ -1098,59 +1098,62 @@ enumBackingType: $enumBackingType, } /** - * @return array + * Members are stored as positional tuples: a class has many of them, + * and their field names would otherwise be repeated for every one. + * + * @return array{string, string, bool, bool, int, int, int, bool, int, bool} */ private function methodNodeToArray(MethodNode $methodNode): array { return [ - 'name' => $methodNode->name, - 'visibility' => $methodNode->visibility, - 'hasReturnType' => $methodNode->hasReturnType, - 'isStatic' => $methodNode->isStatic, - 'paramCount' => $methodNode->paramCount, - 'cyclomaticComplexity' => $methodNode->cyclomaticComplexity, - 'lineCount' => $methodNode->lineCount, - 'hasExplicitVisibility' => $methodNode->hasExplicitVisibility, - 'line' => $methodNode->line, - 'isMagic' => $methodNode->isMagic, + $methodNode->name, + $methodNode->visibility, + $methodNode->hasReturnType, + $methodNode->isStatic, + $methodNode->paramCount, + $methodNode->cyclomaticComplexity, + $methodNode->lineCount, + $methodNode->hasExplicitVisibility, + $methodNode->line, + $methodNode->isMagic, ]; } /** - * @return array + * @return array{string, string, bool, int} */ private function constantNodeToArray(ConstantNode $constantNode): array { return [ - 'name' => $constantNode->name, - 'visibility' => $constantNode->visibility, - 'hasExplicitVisibility' => $constantNode->hasExplicitVisibility, - 'line' => $constantNode->line, + $constantNode->name, + $constantNode->visibility, + $constantNode->hasExplicitVisibility, + $constantNode->line, ]; } /** - * @return array + * @return array{string, string, bool, int} */ private function propertyNodeToArray(PropertyNode $propertyNode): array { return [ - 'name' => $propertyNode->name, - 'visibility' => $propertyNode->visibility, - 'hasExplicitVisibility' => $propertyNode->hasExplicitVisibility, - 'line' => $propertyNode->line, + $propertyNode->name, + $propertyNode->visibility, + $propertyNode->hasExplicitVisibility, + $propertyNode->line, ]; } /** - * @return array + * @return array{string, int, int|string|null} */ private function enumCaseNodeToArray(EnumCaseNode $enumCaseNode): array { return [ - 'name' => $enumCaseNode->name, - 'line' => $enumCaseNode->line, - 'value' => $enumCaseNode->value, + $enumCaseNode->name, + $enumCaseNode->line, + $enumCaseNode->value, ]; } @@ -1159,32 +1162,49 @@ private function enumCaseNodeToArray(EnumCaseNode $enumCaseNode): array */ private function methodNodeFromArray(array $method): ?MethodNode { + if (count($method) !== 10 || ! array_is_list($method)) { + return null; + } + + [ + $name, + $visibility, + $hasReturnType, + $isStatic, + $paramCount, + $cyclomaticComplexity, + $lineCount, + $hasExplicitVisibility, + $line, + $isMagic, + ] = $method; + if ( - ! is_string($method['name'] ?? null) - || ! is_string($method['visibility'] ?? null) - || ! is_bool($method['hasReturnType'] ?? null) - || ! is_bool($method['isStatic'] ?? null) - || ! is_int($method['paramCount'] ?? null) - || ! is_int($method['cyclomaticComplexity'] ?? null) - || ! is_int($method['lineCount'] ?? null) - || ! is_bool($method['hasExplicitVisibility'] ?? null) - || ! is_int($method['line'] ?? null) - || ! is_bool($method['isMagic'] ?? null) + ! is_string($name) + || ! is_string($visibility) + || ! is_bool($hasReturnType) + || ! is_bool($isStatic) + || ! is_int($paramCount) + || ! is_int($cyclomaticComplexity) + || ! is_int($lineCount) + || ! is_bool($hasExplicitVisibility) + || ! is_int($line) + || ! is_bool($isMagic) ) { return null; } return new MethodNode( - name: $method['name'], - visibility: $method['visibility'], - hasReturnType: $method['hasReturnType'], - isStatic: $method['isStatic'], - paramCount: $method['paramCount'], - cyclomaticComplexity: $method['cyclomaticComplexity'], - lineCount: $method['lineCount'], - hasExplicitVisibility: $method['hasExplicitVisibility'], - line: $method['line'], - isMagic: $method['isMagic'], + name: $name, + visibility: $visibility, + hasReturnType: $hasReturnType, + isStatic: $isStatic, + paramCount: $paramCount, + cyclomaticComplexity: $cyclomaticComplexity, + lineCount: $lineCount, + hasExplicitVisibility: $hasExplicitVisibility, + line: $line, + isMagic: $isMagic, ); } @@ -1193,20 +1213,21 @@ private function methodNodeFromArray(array $method): ?MethodNode */ private function constantNodeFromArray(array $constant): ?ConstantNode { - if ( - ! is_string($constant['name'] ?? null) - || ! is_string($constant['visibility'] ?? null) - || ! is_bool($constant['hasExplicitVisibility'] ?? null) - || ! is_int($constant['line'] ?? null) - ) { + if (count($constant) !== 4 || ! array_is_list($constant)) { + return null; + } + + [$name, $visibility, $hasExplicitVisibility, $line] = $constant; + + if (! is_string($name) || ! is_string($visibility) || ! is_bool($hasExplicitVisibility) || ! is_int($line)) { return null; } return new ConstantNode( - name: $constant['name'], - visibility: $constant['visibility'], - hasExplicitVisibility: $constant['hasExplicitVisibility'], - line: $constant['line'], + name: $name, + visibility: $visibility, + hasExplicitVisibility: $hasExplicitVisibility, + line: $line, ); } @@ -1215,20 +1236,21 @@ private function constantNodeFromArray(array $constant): ?ConstantNode */ private function propertyNodeFromArray(array $property): ?PropertyNode { - if ( - ! is_string($property['name'] ?? null) - || ! is_string($property['visibility'] ?? null) - || ! is_bool($property['hasExplicitVisibility'] ?? null) - || ! is_int($property['line'] ?? null) - ) { + if (count($property) !== 4 || ! array_is_list($property)) { + return null; + } + + [$name, $visibility, $hasExplicitVisibility, $line] = $property; + + if (! is_string($name) || ! is_string($visibility) || ! is_bool($hasExplicitVisibility) || ! is_int($line)) { return null; } return new PropertyNode( - name: $property['name'], - visibility: $property['visibility'], - hasExplicitVisibility: $property['hasExplicitVisibility'], - line: $property['line'], + name: $name, + visibility: $visibility, + hasExplicitVisibility: $hasExplicitVisibility, + line: $line, ); } @@ -1237,19 +1259,19 @@ private function propertyNodeFromArray(array $property): ?PropertyNode */ private function enumCaseNodeFromArray(array $enumCase): ?EnumCaseNode { - $value = $enumCase['value'] ?? null; + if (count($enumCase) !== 3 || ! array_is_list($enumCase)) { + return null; + } - if ( - ! is_string($enumCase['name'] ?? null) - || ! is_int($enumCase['line'] ?? null) - || ($value !== null && ! is_int($value) && ! is_string($value)) - ) { + [$name, $line, $value] = $enumCase; + + if (! is_string($name) || ! is_int($line) || ($value !== null && ! is_int($value) && ! is_string($value))) { return null; } return new EnumCaseNode( - name: $enumCase['name'], - line: $enumCase['line'], + name: $name, + line: $line, value: $value, ); } diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index b88fe07e..9a9bca87 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -2031,7 +2031,7 @@ public static function malformedClassNodePayloadProvider(): iterable ], ], ]; - yield 'method has numeric keys' => [ + yield 'method is not a tuple' => [ [ 'nodes' => [ [ @@ -2077,20 +2077,7 @@ public static function malformedClassNodePayloadProvider(): iterable 'dependencies' => [], 'implements' => [], 'traits' => [], - 'methods' => [ - [ - 'name' => 'run', - 'visibility' => 'public', - 'hasReturnType' => true, - 'isStatic' => false, - 'paramCount' => 0, - 'cyclomaticComplexity' => 1, - 'lineCount' => 1, - 'hasExplicitVisibility' => true, - 'line' => 'bad', - 'isMagic' => false, - ], - ], + 'methods' => [['run', 'public', true, false, 0, 1, 1, true, 'bad', false]], 'constants' => [], 'properties' => [], 'functionCalls' => [], @@ -2100,7 +2087,7 @@ public static function malformedClassNodePayloadProvider(): iterable ], ], ]; - yield 'method has missing hasExplicitVisibility' => [ + yield 'method tuple is too short' => [ [ 'nodes' => [ [ @@ -2118,19 +2105,7 @@ public static function malformedClassNodePayloadProvider(): iterable 'dependencies' => [], 'implements' => [], 'traits' => [], - 'methods' => [ - [ - 'name' => 'run', - 'visibility' => 'public', - 'hasReturnType' => true, - 'isStatic' => false, - 'paramCount' => 0, - 'cyclomaticComplexity' => 1, - 'lineCount' => 1, - 'line' => 1, - 'isMagic' => false, - ], - ], + 'methods' => [['run', 'public', true, false, 0, 1, 1, 1, false]], 'constants' => [], 'properties' => [], 'functionCalls' => [], @@ -2196,7 +2171,7 @@ public static function malformedClassNodePayloadProvider(): iterable ], ], ]; - yield 'constant has numeric keys' => [ + yield 'constant is not a tuple' => [ [ 'nodes' => [ [ @@ -2243,14 +2218,7 @@ public static function malformedClassNodePayloadProvider(): iterable 'implements' => [], 'traits' => [], 'methods' => [], - 'constants' => [ - [ - 'name' => 'VERSION', - 'visibility' => 'public', - 'hasExplicitVisibility' => true, - 'line' => 'bad', - ], - ], + 'constants' => [['VERSION', 'public', true, 'bad']], 'properties' => [], 'functionCalls' => [], 'superglobals' => [], @@ -2315,7 +2283,7 @@ public static function malformedClassNodePayloadProvider(): iterable ], ], ]; - yield 'property has numeric keys' => [ + yield 'property is not a tuple' => [ [ 'nodes' => [ [ @@ -2363,14 +2331,7 @@ public static function malformedClassNodePayloadProvider(): iterable 'traits' => [], 'methods' => [], 'constants' => [], - 'properties' => [ - [ - 'name' => 'name', - 'visibility' => 'private', - 'hasExplicitVisibility' => true, - 'line' => 'bad', - ], - ], + 'properties' => [['name', 'private', true, 'bad']], 'functionCalls' => [], 'superglobals' => [], 'layers' => [], @@ -2436,7 +2397,7 @@ public static function malformedClassNodePayloadProvider(): iterable ], ], ]; - yield 'enum case has non-string keys' => [ + yield 'enum case is not a tuple' => [ [ 'nodes' => [ [ @@ -2486,7 +2447,7 @@ public static function malformedClassNodePayloadProvider(): iterable 'methods' => [], 'constants' => [], 'properties' => [], - 'enumCases' => [['name' => 'Hearts', 'line' => 'bad']], + 'enumCases' => [['Hearts', 'bad', null]], 'functionCalls' => [], 'superglobals' => [], 'layers' => [], @@ -2515,7 +2476,7 @@ public static function malformedClassNodePayloadProvider(): iterable 'methods' => [], 'constants' => [], 'properties' => [], - 'enumCases' => [['name' => 'Hearts', 'line' => 4, 'value' => ['bad']]], + 'enumCases' => [['Hearts', 4, ['bad']]], 'functionCalls' => [], 'superglobals' => [], 'layers' => [], From 3d5b34db2f33ebe69a3803cec62170bae52c8f74 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 09:38:44 +0700 Subject: [PATCH 077/104] update comment --- src/Cache/AnalysisResultCache.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 2fad3c75..9e5df476 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -1100,6 +1100,9 @@ enumBackingType: $enumBackingType, /** * Members are stored as positional tuples: a class has many of them, * and their field names would otherwise be repeated for every one. + * Changing a tuple's order or length is a format change: bump + * FORMAT_VERSION, or a same-length reorder would load silently with + * the wrong values. * * @return array{string, string, bool, bool, int, int, int, bool, int, bool} */ From 50c44fec3805bdebb28b912639c54bb7ed7247c5 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 10:10:10 +0700 Subject: [PATCH 078/104] update minimum number for LargeNumericLiteralMustUseSeparatorRule to 1_000_000 --- docs/available-rules.md | 2 +- docs/presets.md | 2 +- ...argeNumericLiteralMustUseSeparatorRule.php | 2 +- ...NumericLiteralMustUseSeparatorRuleTest.php | 10 +++---- ...ricLiteralMustUseSeparatorRuleUnitTest.php | 28 +++++++++---------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/available-rules.md b/docs/available-rules.md index 0b8ee535..536ba66e 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -64,7 +64,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\File`. | `Psr1ValidUtf8Rule` | `new Psr1ValidUtf8Rule(sourcePaths: ['src/'])` | PHP files use valid UTF-8 encoding. | | `Psr1Utf8WithoutBomRule` | `new Psr1Utf8WithoutBomRule(sourcePaths: ['src/'])` | PHP files do not start with a byte order mark. Supports `--fix`. | | `MustUseLowercaseKeywordConstantRule` | `new MustUseLowercaseKeywordConstantRule(sourcePaths: ['src/'])` | PHP's special keyword constants `true`, `false`, and `null` use their canonical lowercase spelling. Fully qualified forms such as `\TRUE` are preserved as `\true`. Supports `--fix`. | -| `LargeNumericLiteralMustUseSeparatorRule` | `new LargeNumericLiteralMustUseSeparatorRule(minimum: 10_000, sourcePaths: ['src/'])` | Plain decimal integer and float literals whose magnitude is at least `minimum` (default `10_000`) group their integer digits in threes with `_` separators, so `1000500.001` becomes `1_000_500.001`. Hexadecimal, octal, binary, exponent, and already separated literals are ignored. Supports `--fix`. | +| `LargeNumericLiteralMustUseSeparatorRule` | `new LargeNumericLiteralMustUseSeparatorRule(minimum: 1_000_000, sourcePaths: ['src/'])` | Plain decimal integer and float literals whose magnitude is at least `minimum` (default `1_000_000`) group their integer digits in threes with `_` separators, so `1000500.001` becomes `1_000_500.001`. Hexadecimal, octal, binary, exponent, and already separated literals are ignored. Supports `--fix`. | {: .rule-table } Pass `sourcePaths: null` or omit it to let the rule read PSR-4 paths from `composer.json`. diff --git a/docs/presets.md b/docs/presets.md index ef621c7b..72446e1f 100644 --- a/docs/presets.md +++ b/docs/presets.md @@ -27,7 +27,7 @@ StructArmed ships with presets for common PHP standards and architecture styles. | `Preset::DDD()` | Layer isolation, entity/VO/repository/event/service conventions, including keeping Doctrine ORM repository inheritance out of the Domain layer | | `Preset::MVC()` | Layer isolation, thin controllers, model/view/service rules, return types for helper functions | | `Preset::YAGNI()` | Speculative-abstraction cleanup: interfaces must be implemented by a class or extended by another interface, abstract classes must be extended, traits must be used, and extended classes that are never instantiated must be abstract — a dependency reference (type hint, `instanceof`, `::class`, static call, a class-name string, ...) also counts as usage within the scanned paths, while only instantiation (`new X`, `new self`/`static`/`parent`, or a constant class expression such as `new (X::class)`) keeps an extended class concrete. All rules support `--fix`, removing the unused declaration or adding the `abstract` modifier | -| `Preset::CODEQUALITY()` | General readability conventions independent of any architecture style: closures and arrow functions that do not read `$this` must be declared `static`, and plain decimal numeric literals of `10_000` or more must group their digits with `_` separators (`1000500` becomes `1_000_500`). Both rules support `--fix`. Tune the literal threshold with `replaceRule(CodeQualityPreset::LARGE_NUMERIC_LITERALS_MUST_USE_SEPARATOR, new LargeNumericLiteralMustUseSeparatorRule(minimum: 1_000))` | +| `Preset::CODEQUALITY()` | General readability conventions independent of any architecture style: closures and arrow functions that do not read `$this` must be declared `static`, and plain decimal numeric literals of `1_000_000` or more must group their digits with `_` separators (`1000500` becomes `1_000_500`). Both rules support `--fix`. Tune the literal threshold with `replaceRule(CodeQualityPreset::LARGE_NUMERIC_LITERALS_MUST_USE_SEPARATOR, new LargeNumericLiteralMustUseSeparatorRule(minimum: 1_000))` | ## Initialize Presets diff --git a/src/Rule/Rules/File/LargeNumericLiteralMustUseSeparatorRule.php b/src/Rule/Rules/File/LargeNumericLiteralMustUseSeparatorRule.php index 13a5f766..0b736ac1 100644 --- a/src/Rule/Rules/File/LargeNumericLiteralMustUseSeparatorRule.php +++ b/src/Rule/Rules/File/LargeNumericLiteralMustUseSeparatorRule.php @@ -36,7 +36,7 @@ * @param list|null $sourcePaths */ public function __construct( - private int $minimum = 10_000, + private int $minimum = 1_000_000, ?array $sourcePaths = null, ?PhpFileFinder $phpFileFinder = null, ) { diff --git a/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleTest.php b/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleTest.php index 90752a87..0754187b 100644 --- a/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleTest.php +++ b/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleTest.php @@ -40,7 +40,7 @@ public function testFixesMultipleLiteralsWithoutChangingSurroundingCode(): void makeProject("makeProject("assertTrue($largeNumericLiteralMustUseSeparatorRule->fix($violations[1])); $this->assertSame( - "assertTrue($largeNumericLiteralMustUseSeparatorRule->fix($violations[0])); $this->assertSame( - "evaluate( - [[3, '10000', 10000], [4, '100000', 100000], [5, '1000000', 1000000]], + [[3, '1000000', 1000000], [4, '10000000', 10000000], [5, '100000000', 100000000]], largeNumericLiteralMustUseSeparatorRule: $largeNumericLiteralMustUseSeparatorRule, ); $this->assertInstanceOf(FixableInterface::class, $largeNumericLiteralMustUseSeparatorRule); $this->assertSame( [ - 'Numeric literal [10000] must use separator formatting [10_000]', - 'Numeric literal [100000] must use separator formatting [100_000]', 'Numeric literal [1000000] must use separator formatting [1_000_000]', + 'Numeric literal [10000000] must use separator formatting [10_000_000]', + 'Numeric literal [100000000] must use separator formatting [100_000_000]', ], array_map(static fn (RuleViolation $ruleViolation): string => $ruleViolation->message, $violations), ); @@ -51,7 +51,7 @@ public function testReportsLargeUnformattedDecimalIntegers(): void array_map(static fn (RuleViolation $ruleViolation): int => $ruleViolation->line, $violations), ); $this->assertSame( - ['10000', '100000', '1000000'], + ['1000000', '10000000', '100000000'], array_map( static fn (RuleViolation $ruleViolation): ?string => $ruleViolation->numericLiteral, $violations, @@ -62,26 +62,26 @@ public function testReportsLargeUnformattedDecimalIntegers(): void public function testIgnoresBelowThresholdAndAlreadySeparatedIntegers(): void { $this->assertSame([], $this->evaluate([ - [3, '9999', 9999], - [4, '10_000', 10000], - [5, '1_000_000', 1000000], - [6, '9999.99', 9999.99], - [7, '10_000.0', 10000.0], + [3, '999999', 999999], + [4, '1_000_000', 1000000], + [5, '10_000_000', 10000000], + [6, '999999.99', 999999.99], + [7, '1_000_000.0', 1000000.0], ])); } public function testReportsPlainDecimalFloatsAndPreservesTheirFractionalParts(): void { $violations = $this->evaluate([ - [3, '10000.0', 10000.0], - [4, '1000000.0', 1000000.0], + [3, '1000000.0', 1000000.0], + [4, '10000000.0', 10000000.0], [5, '1000500.001', 1000500.001], ]); $this->assertSame( [ - 'Numeric literal [10000.0] must use separator formatting [10_000.0]', 'Numeric literal [1000000.0] must use separator formatting [1_000_000.0]', + 'Numeric literal [10000000.0] must use separator formatting [10_000_000.0]', 'Numeric literal [1000500.001] must use separator formatting [1_000_500.001]', ], array_map(static fn (RuleViolation $ruleViolation): string => $ruleViolation->message, $violations), @@ -124,10 +124,10 @@ public function testIgnoresUnsupportedNumericSyntaxesAndFloats(): void public function testUsesTheLiteralMagnitudeCollectedInsideAUnaryMinus(): void { - $violations = $this->evaluate([[3, '100000', 100000]]); + $violations = $this->evaluate([[3, '1000000', 1000000]]); $this->assertCount(1, $violations); - $this->assertSame('100000', $violations[0]->numericLiteral); + $this->assertSame('1000000', $violations[0]->numericLiteral); } public function testVisitorWithoutLiteralPayloadDoesNothing(): void From e5781944ac95ef2a7629a393b9127190ffdbaf29 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 10:50:06 +0700 Subject: [PATCH 079/104] chore: Clean up setInstantiated() validation as it post process check already in Analyser --- src/Analyser/ClassNode.php | 9 -------- tests/Analyser/ClassNodeTest.php | 37 -------------------------------- 2 files changed, 46 deletions(-) diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index 13913283..f8aee928 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -139,18 +139,9 @@ public function setReferenced(bool $isReferenced): void * `new self`/`new static`/`new parent` resolving to it. Instantiation is * the one usage that requires a class to stay concrete. Computed by the * analyser when a usage-aware rule is active; false otherwise. - * - * Only a concrete named class can be an instantiation target — `new` on - * an abstract class, interface, trait, or enum is fatal — so marking any - * other class-like as instantiated is ignored. (Anonymous classes never - * become ClassNodes in the first place.) */ public function setInstantiated(bool $isInstantiated): void { - if ($isInstantiated && (! $this->isClass() || $this->isAbstract)) { - return; - } - $this->isInstantiated = $isInstantiated; } diff --git a/tests/Analyser/ClassNodeTest.php b/tests/Analyser/ClassNodeTest.php index 2d6e28be..8363469b 100644 --- a/tests/Analyser/ClassNodeTest.php +++ b/tests/Analyser/ClassNodeTest.php @@ -418,43 +418,6 @@ className: 'App\\Domain\\BaseRepository', $this->assertFalse($classNode->isInstantiated); } - public function testSetInstantiatedIsIgnoredForNonInstantiableClassLikes(): void - { - $makeNode = static fn ( - bool $isAbstract = false, - bool $isInterface = false, - bool $isTrait = false, - bool $isEnum = false, - ): ClassNode => new ClassNode( - className: 'App\\Domain\\SomeClassLike', - file: '/src/SomeClassLike.php', - line: 5, - layer: 'Domain', - extends: null, - isAbstract: $isAbstract, - isFinal: false, - isInterface: $isInterface, - isReadonly: false, - isTrait: $isTrait, - isEnum: $isEnum, - ); - - $nonInstantiables = [ - 'abstract class' => $makeNode(isAbstract: true), - 'interface' => $makeNode(isInterface: true), - 'trait' => $makeNode(isTrait: true), - 'enum' => $makeNode(isEnum: true), - ]; - - foreach ($nonInstantiables as $kind => $classNode) { - $classNode->setInstantiated(true); - - // `new` on these class-likes is fatal, so they can never be an - // instantiation target. - $this->assertFalse($classNode->isInstantiated, $kind); - } - } - public function testDependsOnMatchesExistingClassesExactly(): void { $classNode = new ClassNode( From afd684fd31d21a6b4eea0c526a4f6aa2643d7783 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 11:42:03 +0700 Subject: [PATCH 080/104] perf: batch PHP fixer violations by rule and file --- src/Cli/AnalyseCommand.php | 36 ++++++++++++++--- .../AbstractPhpParserFixableRule.php | 22 +++++++++-- .../PhpParser/PhpParserFixerProcessor.php | 18 ++++++--- .../MethodVisibilityFixerPipelineTest.php | 39 +++++++++++++++++++ 4 files changed, 101 insertions(+), 14 deletions(-) diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index 9ac8da0c..f877d3dc 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -18,10 +18,12 @@ use Boundwize\StructArmed\Report\Reports\ConsoleReport; use Boundwize\StructArmed\Report\Reports\JsonReport; use Boundwize\StructArmed\Rule\FixableInterface; +use Boundwize\StructArmed\Rule\Fixer\PhpParser\AbstractPhpParserFixableRule; use Boundwize\StructArmed\Rule\RuleViolationCollection; use Boundwize\StructArmed\Util\Path; use RuntimeException; +use function array_shift; use function count; use function explode; use function in_array; @@ -328,14 +330,38 @@ private function resolveRuleViolationCollection( private function fixViolations(Architecture $architecture, RuleViolationCollection $ruleViolationCollection): int { - $rules = $architecture->getRules(); $fixedCount = 0; - foreach ($ruleViolationCollection as $ruleViolation) { - $rule = $rules[$ruleViolation->ruleKey] ?? null; + foreach ($architecture->getRules() as $ruleKey => $rule) { + $ruleViolations = $ruleViolationCollection->forRule($ruleKey); - if ($rule instanceof FixableInterface && $rule->fix($ruleViolation)) { - $fixedCount++; + if ($ruleViolations === []) { + continue; + } + + if (! $rule instanceof AbstractPhpParserFixableRule) { + foreach ($ruleViolations as $ruleViolation) { + if ($rule instanceof FixableInterface && $rule->fix($ruleViolation)) { + $fixedCount++; + } + } + + continue; + } + + $violationsByFile = []; + + foreach ($ruleViolations as $ruleViolation) { + $violationsByFile[$ruleViolation->file][] = $ruleViolation; + } + + foreach ($violationsByFile as $fileViolations) { + $batchSize = count($fileViolations); + $firstViolation = array_shift($fileViolations); + + if ($rule->fix($firstViolation, ...$fileViolations)) { + $fixedCount += $batchSize; + } } } diff --git a/src/Rule/Fixer/PhpParser/AbstractPhpParserFixableRule.php b/src/Rule/Fixer/PhpParser/AbstractPhpParserFixableRule.php index a6edc60c..00c9fde8 100644 --- a/src/Rule/Fixer/PhpParser/AbstractPhpParserFixableRule.php +++ b/src/Rule/Fixer/PhpParser/AbstractPhpParserFixableRule.php @@ -10,13 +10,27 @@ abstract readonly class AbstractPhpParserFixableRule implements FixableInterface { - final public function fix(RuleViolation $ruleViolation): bool + /** + * Additional violations let the CLI fix one rule's violations for a file + * in a single read, parse, and write cycle. + */ + final public function fix(RuleViolation $ruleViolation, RuleViolation ...$additionalViolations): bool { - $nodeVisitor = $this->createFixerVisitor($ruleViolation); + $ruleViolations = [$ruleViolation, ...$additionalViolations]; + $file = $ruleViolation->file; + $nodeVisitors = []; + + foreach ($ruleViolations as $ruleViolation) { + if ($ruleViolation->file !== $file) { + return false; + } + + $nodeVisitors[] = $this->createFixerVisitor($ruleViolation); + } return $this->fixerProcessor()->process( - $ruleViolation->file, - $nodeVisitor, + $file, + $nodeVisitors, $this->shouldRemoveFileWhenEmpty(), ); } diff --git a/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php b/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php index 1eb3169a..8d6adf61 100644 --- a/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php +++ b/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php @@ -27,12 +27,17 @@ final readonly class PhpParserFixerProcessor { - public function process(string $file, NodeVisitor $nodeVisitor, bool $removeFileWhenEmpty = false): bool + /** @param NodeVisitor|non-empty-list $nodeVisitors */ + public function process(string $file, NodeVisitor|array $nodeVisitors, bool $removeFileWhenEmpty = false): bool { if (! is_file($file)) { return false; } + if ($nodeVisitors instanceof NodeVisitor) { + $nodeVisitors = [$nodeVisitors]; + } + $code = (string) file_get_contents($file); $parser = (new ParserFactory())->createForNewestSupportedVersion(); @@ -47,10 +52,13 @@ public function process(string $file, NodeVisitor $nodeVisitor, bool $removeFile return false; } - $nameResolver = new NameResolver(options: ['replaceNodes' => false]); - $statements = (new NodeTraverser($nameResolver, $nodeVisitor)) - ->traverse((new NodeTraverser(new CloningVisitor())) - ->traverse($originalStatements)); + $statements = (new NodeTraverser(new CloningVisitor()))->traverse($originalStatements); + $statements = (new NodeTraverser(new NameResolver(options: ['replaceNodes' => false]))) + ->traverse($statements); + + foreach ($nodeVisitors as $nodeVisitor) { + $statements = (new NodeTraverser($nodeVisitor))->traverse($statements); + } // A fix that removes the last declaration leaves only boilerplate // (declare/namespace/use); the whole file is dead weight at that point. diff --git a/tests/Rule/Fixer/PhpParser/ClassMethod/MethodVisibilityFixerPipelineTest.php b/tests/Rule/Fixer/PhpParser/ClassMethod/MethodVisibilityFixerPipelineTest.php index ab412054..98fa2b3a 100644 --- a/tests/Rule/Fixer/PhpParser/ClassMethod/MethodVisibilityFixerPipelineTest.php +++ b/tests/Rule/Fixer/PhpParser/ClassMethod/MethodVisibilityFixerPipelineTest.php @@ -45,6 +45,45 @@ static function save(): void } } + public function testProcessFixesMultipleMethodsInOneBatch(): void + { + $file = $this->temporaryPhpFile(<<<'PHP' +assertTrue($processor->process($file, [ + new AddPublicMethodVisibilityVisitor('App\\Order', 'create'), + new AddPublicMethodVisibilityVisitor('App\\Order', 'save'), + ])); + $this->assertStringContainsString( + ' public function create(): void', + (string) file_get_contents($file) + ); + $this->assertStringContainsString( + ' public static function save(): void', + (string) file_get_contents($file) + ); + } finally { + unlink($file); + } + } + public function testProcessReturnsFalseForMissingFile(): void { $this->assertFalse($this->process(sys_get_temp_dir() . '/missing-structarmed.php', 'App\\Order', 'save')); From dbe37eb3383edcae990a1f9d6bfe6a8f6289a29a Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 11:43:44 +0700 Subject: [PATCH 081/104] rectify --- src/Cli/AnalyseCommand.php | 8 ++++---- .../ClassMethod/MethodVisibilityFixerPipelineTest.php | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index f877d3dc..89e6de73 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -355,11 +355,11 @@ private function fixViolations(Architecture $architecture, RuleViolationCollecti $violationsByFile[$ruleViolation->file][] = $ruleViolation; } - foreach ($violationsByFile as $fileViolations) { - $batchSize = count($fileViolations); - $firstViolation = array_shift($fileViolations); + foreach ($violationsByFile as $violations) { + $batchSize = count($violations); + $firstViolation = array_shift($violations); - if ($rule->fix($firstViolation, ...$fileViolations)) { + if ($rule->fix($firstViolation, ...$violations)) { $fixedCount += $batchSize; } } diff --git a/tests/Rule/Fixer/PhpParser/ClassMethod/MethodVisibilityFixerPipelineTest.php b/tests/Rule/Fixer/PhpParser/ClassMethod/MethodVisibilityFixerPipelineTest.php index 98fa2b3a..7d4ade8f 100644 --- a/tests/Rule/Fixer/PhpParser/ClassMethod/MethodVisibilityFixerPipelineTest.php +++ b/tests/Rule/Fixer/PhpParser/ClassMethod/MethodVisibilityFixerPipelineTest.php @@ -65,9 +65,9 @@ static function save(): void PHP); try { - $processor = new PhpParserFixerProcessor(); + $phpParserFixerProcessor = new PhpParserFixerProcessor(); - $this->assertTrue($processor->process($file, [ + $this->assertTrue($phpParserFixerProcessor->process($file, [ new AddPublicMethodVisibilityVisitor('App\\Order', 'create'), new AddPublicMethodVisibilityVisitor('App\\Order', 'save'), ])); From ed20b5d54654eb2f995f23d0d4864f6a5761ff79 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 11:46:45 +0700 Subject: [PATCH 082/104] fix interface --- src/Cli/AnalyseCommand.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index 89e6de73..c1d9f204 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -335,13 +335,13 @@ private function fixViolations(Architecture $architecture, RuleViolationCollecti foreach ($architecture->getRules() as $ruleKey => $rule) { $ruleViolations = $ruleViolationCollection->forRule($ruleKey); - if ($ruleViolations === []) { + if (! $rule instanceof FixableInterface || $ruleViolations === []) { continue; } if (! $rule instanceof AbstractPhpParserFixableRule) { foreach ($ruleViolations as $ruleViolation) { - if ($rule instanceof FixableInterface && $rule->fix($ruleViolation)) { + if ($rule->fix($ruleViolation)) { $fixedCount++; } } From e506b1624d5143c0daca5456c1df2c3d979eee97 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 11:52:52 +0700 Subject: [PATCH 083/104] add more test --- .../Class_/MustBeUsedInterfaceRuleFixTest.php | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/Rule/Class_/MustBeUsedInterfaceRuleFixTest.php b/tests/Rule/Class_/MustBeUsedInterfaceRuleFixTest.php index 152db619..4c978218 100644 --- a/tests/Rule/Class_/MustBeUsedInterfaceRuleFixTest.php +++ b/tests/Rule/Class_/MustBeUsedInterfaceRuleFixTest.php @@ -48,6 +48,46 @@ className: 'App\\UnusedInterface', $this->assertFileDoesNotExist($file); } + public function testBatchFixRunsEveryVisitorBeforeDeletingFile(): void + { + $temporaryDirectory = $this->makeTemporaryDirectory('structarmed-yagni-interface'); + $file = $temporaryDirectory . '/UnusedInterfaces.php'; + + file_put_contents( + $file, + "assertTrue($rule->fix( + new RuleViolation( + message: 'Interface [App\\FirstUnused] must be used', + file: $file, + line: 5, + className: 'App\\FirstUnused', + layer: 'Domain', + ), + new RuleViolation( + message: 'Interface [App\\SecondUnused] must be used', + file: $file, + line: 9, + className: 'App\\SecondUnused', + layer: 'Domain', + ), + )); + $this->assertFileDoesNotExist($file); + + // A later fixer batch stops at the processor's is_file() guard. + $this->assertFalse($rule->fix(new RuleViolation( + message: 'Interface [App\\FirstUnused] must be used', + file: $file, + line: 5, + className: 'App\\FirstUnused', + layer: 'Domain', + ))); + } + public function testFixKeepsFileWhenDeclareBlockContainsExecutableCode(): void { $temporaryDirectory = $this->makeTemporaryDirectory('structarmed-yagni-interface'); From 78bd99858921f28d9185b91ed32f4e8251497b19 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 11:55:49 +0700 Subject: [PATCH 084/104] add more test --- .../Class_/MustBeUsedInterfaceRuleFixTest.php | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/tests/Rule/Class_/MustBeUsedInterfaceRuleFixTest.php b/tests/Rule/Class_/MustBeUsedInterfaceRuleFixTest.php index 4c978218..09445443 100644 --- a/tests/Rule/Class_/MustBeUsedInterfaceRuleFixTest.php +++ b/tests/Rule/Class_/MustBeUsedInterfaceRuleFixTest.php @@ -58,9 +58,9 @@ public function testBatchFixRunsEveryVisitorBeforeDeletingFile(): void "assertTrue($rule->fix( + $this->assertTrue($mustBeUsedInterfaceRule->fix( new RuleViolation( message: 'Interface [App\\FirstUnused] must be used', file: $file, @@ -79,7 +79,7 @@ className: 'App\\SecondUnused', $this->assertFileDoesNotExist($file); // A later fixer batch stops at the processor's is_file() guard. - $this->assertFalse($rule->fix(new RuleViolation( + $this->assertFalse($mustBeUsedInterfaceRule->fix(new RuleViolation( message: 'Interface [App\\FirstUnused] must be used', file: $file, line: 5, @@ -88,6 +88,37 @@ className: 'App\\FirstUnused', ))); } + public function testBatchFixRejectsViolationsFromDifferentFiles(): void + { + $temporaryDirectory = $this->makeTemporaryDirectory('structarmed-yagni-interface'); + $firstFile = $temporaryDirectory . '/FirstUnused.php'; + $secondFile = $temporaryDirectory . '/SecondUnused.php'; + + file_put_contents($firstFile, "assertFalse($mustBeUsedInterfaceRule->fix( + new RuleViolation( + message: 'Interface [FirstUnused] must be used', + file: $firstFile, + line: 3, + className: 'FirstUnused', + layer: 'Domain', + ), + new RuleViolation( + message: 'Interface [SecondUnused] must be used', + file: $secondFile, + line: 3, + className: 'SecondUnused', + layer: 'Domain', + ), + )); + $this->assertFileExists($firstFile); + $this->assertFileExists($secondFile); + } + public function testFixKeepsFileWhenDeclareBlockContainsExecutableCode(): void { $temporaryDirectory = $this->makeTemporaryDirectory('structarmed-yagni-interface'); From 50d026938eb22a9d1c7485773785a902a0d0a921 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 12:06:29 +0700 Subject: [PATCH 085/104] sync on AbstractJsonRecastFixableRule --- src/Cli/AnalyseCommand.php | 6 +++- .../AbstractJsonRecastFixableRule.php | 18 ++++++++-- .../JsonRecast/JsonRecastFixerProcessor.php | 32 +++++++++++++---- .../Composer/Psr4DirectoryExistsRuleTest.php | 35 +++++++++++++++++++ .../JsonRecastFixerProcessorTest.php | 24 ++++++++++++- 5 files changed, 104 insertions(+), 11 deletions(-) diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index c1d9f204..95090202 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -18,6 +18,7 @@ use Boundwize\StructArmed\Report\Reports\ConsoleReport; use Boundwize\StructArmed\Report\Reports\JsonReport; use Boundwize\StructArmed\Rule\FixableInterface; +use Boundwize\StructArmed\Rule\Fixer\JsonRecast\AbstractJsonRecastFixableRule; use Boundwize\StructArmed\Rule\Fixer\PhpParser\AbstractPhpParserFixableRule; use Boundwize\StructArmed\Rule\RuleViolationCollection; use Boundwize\StructArmed\Util\Path; @@ -339,7 +340,10 @@ private function fixViolations(Architecture $architecture, RuleViolationCollecti continue; } - if (! $rule instanceof AbstractPhpParserFixableRule) { + if ( + ! $rule instanceof AbstractPhpParserFixableRule + && ! $rule instanceof AbstractJsonRecastFixableRule + ) { foreach ($ruleViolations as $ruleViolation) { if ($rule->fix($ruleViolation)) { $fixedCount++; diff --git a/src/Rule/Fixer/JsonRecast/AbstractJsonRecastFixableRule.php b/src/Rule/Fixer/JsonRecast/AbstractJsonRecastFixableRule.php index 9b06fb7a..4ee4d74b 100644 --- a/src/Rule/Fixer/JsonRecast/AbstractJsonRecastFixableRule.php +++ b/src/Rule/Fixer/JsonRecast/AbstractJsonRecastFixableRule.php @@ -10,11 +10,23 @@ abstract readonly class AbstractJsonRecastFixableRule implements FixableInterface { - final public function fix(RuleViolation $ruleViolation): bool + final public function fix(RuleViolation $ruleViolation, RuleViolation ...$additionalViolations): bool { + $ruleViolations = [$ruleViolation, ...$additionalViolations]; + $file = $ruleViolation->file; + $nodeVisitors = []; + + foreach ($ruleViolations as $ruleViolation) { + if ($ruleViolation->file !== $file) { + return false; + } + + $nodeVisitors[] = $this->createFixerVisitor($ruleViolation); + } + return $this->fixerProcessor()->process( - $ruleViolation->file, - $this->createFixerVisitor($ruleViolation), + $file, + $nodeVisitors, ); } diff --git a/src/Rule/Fixer/JsonRecast/JsonRecastFixerProcessor.php b/src/Rule/Fixer/JsonRecast/JsonRecastFixerProcessor.php index 8d881713..f97a55dd 100644 --- a/src/Rule/Fixer/JsonRecast/JsonRecastFixerProcessor.php +++ b/src/Rule/Fixer/JsonRecast/JsonRecastFixerProcessor.php @@ -5,8 +5,12 @@ namespace Boundwize\StructArmed\Rule\Fixer\JsonRecast; use Boundwize\JsonRecast\JsonRecast; +use Boundwize\JsonRecast\JsonRecastResult; +use Boundwize\JsonRecast\Node\JsonDocument; +use Boundwize\JsonRecast\NodeTraverser\NodeJsonTraverser; use Boundwize\JsonRecast\NodeVisitor\NodeJsonVisitor; use Boundwize\JsonRecast\Parser\ParseError; +use RuntimeException; use function file_get_contents; use function file_put_contents; @@ -14,24 +18,40 @@ final readonly class JsonRecastFixerProcessor { - public function process(string $file, NodeJsonVisitor $nodeJsonVisitor): bool + /** @param NodeJsonVisitor|non-empty-list $nodeJsonVisitors */ + public function process(string $file, NodeJsonVisitor|array $nodeJsonVisitors): bool { if (! is_file($file)) { return false; } + if ($nodeJsonVisitors instanceof NodeJsonVisitor) { + $nodeJsonVisitors = [$nodeJsonVisitors]; + } + $json = (string) file_get_contents($file); try { - $result = JsonRecast::traverse( - JsonRecast::parse($json), - $nodeJsonVisitor - ); + $document = JsonRecast::parse($json); } catch (ParseError) { return false; } - $fixedJson = JsonRecast::print($result); + $nodeJsonTraverser = new NodeJsonTraverser(); + + foreach ($nodeJsonVisitors as $nodeJsonVisitor) { + $nodeJsonTraverser->addVisitor($nodeJsonVisitor); + } + + $nodeJsonTraversalResult = $nodeJsonTraverser->traverse($document); + + if (! $nodeJsonTraversalResult->node instanceof JsonDocument) { + throw new RuntimeException('JsonRecast fixer traversal must return JsonDocument.'); + } + + $jsonRecastResult = new JsonRecastResult($nodeJsonTraversalResult->node, $nodeJsonTraversalResult->changeSet); + + $fixedJson = JsonRecast::print($jsonRecastResult); return $fixedJson !== $json && file_put_contents($file, $fixedJson) !== false; } diff --git a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php index 28f8f9c2..5137ca83 100644 --- a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php +++ b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php @@ -223,6 +223,41 @@ public function testFixRemovesPsr4MappingsForMissingDirectories(): void } } JSON, file_get_contents($basePath . '/composer.json')); + + $batchBasePath = $this->makeTempProject(<<<'JSON' +{ + "autoload": { + "psr-4": { + "Missing\\": "missing/" + } + } +} +JSON); + $batchViolation = $psr4DirectoryExistsRule->evaluateProject($batchBasePath, Architecture::define()); + + $this->assertInstanceOf(RuleViolation::class, $batchViolation); + $this->assertTrue($psr4DirectoryExistsRule->fix($batchViolation, $batchViolation)); + $this->assertSame("{\n}", file_get_contents($batchBasePath . '/composer.json')); + + $firstBasePath = $this->makeTempProject('{}'); + $secondBasePath = $this->makeTempProject('{}'); + + $this->assertFalse($psr4DirectoryExistsRule->fix( + new RuleViolation( + message: 'First violation', + file: $firstBasePath . '/composer.json', + line: 1, + className: '', + ), + new RuleViolation( + message: 'Second violation', + file: $secondBasePath . '/composer.json', + line: 1, + className: '', + ), + )); + $this->assertSame('{}', file_get_contents($firstBasePath . '/composer.json')); + $this->assertSame('{}', file_get_contents($secondBasePath . '/composer.json')); } public function testFixRemovesPsr4BlockWhenEveryMappingDirectoryIsMissing(): void diff --git a/tests/Rule/Fixer/JsonRecast/JsonRecastFixerProcessorTest.php b/tests/Rule/Fixer/JsonRecast/JsonRecastFixerProcessorTest.php index ed85f322..7bfcdc1b 100644 --- a/tests/Rule/Fixer/JsonRecast/JsonRecastFixerProcessorTest.php +++ b/tests/Rule/Fixer/JsonRecast/JsonRecastFixerProcessorTest.php @@ -4,10 +4,13 @@ namespace Boundwize\StructArmed\Tests\Rule\Fixer\JsonRecast; +use Boundwize\JsonRecast\Node\NodeJson; +use Boundwize\JsonRecast\Node\StringNode; use Boundwize\JsonRecast\NodeVisitor\NodeJsonVisitorAbstract; use Boundwize\StructArmed\Rule\Fixer\JsonRecast\JsonRecastFixerProcessor; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use RuntimeException; use function file_put_contents; use function sys_get_temp_dir; @@ -17,12 +20,31 @@ #[CoversClass(JsonRecastFixerProcessor::class)] final class JsonRecastFixerProcessorTest extends TestCase { - public function testProcessReturnsFalseWhenFileDoesNotExist(): void + public function testProcessHandlesUnavailableFileAndInvalidTraversalRoot(): void { $file = $this->temporaryJsonFile('{}'); unlink($file); $this->assertFalse($this->process($file)); + + $file = $this->temporaryJsonFile('{}'); + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('JsonRecast fixer traversal must return JsonDocument.'); + + (new JsonRecastFixerProcessor())->process( + $file, + new class extends NodeJsonVisitorAbstract { + public function beforeTraverse(NodeJson $nodeJson): StringNode + { + return new StringNode('replacement'); + } + }, + ); + } finally { + unlink($file); + } } public function testProcessReturnsFalseWhenJsonCannotBeParsed(): void From 9962e02c4ad952d6d40ed5e4dfb37c4a658382be Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 12:22:44 +0700 Subject: [PATCH 086/104] perf: Only run fixViolations() when rule violation collection is not empty --- src/Cli/AnalyseCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index 95090202..a0d191d2 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -187,7 +187,7 @@ public function run(array $arguments, string $basePath): int return $this->reportError($runtimeException); } - if (isset($options['fix'])) { + if (isset($options['fix']) && ! $ruleViolationCollection->isEmpty()) { // Removal fixers can cascade: deleting an unused child abstraction // may leave its parent unused, so fix and re-analyse until a pass // fixes nothing. The pass cap only guards against a fixer that From 67054f0ab9e4cab8b76e54420e8c62877e261cf4 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 12:26:57 +0700 Subject: [PATCH 087/104] check empty once more time --- src/Cli/AnalyseCommand.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index a0d191d2..89df37bc 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -239,6 +239,10 @@ public function run(array $arguments, string $basePath): int $violationCountBeforePass - $ruleViolationCollection->count() ); $elapsed = microtime(true) - $start; + + if ($ruleViolationCollection->isEmpty()) { + break; + } } } From 2e23cfcc66afeb8d5be4fc1c147c42f0d9b70fa7 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 16:50:16 +0700 Subject: [PATCH 088/104] refactor: Collect dependencies, superglobals and language constructs as key sets in AnalysisNodeCollector --- src/Analyser/AnalysisNodeCollector.php | 38 ++++++++++++++------------ src/Analyser/ClassLikeAnalysis.php | 6 ++-- src/Analyser/FunctionLikeAnalysis.php | 6 ++-- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index 6410e144..d896da2b 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -314,7 +314,7 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract private string $currentFile = ''; - /** @var list */ + /** @var array */ private array $currentNamespaceUses = []; /** @var ClassLike[] */ @@ -539,7 +539,7 @@ public function enterNode(Node $node): null if ($node instanceof Use_) { foreach ($node->uses as $use) { - $this->currentNamespaceUses[] = $use->name->toString(); + $this->currentNamespaceUses[$use->name->toString()] = true; } return null; @@ -549,7 +549,7 @@ public function enterNode(Node $node): null $prefix = $node->prefix->toString(); foreach ($node->uses as $use) { - $this->currentNamespaceUses[] = $prefix . '\\' . $use->name->toString(); + $this->currentNamespaceUses[$prefix . '\\' . $use->name->toString()] = true; } return null; @@ -931,10 +931,11 @@ private function finishFunctionLikeAnalysis(): void $parent = $this->activeFunctionLikeAnalyses[$activeCount - 2]; - array_push($parent->dependencies, ...$functionLikeAnalysis->dependencies); array_push($parent->functionCallNames, ...$functionLikeAnalysis->functionCallNames); - array_push($parent->superglobals, ...$functionLikeAnalysis->superglobals); - array_push($parent->languageConstructs, ...$functionLikeAnalysis->languageConstructs); + + $parent->dependencies += $functionLikeAnalysis->dependencies; + $parent->superglobals += $functionLikeAnalysis->superglobals; + $parent->languageConstructs += $functionLikeAnalysis->languageConstructs; if ($functionLikeAnalysis->cyclomaticComplexity > 1) { $parent->cyclomaticComplexity += $functionLikeAnalysis->cyclomaticComplexity - 1; @@ -1315,13 +1316,13 @@ private function stripLeadingNamespaceSeparator(string $name): string private function addDependency(string $dependency): void { foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { - $activeClassLikeAnalysis->dependencies[] = $dependency; + $activeClassLikeAnalysis->dependencies[$dependency] = true; } $activeFunctionLikeCount = count($this->activeFunctionLikeAnalyses); if ($activeFunctionLikeCount > 0) { - $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1]->dependencies[] = $dependency; + $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1]->dependencies[$dependency] = true; } } @@ -1341,13 +1342,13 @@ private function addFunctionCallName(Name $functionCallName): void private function addSuperglobal(string $superglobal): void { foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { - $activeClassLikeAnalysis->superglobals[] = $superglobal; + $activeClassLikeAnalysis->superglobals[$superglobal] = true; } $activeFunctionLikeCount = count($this->activeFunctionLikeAnalyses); if ($activeFunctionLikeCount > 0) { - $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1]->superglobals[] = $superglobal; + $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1]->superglobals[$superglobal] = true; } } @@ -1370,13 +1371,14 @@ private function markThisUsage(): void private function addLanguageConstruct(string $languageConstruct): void { foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { - $activeClassLikeAnalysis->languageConstructs[] = $languageConstruct; + $activeClassLikeAnalysis->languageConstructs[$languageConstruct] = true; } $activeFunctionLikeCount = count($this->activeFunctionLikeAnalyses); if ($activeFunctionLikeCount > 0) { - $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1]->languageConstructs[] = $languageConstruct; + $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1] + ->languageConstructs[$languageConstruct] = true; } } @@ -1430,10 +1432,10 @@ private function collectFunctionLike(FunctionLikeAnalysis $functionLikeAnalysis) $functionCalls[] = $this->resolveFunctionName($functionCallName); } - $dependencies = array_values(array_unique($functionLikeAnalysis->dependencies)); + $dependencies = array_keys($functionLikeAnalysis->dependencies); $functionCalls = array_values(array_unique($functionCalls)); - $superglobals = array_values(array_unique($functionLikeAnalysis->superglobals)); - $languageConstructs = array_values(array_unique($functionLikeAnalysis->languageConstructs)); + $superglobals = array_keys($functionLikeAnalysis->superglobals); + $languageConstructs = array_keys($functionLikeAnalysis->languageConstructs); $hasReturnType = $functionLike->getReturnType() instanceof Node; $paramCount = count($functionLike->getParams()); $lineCount = $this->calculateLineCount($functionLike); @@ -1547,10 +1549,10 @@ private function collectClassLikeAnalysis(int $classLikeId): array } return [ - 'dependencies' => array_values(array_unique($analysis->dependencies)), + 'dependencies' => array_keys($analysis->dependencies), 'functionCalls' => array_values(array_unique($functionCalls)), - 'superglobals' => array_values(array_unique($analysis->superglobals)), - 'languageConstructs' => array_values(array_unique($analysis->languageConstructs)), + 'superglobals' => array_keys($analysis->superglobals), + 'languageConstructs' => array_keys($analysis->languageConstructs), 'traits' => $analysis->traits, 'constants' => $analysis->constants, 'properties' => $analysis->properties, diff --git a/src/Analyser/ClassLikeAnalysis.php b/src/Analyser/ClassLikeAnalysis.php index 8ca820a5..c6bd5ab7 100644 --- a/src/Analyser/ClassLikeAnalysis.php +++ b/src/Analyser/ClassLikeAnalysis.php @@ -14,16 +14,16 @@ */ final class ClassLikeAnalysis { - /** @var list */ + /** @var array */ public array $dependencies = []; /** @var list */ public array $functionCallNames = []; - /** @var string[] */ + /** @var array */ public array $superglobals = []; - /** @var string[] */ + /** @var array */ public array $languageConstructs = []; /** @var string[] */ diff --git a/src/Analyser/FunctionLikeAnalysis.php b/src/Analyser/FunctionLikeAnalysis.php index cd8c72aa..a7aa9d57 100644 --- a/src/Analyser/FunctionLikeAnalysis.php +++ b/src/Analyser/FunctionLikeAnalysis.php @@ -16,16 +16,16 @@ */ final class FunctionLikeAnalysis { - /** @var list */ + /** @var array */ public array $dependencies = []; /** @var list */ public array $functionCallNames = []; - /** @var string[] */ + /** @var array */ public array $superglobals = []; - /** @var string[] */ + /** @var array */ public array $languageConstructs = []; public int $cyclomaticComplexity = 1; From 78019cfae187ced0b31fbd671eb0be039f659933 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 17:00:21 +0700 Subject: [PATCH 089/104] perf: Dispatch Variable nodes first in AnalysisNodeCollector::enterNode() and collect dependencies as sets --- src/Analyser/AnalysisNodeCollector.php | 30 +++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index d896da2b..8231cf90 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -527,6 +527,20 @@ public function enterNode(Node $node): null return null; } + // Variables are the most frequent node class in a typical tree, so they + // dispatch before the statement and function-like checks below. Only + // `$this` and superglobals are recorded; both handlers are no-ops + // outside any class-like or function-like scope. + if ($node instanceof Variable) { + if ($node->name === 'this') { + $this->markThisUsage(); + } elseif (is_string($node->name) && isset(self::SUPERGLOBALS[$node->name])) { + $this->addSuperglobal('$' . $node->name); + } + + return null; + } + // The scope-tracking node types are all statements, so the far more // frequent expression/name/identifier nodes skip their checks with a // single instanceof. @@ -592,7 +606,7 @@ public function enterNode(Node $node): null return null; } - } elseif ($node instanceof Closure || $node instanceof ArrowFunction) { + } elseif ($node instanceof FunctionLike) { $this->startFunctionLikeAnalysis($node); return null; @@ -1030,20 +1044,6 @@ private function collectNodeAnalysis(Node $node): void return; } - if ($node instanceof Variable) { - if (! is_string($node->name)) { - return; - } - - if ($node->name === 'this') { - $this->markThisUsage(); - } elseif (isset(self::SUPERGLOBALS[$node->name])) { - $this->addSuperglobal('$' . $node->name); - } - - return; - } - if ($node instanceof FuncCall) { if ($node->name instanceof Name) { $functionName = $node->name->toLowerString(); From 3b79792c9aedf3177b0a2fd16494527c7357cc53 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 17:10:12 +0700 Subject: [PATCH 090/104] better comment --- src/Analyser/AnalysisNodeCollector.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index 8231cf90..e18029ce 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -527,10 +527,13 @@ public function enterNode(Node $node): null return null; } - // Variables are the most frequent node class in a typical tree, so they - // dispatch before the statement and function-like checks below. Only - // `$this` and superglobals are recorded; both handlers are no-ops - // outside any class-like or function-like scope. + // This ordering is about the instanceof tests in this method, not the + // traversal: the traverser still enters a statement before the + // expressions inside it. Variable is the most frequent node class, so + // testing it first spares every variable the Stmt and FunctionLike + // checks and the collectNodeAnalysis() call. Only `$this` and + // superglobals are recorded; outside a class-like or function-like + // scope both handlers record nothing, so no scope check is needed. if ($node instanceof Variable) { if ($node->name === 'this') { $this->markThisUsage(); From 818b372853d8f947cda2c9c013f01190e72b675d Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 18:25:48 +0700 Subject: [PATCH 091/104] Add AnonymousClassRuleInterface and new AnonymousClassMayNotHaveEmptyParenthesesRule --- docs/available-rules.md | 1 + docs/custom-rules-and-presets.md | 8 +- docs/presets.md | 2 +- src/Analyser/Analyser.php | 27 +- src/Analyser/AnalysisNodeCollector.php | 32 ++- src/Analyser/AnalysisNodeExtractor.php | 2 +- src/Analyser/AnonymousClassNode.php | 54 +++- src/Analyser/FileAnalysisProvider.php | 13 + src/Architecture.php | 14 +- src/Baseline/Baseline.php | 2 +- src/Cache/AnalysisResultCache.php | 63 +++-- src/Preset/Presets/PerPreset.php | 9 + src/Rule/AnonymousClassRuleInterface.php | 29 ++ ...RemoveAnonymousClassParenthesesVisitor.php | 78 ++++++ .../PhpParser/PhpParserFixerProcessor.php | 13 +- .../PhpParser/TokenAwareVisitorInterface.php | 21 ++ ...ousClassMayNotHaveEmptyParenthesesRule.php | 56 ++++ .../PhpParser/AnonymousClassParentheses.php | 67 +++++ tests/Analyser/AnalyserTest.php | 82 ++++++ tests/Analyser/AnalysisNodeCollectorTest.php | 58 +++- tests/Cache/AnalysisResultCacheTest.php | 20 +- tests/Preset/PresetTest.php | 1 + ...lassMayNotHaveEmptyParenthesesRuleTest.php | 249 ++++++++++++++++++ .../AnonymousClassParenthesesTest.php | 65 +++++ 24 files changed, 904 insertions(+), 62 deletions(-) create mode 100644 src/Rule/AnonymousClassRuleInterface.php create mode 100644 src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php create mode 100644 src/Rule/Fixer/PhpParser/TokenAwareVisitorInterface.php create mode 100644 src/Rule/Rules/Class_/AnonymousClassMayNotHaveEmptyParenthesesRule.php create mode 100644 src/Util/PhpParser/AnonymousClassParentheses.php create mode 100644 tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php create mode 100644 tests/Util/PhpParser/AnonymousClassParenthesesTest.php diff --git a/docs/available-rules.md b/docs/available-rules.md index 536ba66e..3612cd65 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -75,6 +75,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`. | Rule | Constructor | Checks | |---|---|---| +| `AnonymousClassMayNotHaveEmptyParenthesesRule` | `new AnonymousClassMayNotHaveEmptyParenthesesRule(layer: 'Source')` | Anonymous classes that pass no constructor argument omit the parentheses after `class` (`new class {}`, not `new class () {}`), per [PER Coding Style](https://www.php-fig.org/per/coding-style/#8-anonymous-classes). Supports `--fix` by removing the empty parentheses. | | `ClassConstantNameMustBeUpperCaseRule` | `new ClassConstantNameMustBeUpperCaseRule(layer: 'Domain')` | Class, interface, and trait constants use upper case with underscore separators. Enums are skipped (PER Coding Style recommends PascalCase enum constants). | | `ClassImplementingInterfaceMustHaveSuffixRule` | `new ClassImplementingInterfaceMustHaveSuffixRule(layer: 'HTTP', interface: MiddlewareInterface::class, suffix: 'Middleware')` | Classes implementing a specific interface use the required suffix. | | `ClassNameMustBeStudlyCapsRule` | `new ClassNameMustBeStudlyCapsRule(layer: 'Source')` | Class names use StudlyCaps. | diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index 4b6a68fb..8bc06117 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -190,7 +190,9 @@ Both carry the body-level facts a `ClassNode` has — `$dependencies`, `$functio A closure declared inside a class or a named function is counted on both nodes: the enclosing `ClassNode` (or `FunctionNode`) keeps seeing everything the closure does, exactly as it sees its own method bodies, and the `AnonymousFunctionNode` reports the closure body on its own. -Rules opt in to these nodes by implementing `Boundwize\StructArmed\Rule\FunctionRuleInterface` and/or `Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface`. Both share the `appliesTo()` / `evaluate()` method names with `RuleInterface`, each typed against its own node kind. Global skip paths, rule-scoped `skip()` paths, and `skipRule()` apply the same way. Function-likes are not part of the declarative `ruleset()` layer-dependency check. +Anonymous classes (`new class ... {}`) are collected the same way, as `Boundwize\StructArmed\Analyser\AnonymousClassNode`: identified by `$file` and `$line` plus `$enclosingClassName` / `$enclosingFunctionName` (with `enclosingScopeName()` and `AnonymousClassNode::FILE_SCOPE`), and carrying `$extends`, `$implements`, `$traits`, `$layer` / `$layers` with `isInLayer()`, and `$hasEmptyParentheses` — whether the declaration spells `new class () {}` although it passes no constructor argument. An anonymous class never becomes a `ClassNode`; the named class-like or function declaring it keeps seeing its body, exactly as it sees a closure's. + +Rules opt in to these nodes by implementing `Boundwize\StructArmed\Rule\FunctionRuleInterface`, `Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface`, and/or `Boundwize\StructArmed\Rule\AnonymousClassRuleInterface`. All share the `appliesTo()` / `evaluate()` method names with `RuleInterface`, each typed against its own node kind. Global skip paths, rule-scoped `skip()` paths, and `skipRule()` apply the same way. Function-likes and anonymous classes are not part of the declarative `ruleset()` layer-dependency check. ```php functionName, $layerAwareRule->injectClassNodeMap($classDependencyMaps['classNodeMap']); } - // Function-likes are not part of the class hierarchy, so they take no - // part in the declarative ruleset below; a rule only sees the node - // kind whose interface it implements, so each node collection is - // paired with the rules grouped for its kind above. + // Function-likes and anonymous classes are not part of the class + // hierarchy, so they take no part in the declarative ruleset below; a + // rule only sees the node kind whose interface it implements, so each + // node collection is paired with the rules grouped for its kind above. $this->evaluateNodeRules( [ [$classNodes, $classNodeRules], [$extractionResult->functionNodes, $functionNodeRules], [$extractionResult->anonymousFunctionNodes, $anonymousFunctionNodeRules], + [$extractionResult->anonymousClassNodes, $anonymousClassNodeRules], ], $globalSkipPathMatcher, $ruleSkipMatchers, @@ -368,15 +376,16 @@ className: $classNode->className, /** * Evaluates each node collection against the rules grouped for its node - * kind, in a single evaluation implementation: all three rule interfaces + * kind, in a single evaluation implementation: all four rule interfaces * share the appliesTo()/evaluate() method names, and a rule only receives * the node kind whose interface it implements. * - * @param list, 1: array}> $nodeGroups + * @param list, 1: array}> $nodeGroups * @param array $ruleSkipMatchers * @phpstan-param list|list|list, - * 1: array + * 0: list|list|list|list, + * 1: array * }> $nodeGroups */ private function evaluateNodeRules( @@ -548,7 +557,7 @@ private function isSourceSynthesised(Architecture $architecture): bool } /** - * @param array $nodeRules + * @param array $nodeRules Node rules of every kind, by key * @param array> $ruleSkipPaths * @return array */ diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index e18029ce..1f1ae362 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -5,6 +5,7 @@ namespace Boundwize\StructArmed\Analyser; use Boundwize\StructArmed\LayerResolver\LayerResolverInterface; +use Boundwize\StructArmed\Util\PhpParser\AnonymousClassParentheses; use Boundwize\StructArmed\Util\PhpParser\VisibilityFlagChecker; use PhpParser\ConstExprEvaluationException; use PhpParser\ConstExprEvaluator; @@ -71,6 +72,7 @@ use PhpParser\Node\Stmt\Use_; use PhpParser\Node\Stmt\While_; use PhpParser\NodeVisitorAbstract; +use PhpParser\Token; use function array_keys; use function array_pop; @@ -314,6 +316,9 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract private string $currentFile = ''; + /** @var array */ + private array $currentTokens = []; + /** @var array */ private array $currentNamespaceUses = []; @@ -392,9 +397,11 @@ public function __construct( }); } - public function setCurrentFile(string $file): void + /** @param array $tokens The file's token stream, for the facts its AST does not carry */ + public function setCurrentFile(string $file, array $tokens = []): void { $this->currentFile = $file; + $this->currentTokens = $tokens; $this->currentFileReferences = []; $this->currentFileInstantiations = []; $this->nonCanonicalKeywordConstants = []; @@ -695,12 +702,25 @@ public function leaveNode(Node $node): null // extend, the interfaces they implement, and the traits they use // are still used within the scanned paths. if ($node instanceof Class_) { + // Its own (nameless) entry is already popped, so the innermost + // active names are the named scopes declaring it; they also + // resolve its layer, as they do for an anonymous function. + $enclosingClassName = $this->innermostActiveClassLikeName(); + $enclosingFunctionName = $this->activeFunctionNames === [] ? null : end($this->activeFunctionNames); + [$layer, $layers] = $this->resolveLayerData($enclosingClassName ?? $enclosingFunctionName ?? ''); + $this->anonymousClassNodes[] = new AnonymousClassNode( - file: $this->currentFile, - line: $node->getStartLine(), - extends: $node->extends instanceof Name ? $node->extends->toString() : null, - implements: $this->collectImplements($node), - traits: $this->collectTraits($node), + file: $this->currentFile, + line: $node->getStartLine(), + extends: $node->extends instanceof Name ? $node->extends->toString() : null, + implements: $this->collectImplements($node), + traits: $this->collectTraits($node), + layer: $layer, + enclosingClassName: $enclosingClassName, + enclosingFunctionName: $enclosingFunctionName, + hasEmptyParentheses: AnonymousClassParentheses::emptyTokenRange($this->currentTokens, $node) + !== null, + layers: $layers, ); } diff --git a/src/Analyser/AnalysisNodeExtractor.php b/src/Analyser/AnalysisNodeExtractor.php index 56ec98d5..b0974f77 100644 --- a/src/Analyser/AnalysisNodeExtractor.php +++ b/src/Analyser/AnalysisNodeExtractor.php @@ -51,7 +51,7 @@ public function extract( $numericLiterals = []; if ($ast !== null && $ast !== []) { - $analysisNodeCollector->setCurrentFile($file); + $analysisNodeCollector->setCurrentFile($file, $this->fileAnalysisProvider->tokens()); $nodeTraverser->traverse($ast); $nonCanonicalKeywordConstants = $analysisNodeCollector->getNonCanonicalKeywordConstants(); diff --git a/src/Analyser/AnonymousClassNode.php b/src/Analyser/AnonymousClassNode.php index c1f85c57..3e2728ac 100644 --- a/src/Analyser/AnonymousClassNode.php +++ b/src/Analyser/AnonymousClassNode.php @@ -4,20 +4,39 @@ namespace Boundwize\StructArmed\Analyser; +use function array_filter; +use function in_array; + /** * An anonymous class declaration (`new class ... {}`). Anonymous classes never - * become ClassNodes — they cannot be referenced by name and no rule targets - * them directly — but the class they extend, the interfaces they implement, - * and the traits they use are still used within the scanned paths, which - * usage-aware rules must take into account. + * become ClassNodes — they cannot be referenced by name — so one is identified + * by its file and line, plus the named class-like and/or function it is + * declared in, and rules target it through + * {@see \Boundwize\StructArmed\Rule\AnonymousClassRuleInterface}. * - * The usage example is on MustBeFinalRule, which must skip if target class is extended by an anonymous class. + * The class it extends, the interfaces it implements, and the traits it uses + * are still used within the scanned paths, which usage-aware rules must take + * into account: MustBeFinalRule must skip a class extended by an anonymous class. */ final readonly class AnonymousClassNode { /** - * @param string[] $implements Interface names this anonymous class implements - * @param string[] $traits Trait names this anonymous class uses + * Scope label reported by {@see enclosingScopeName()} for an anonymous + * class declared outside any class-like or named function. + */ + public const FILE_SCOPE = 'file scope'; + + /** @var list */ + public array $layers; + + /** + * @param string[] $implements Interface names this anonymous class implements + * @param string[] $traits Trait names this anonymous class uses + * @param string|null $enclosingClassName Innermost named class-like this anonymous class is declared in + * @param string|null $enclosingFunctionName Innermost named function this anonymous class is declared in + * @param bool $hasEmptyParentheses Whether `()` follows `class` although no constructor argument + * is passed: `new class () {}` rather than `new class {}` + * @param list $layers Layer names this anonymous class belongs to; defaults to [$layer] */ public function __construct( public string $file, @@ -25,6 +44,27 @@ public function __construct( public ?string $extends, public array $implements = [], public array $traits = [], + public ?string $layer = null, + public ?string $enclosingClassName = null, + public ?string $enclosingFunctionName = null, + public bool $hasEmptyParentheses = false, + array $layers = [], ) { + $this->layers = $layers ?: array_filter([$this->layer]); + } + + public function isInLayer(string $layer): bool + { + return in_array($layer, $this->layers, true); + } + + /** + * Label of the innermost named scope declaring this anonymous class — + * the enclosing class-like, else the enclosing named function — or + * {@see self::FILE_SCOPE} for one declared in top-level procedural code. + */ + public function enclosingScopeName(): string + { + return $this->enclosingClassName ?? $this->enclosingFunctionName ?? self::FILE_SCOPE; } } diff --git a/src/Analyser/FileAnalysisProvider.php b/src/Analyser/FileAnalysisProvider.php index b6afa900..6f036081 100644 --- a/src/Analyser/FileAnalysisProvider.php +++ b/src/Analyser/FileAnalysisProvider.php @@ -45,6 +45,7 @@ use PhpParser\Node\Stmt\Use_; use PhpParser\Parser; use PhpParser\ParserFactory; +use PhpParser\Token; use function array_key_exists; use function array_keys; @@ -202,6 +203,18 @@ public function ast(string $file, bool $retainForAnalysis = true): ?array return $this->parse($file); } + /** + * The token stream of the file {@see ast()} parsed last, for the facts an + * AST does not carry. PHP-Parser keeps it until its next parse, so it is + * read right after ast(); the provider itself retains no token arrays. + * + * @return array + */ + public function tokens(): array + { + return $this->parser->getTokens(); + } + /** * Parses an already normalised file that has neither a cached AST nor an * analysis, recording its AST, validity and invalid PHP tag line in one pass. diff --git a/src/Architecture.php b/src/Architecture.php index 92a5b77e..69566395 100644 --- a/src/Architecture.php +++ b/src/Architecture.php @@ -6,6 +6,7 @@ use Boundwize\StructArmed\Exception\RuleNotFoundException; use Boundwize\StructArmed\Preset\PresetInterface; +use Boundwize\StructArmed\Rule\AnonymousClassRuleInterface; use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface; use Boundwize\StructArmed\Rule\FunctionRuleInterface; use Boundwize\StructArmed\Rule\ProjectRuleInterface; @@ -51,8 +52,8 @@ final class Architecture private array $layers = []; /** - * @var array - * key → rule + * @var array key → rule */ private array $rules = []; @@ -357,7 +358,8 @@ public function registerPresetSourcePaths(string $preset, ?array $sourcePaths): */ public function rule( string $key, - RuleInterface|ProjectRuleInterface|FunctionRuleInterface|AnonymousFunctionRuleInterface $rule, + RuleInterface|ProjectRuleInterface|FunctionRuleInterface + |AnonymousFunctionRuleInterface|AnonymousClassRuleInterface $rule, ): self { $this->rules[$key] = $rule; $this->resolvePendingRuleSkip($key); @@ -374,7 +376,8 @@ public function rule( */ public function replaceRule( string $key, - RuleInterface|ProjectRuleInterface|FunctionRuleInterface|AnonymousFunctionRuleInterface $rule, + RuleInterface|ProjectRuleInterface|FunctionRuleInterface + |AnonymousFunctionRuleInterface|AnonymousClassRuleInterface $rule, ): self { if (! isset($this->rules[$key])) { throw new RuleNotFoundException(sprintf( @@ -435,7 +438,8 @@ public function getRulesetSkipPaths(): array } /** - * @return array + * @return array */ public function getRules(): array { diff --git a/src/Baseline/Baseline.php b/src/Baseline/Baseline.php index e3a0fbed..f2417b6d 100644 --- a/src/Baseline/Baseline.php +++ b/src/Baseline/Baseline.php @@ -147,7 +147,7 @@ private function isListArray(Array_ $array): bool private function prettyPrintArray(Array_ $array): string { - return (new class () extends Standard { + return (new class extends Standard { // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps protected function pExpr_Array(Array_ $node): string { diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 9e5df476..7bf76efe 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -66,7 +66,7 @@ final class AnalysisResultCache * their shape or naming changes: it is recorded in the metadata marker, * so a cache written by an older format is cleared on its next use. */ - public const FORMAT_VERSION = 5; + public const FORMAT_VERSION = 6; private readonly string $cacheDirectory; @@ -568,11 +568,16 @@ private function fileInstantiationsFromPayload(array $payload): ?array private function anonymousClassNodeToArray(AnonymousClassNode $anonymousClassNode): array { return [ - 'file' => $anonymousClassNode->file, - 'line' => $anonymousClassNode->line, - 'extends' => $anonymousClassNode->extends, - 'implements' => $anonymousClassNode->implements, - 'traits' => $anonymousClassNode->traits, + 'file' => $anonymousClassNode->file, + 'line' => $anonymousClassNode->line, + 'extends' => $anonymousClassNode->extends, + 'implements' => $anonymousClassNode->implements, + 'traits' => $anonymousClassNode->traits, + 'layer' => $anonymousClassNode->layer, + 'enclosingClassName' => $anonymousClassNode->enclosingClassName, + 'enclosingFunctionName' => $anonymousClassNode->enclosingFunctionName, + 'hasEmptyParentheses' => $anonymousClassNode->hasEmptyParentheses, + 'layers' => $anonymousClassNode->layers, ]; } @@ -595,26 +600,48 @@ private function anonymousClassNodesFromPayload(array $payload): ?array return null; } - $file = $rawNode['file'] ?? null; - $line = $rawNode['line'] ?? null; - $extends = $rawNode['extends'] ?? null; - $implements = $rawNode['implements'] ?? []; - $traits = $rawNode['traits'] ?? []; + $file = $rawNode['file'] ?? null; + $line = $rawNode['line'] ?? null; + $extends = $rawNode['extends'] ?? null; + $implements = $rawNode['implements'] ?? []; + $traits = $rawNode['traits'] ?? []; + $layer = $rawNode['layer'] ?? null; + $enclosingClassName = $rawNode['enclosingClassName'] ?? null; + $enclosingFunctionName = $rawNode['enclosingFunctionName'] ?? null; + $hasEmptyParentheses = $rawNode['hasEmptyParentheses'] ?? false; + $layers = $rawNode['layers'] ?? []; - if (! is_string($file) || ! is_int($line) || ($extends !== null && ! is_string($extends))) { + if ( + ! is_string($file) + || ! is_int($line) + || ($extends !== null && ! is_string($extends)) + || ($layer !== null && ! is_string($layer)) + || ($enclosingClassName !== null && ! is_string($enclosingClassName)) + || ($enclosingFunctionName !== null && ! is_string($enclosingFunctionName)) + || ! is_bool($hasEmptyParentheses) + ) { return null; } - if (! $this->isStringArray($implements) || ! $this->isStringArray($traits)) { + if ( + ! $this->isStringArray($implements) + || ! $this->isStringArray($traits) + || ! $this->isStringArray($layers) + ) { return null; } $anonymousClassNodes[] = new AnonymousClassNode( - file: $file, - line: $line, - extends: $extends, - implements: $implements, - traits: $traits, + file: $file, + line: $line, + extends: $extends, + implements: $implements, + traits: $traits, + layer: $layer, + enclosingClassName: $enclosingClassName, + enclosingFunctionName: $enclosingFunctionName, + hasEmptyParentheses: $hasEmptyParentheses, + layers: array_values($layers), ); } diff --git a/src/Preset/Presets/PerPreset.php b/src/Preset/Presets/PerPreset.php index 4235a1e4..dfb3344a 100644 --- a/src/Preset/Presets/PerPreset.php +++ b/src/Preset/Presets/PerPreset.php @@ -6,6 +6,7 @@ use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Preset\PresetInterface; +use Boundwize\StructArmed\Rule\Rules\Class_\AnonymousClassMayNotHaveEmptyParenthesesRule; use Boundwize\StructArmed\Rule\Rules\Class_\EnumCaseNameMustBePascalCaseRule; use Boundwize\StructArmed\Rule\Rules\Class_\EnumConstantMayNotBeProtectedRule; use Boundwize\StructArmed\Rule\Rules\Class_\EnumMethodMayNotBeProtectedRule; @@ -25,6 +26,9 @@ public const ENUM_CONSTANTS_MAY_NOT_BE_PROTECTED = 'per.enum_constants.may_not_be_protected'; + public const ANONYMOUS_CLASSES_MAY_NOT_HAVE_EMPTY_PARENTHESES = + 'per.anonymous_classes.may_not_have_empty_parentheses'; + /** * @param list|null $sourcePaths */ @@ -61,5 +65,10 @@ public function apply(Architecture $architecture): void self::ENUM_CONSTANTS_MAY_NOT_BE_PROTECTED, new EnumConstantMayNotBeProtectedRule($layerName) ); + + $architecture->rule( + self::ANONYMOUS_CLASSES_MAY_NOT_HAVE_EMPTY_PARENTHESES, + new AnonymousClassMayNotHaveEmptyParenthesesRule($layerName) + ); } } diff --git a/src/Rule/AnonymousClassRuleInterface.php b/src/Rule/AnonymousClassRuleInterface.php new file mode 100644 index 00000000..949e0382 --- /dev/null +++ b/src/Rule/AnonymousClassRuleInterface.php @@ -0,0 +1,29 @@ + */ + private array $tokens = []; + + public function __construct( + private readonly int $line, + ) { + } + + public function setTokens(array $tokens): void + { + $this->tokens = $tokens; + } + + public function enterNode(Node $node): ?Node + { + if (! $node instanceof Class_ || $node->name instanceof Identifier || $node->getStartLine() !== $this->line) { + return null; + } + + $range = AnonymousClassParentheses::emptyTokenRange($this->tokens, $node); + + if ($range === null) { + return null; + } + + [$first, $last] = $range; + $hasChanged = false; + + for ($index = $first; $index <= $last; $index++) { + if ($this->tokens[$index]->text === '') { + continue; + } + + $this->tokens[$index]->text = ''; + $hasChanged = true; + } + + // `new class(){}` keeps a space between the keyword and what follows. + if (! isset($this->tokens[$last + 1]) || $this->tokens[$last + 1]->id !== T_WHITESPACE) { + $this->tokens[$last]->text = ' '; + $hasChanged = true; + } + + if (! $hasChanged) { + return null; + } + + return $node; + } +} diff --git a/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php b/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php index 8d6adf61..13880d6d 100644 --- a/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php +++ b/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php @@ -52,11 +52,18 @@ public function process(string $file, NodeVisitor|array $nodeVisitors, bool $rem return false; } + $tokens = $parser->getTokens(); $statements = (new NodeTraverser(new CloningVisitor()))->traverse($originalStatements); $statements = (new NodeTraverser(new NameResolver(options: ['replaceNodes' => false]))) ->traverse($statements); foreach ($nodeVisitors as $nodeVisitor) { + // A token edit lands in the output through the same tokens the + // format-preserving printer copies unchanged code from. + if ($nodeVisitor instanceof TokenAwareVisitorInterface) { + $nodeVisitor->setTokens($tokens); + } + $statements = (new NodeTraverser($nodeVisitor))->traverse($statements); } @@ -79,11 +86,7 @@ protected function pScalar_Float(Float_ $node): string return parent::pScalar_Float($node); } }; - $fixedCode = $prettyPrinter->printFormatPreserving( - $statements, - $originalStatements, - $parser->getTokens(), - ); + $fixedCode = $prettyPrinter->printFormatPreserving($statements, $originalStatements, $tokens); return $fixedCode !== $code && file_put_contents($file, $fixedCode) !== false; } diff --git a/src/Rule/Fixer/PhpParser/TokenAwareVisitorInterface.php b/src/Rule/Fixer/PhpParser/TokenAwareVisitorInterface.php new file mode 100644 index 00000000..d4fb8cc7 --- /dev/null +++ b/src/Rule/Fixer/PhpParser/TokenAwareVisitorInterface.php @@ -0,0 +1,21 @@ + $tokens The mutable tokens the file being fixed was parsed into */ + public function setTokens(array $tokens): void; +} diff --git a/src/Rule/Rules/Class_/AnonymousClassMayNotHaveEmptyParenthesesRule.php b/src/Rule/Rules/Class_/AnonymousClassMayNotHaveEmptyParenthesesRule.php new file mode 100644 index 00000000..7b71efd3 --- /dev/null +++ b/src/Rule/Rules/Class_/AnonymousClassMayNotHaveEmptyParenthesesRule.php @@ -0,0 +1,56 @@ +isInLayer($this->layer); + } + + public function evaluate(AnonymousClassNode $anonymousClassNode): ?RuleViolation + { + if (! $anonymousClassNode->hasEmptyParentheses) { + return null; + } + + return new RuleViolation( + message: sprintf( + 'Anonymous class in [%s] may not have empty parentheses after `class`', + $anonymousClassNode->enclosingScopeName() + ), + file: $anonymousClassNode->file, + line: $anonymousClassNode->line, + className: $anonymousClassNode->enclosingScopeName(), + layer: $anonymousClassNode->layer, + ); + } + + protected function createFixerVisitor(RuleViolation $ruleViolation): RemoveAnonymousClassParenthesesVisitor + { + return new RemoveAnonymousClassParenthesesVisitor($ruleViolation->line); + } +} diff --git a/src/Util/PhpParser/AnonymousClassParentheses.php b/src/Util/PhpParser/AnonymousClassParentheses.php new file mode 100644 index 00000000..25d6b69e --- /dev/null +++ b/src/Util/PhpParser/AnonymousClassParentheses.php @@ -0,0 +1,67 @@ + $tokens + * @return array{int, int}|null + */ + public static function emptyTokenRange(array $tokens, Class_ $class): ?array + { + $index = $class->getStartTokenPos(); + + // Attributes and modifiers (`new #[Attr] readonly class`) precede the keyword. + while (isset($tokens[$index]) && $tokens[$index]->id !== T_CLASS) { + $index++; + } + + if (! isset($tokens[$index])) { + return null; + } + + $first = $index + 1; + $index = self::skipWhitespace($tokens, $first); + + if (! isset($tokens[$index]) || $tokens[$index]->text !== '(') { + return null; + } + + $index = self::skipWhitespace($tokens, $index + 1); + + if (! isset($tokens[$index]) || $tokens[$index]->text !== ')') { + return null; + } + + return [$first, $index]; + } + + /** @param array $tokens */ + private static function skipWhitespace(array $tokens, int $index): int + { + while (isset($tokens[$index]) && $tokens[$index]->id === T_WHITESPACE) { + $index++; + } + + return $index; + } +} diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index a07dea3b..ed05d360 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -27,6 +27,7 @@ use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface; use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface; use Boundwize\StructArmed\Rule\FunctionRuleInterface; +use Boundwize\StructArmed\Rule\Rules\Class_\AnonymousClassMayNotHaveEmptyParenthesesRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule; use Boundwize\StructArmed\Rule\Rules\Composer\Psr4SourcePathsRule; use Boundwize\StructArmed\Rule\Rules\File\Psr1PhpTagsRule; @@ -291,6 +292,87 @@ public function testFunctionRuleViolationsSurviveTheAnalysisNodeCacheWithFileAna $this->assertEquals($coldViolations, $warmViolations); } + /** @return array */ + private function anonymousClassRuleProjectFiles(): array + { + return [ + 'src/Handler.php' => <<<'PHP' + <<<'PHP' + <<<'PHP' + makeTempProject($this->anonymousClassRuleProjectFiles()); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('anonymous_classes.no_parentheses', new AnonymousClassMayNotHaveEmptyParenthesesRule('Source')) + ->skip(['anonymous_classes.no_parentheses' => ['src/Skipped/']]); + + foreach ([AnalyserOptions::sequential(), AnalyserOptions::parallel(2)] as $analyserOptions) { + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, $analyserOptions) + ->forRule('anonymous_classes.no_parentheses'); + + $messages = array_map( + static fn(RuleViolation $ruleViolation): string => $ruleViolation->message, + $violations + ); + sort($messages); + + $this->assertSame([ + 'Anonymous class in [App\\Handler] may not have empty parentheses after `class`', + 'Anonymous class in [App\\make] may not have empty parentheses after `class`', + ], $messages); + + foreach ($violations as $violation) { + $this->assertSame('anonymous_classes.no_parentheses', $violation->ruleKey); + $this->assertSame('Source', $violation->layer); + $this->assertTrue($violation->fixable); + $this->assertStringNotContainsString('/Skipped/', $this->normalisePath($violation->file)); + } + } + } + + public function testAnonymousClassRuleViolationsSurviveTheAnalysisNodeCache(): void + { + $basePath = $this->makeTempProject($this->anonymousClassRuleProjectFiles()); + $analysisResultCache = new AnalysisResultCache($basePath, new FileHashProvider(), 'cache'); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('anonymous_classes.no_parentheses', new AnonymousClassMayNotHaveEmptyParenthesesRule('Source')); + + $coldViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('anonymous_classes.no_parentheses'); + $warmViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('anonymous_classes.no_parentheses'); + + $this->assertCount(3, $coldViolations); + $this->assertEquals($coldViolations, $warmViolations); + } + public function testSkippedFunctionRuleIsNotEvaluated(): void { $basePath = $this->makeTempProject($this->functionRuleProjectFiles()); diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index e7674804..5b1290f9 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -141,7 +141,7 @@ private function makeCollector(string $code): AnalysisNodeCollector $parser = (new ParserFactory())->createForNewestSupportedVersion(); $ast = $parser->parse($code); - $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php'); + $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php', $parser->getTokens()); $nodeTraverser = new NodeTraverser(new NameResolver(), $analysisNodeCollector); $nodeTraverser->traverse($ast ?? []); @@ -654,6 +654,62 @@ public function make(): BaseHandler { return new class extends BaseHandler {}; } $this->assertSame('/fake/path/Foo.php', $anonymousClassNodes[0]->file); } + public function testCollectsAnonymousClassEnclosingScopesAndEmptyParentheses(): void + { + $anonymousClassNodes = $this->collectAnonymousClassNodes(<<<'PHP' + assertCount(4, $anonymousClassNodes); + + $this->assertSame('App\HandlerFactory', $anonymousClassNodes[0]->enclosingClassName); + $this->assertNull($anonymousClassNodes[0]->enclosingFunctionName); + $this->assertSame('App\HandlerFactory', $anonymousClassNodes[0]->enclosingScopeName()); + $this->assertTrue($anonymousClassNodes[0]->hasEmptyParentheses); + + $this->assertNull($anonymousClassNodes[1]->enclosingClassName); + $this->assertSame('App\make', $anonymousClassNodes[1]->enclosingFunctionName); + $this->assertSame('App\make', $anonymousClassNodes[1]->enclosingScopeName()); + $this->assertTrue($anonymousClassNodes[1]->hasEmptyParentheses); + $this->assertSame(['Stringable'], $anonymousClassNodes[1]->implements); + + $this->assertSame(AnonymousClassNode::FILE_SCOPE, $anonymousClassNodes[2]->enclosingScopeName()); + $this->assertFalse($anonymousClassNodes[2]->hasEmptyParentheses); + + $this->assertSame(AnonymousClassNode::FILE_SCOPE, $anonymousClassNodes[3]->enclosingScopeName()); + $this->assertFalse($anonymousClassNodes[3]->hasEmptyParentheses); + + // The fake file is outside every configured layer path. + $this->assertNull($anonymousClassNodes[0]->layer); + $this->assertSame([], $anonymousClassNodes[0]->layers); + $this->assertFalse($anonymousClassNodes[0]->isInLayer('Domain')); + } + + public function testAnonymousClassParenthesesAreUnknownWithoutTokens(): void + { + $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + + $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php'); + (new NodeTraverser(new NameResolver(), $analysisNodeCollector)) + ->traverse($parser->parse('getAnonymousClassNodes(); + + $this->assertCount(1, $anonymousClassNodes); + $this->assertFalse($anonymousClassNodes[0]->hasEmptyParentheses); + } + public function testCollectsTopLevelAnonymousClassNodeInFileWithoutNamedClasses(): void { $anonymousClassNodes = $this->collectAnonymousClassNodes('makeClassNode($sourceFile)]; $anonymousClassNodes = [ new AnonymousClassNode( - file: $sourceFile, - line: 7, - extends: 'App\BaseHandler', - implements: ['App\Contract'], - traits: ['App\Helper'], + file: $sourceFile, + line: 7, + extends: 'App\BaseHandler', + implements: ['App\Contract'], + traits: ['App\Helper'], + layer: 'Source', + enclosingClassName: 'App\HandlerFactory', + hasEmptyParentheses: true, + layers: ['Source', 'Shared'], + ), + new AnonymousClassNode( + file: $sourceFile, + line: 12, + extends: null, + enclosingFunctionName: 'App\make', ), ]; diff --git a/tests/Preset/PresetTest.php b/tests/Preset/PresetTest.php index 26336ed9..77c0b8f6 100644 --- a/tests/Preset/PresetTest.php +++ b/tests/Preset/PresetTest.php @@ -190,6 +190,7 @@ public function testPerPresetAppliesPsr12RulesAndAddsEnumCaseRule(): void $this->assertArrayHasKey(PerPreset::ENUM_CASES_MUST_BE_PASCAL_CASE, $rules); $this->assertArrayHasKey(PerPreset::ENUM_METHODS_MAY_NOT_BE_PROTECTED, $rules); $this->assertArrayHasKey(PerPreset::ENUM_CONSTANTS_MAY_NOT_BE_PROTECTED, $rules); + $this->assertArrayHasKey(PerPreset::ANONYMOUS_CLASSES_MAY_NOT_HAVE_EMPTY_PARENTHESES, $rules); } public function testPerPresetUsesComposerSourcePathsByDefault(): void diff --git a/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php b/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php new file mode 100644 index 00000000..82a0222a --- /dev/null +++ b/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php @@ -0,0 +1,249 @@ +assertTrue($anonymousClassMayNotHaveEmptyParenthesesRule->appliesTo( + $this->makeNode(layer: 'Source') + )); + $this->assertTrue($anonymousClassMayNotHaveEmptyParenthesesRule->appliesTo( + $this->makeNode(layer: 'Other', layers: ['Other', 'Source']) + )); + $this->assertFalse($anonymousClassMayNotHaveEmptyParenthesesRule->appliesTo( + $this->makeNode(layer: 'Other') + )); + $this->assertFalse($anonymousClassMayNotHaveEmptyParenthesesRule->appliesTo( + $this->makeNode(layer: null) + )); + } + + public function testPassesAnonymousClassWithoutEmptyParentheses(): void + { + $anonymousClassMayNotHaveEmptyParenthesesRule = new AnonymousClassMayNotHaveEmptyParenthesesRule('Source'); + + $this->assertNotInstanceOf( + RuleViolation::class, + $anonymousClassMayNotHaveEmptyParenthesesRule->evaluate($this->makeNode(hasEmptyParentheses: false)) + ); + } + + public function testFlagsAnonymousClassWithEmptyParentheses(): void + { + $anonymousClassMayNotHaveEmptyParenthesesRule = new AnonymousClassMayNotHaveEmptyParenthesesRule('Source'); + + $violation = $anonymousClassMayNotHaveEmptyParenthesesRule->evaluate( + $this->makeNode(enclosingClassName: 'App\Factory') + ); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame( + 'Anonymous class in [App\Factory] may not have empty parentheses after `class`', + $violation->message + ); + $this->assertSame('/src/Factory.php', $violation->file); + $this->assertSame(7, $violation->line); + $this->assertSame('App\Factory', $violation->className); + $this->assertSame('Source', $violation->layer); + } + + public function testReportsFileScopeForTopLevelAnonymousClass(): void + { + $anonymousClassMayNotHaveEmptyParenthesesRule = new AnonymousClassMayNotHaveEmptyParenthesesRule('Source'); + + $violation = $anonymousClassMayNotHaveEmptyParenthesesRule->evaluate($this->makeNode()); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame(AnonymousClassNode::FILE_SCOPE, $violation->className); + } + + public function testAnalyseThenFixRemovesOnlyEmptyParentheses(): void + { + $basePath = $this->makeTemporaryDirectory('structarmed-anonymous-class-parentheses'); + mkdir($basePath . '/src'); + + $file = $basePath . '/src/Factory.php'; + + file_put_contents($file, <<<'PHP' + layer('Source', 'src/') + ->rule('source.anonymous_classes', new AnonymousClassMayNotHaveEmptyParenthesesRule(layer: 'Source')); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.anonymous_classes'); + + $this->assertSame( + [11, 25, 26, 27, 27, 28], + array_map(static fn (RuleViolation $ruleViolation): int => $ruleViolation->line, $violations) + ); + $this->assertTrue($violations[0]->fixable); + $this->assertSame('App\Factory', $violations[0]->className); + + $rule = $architecture->getRules()['source.anonymous_classes']; + $this->assertInstanceOf(AnonymousClassMayNotHaveEmptyParenthesesRule::class, $rule); + + // The CLI fixes one file's violations in a single parse-and-write cycle. + $this->assertTrue($rule->fix($violations[0], ...array_slice($violations, 1))); + + // Only the empty parentheses are gone: the class bodies, the brace + // placement, and the blank lines are untouched. + $this->assertSame(<<<'PHP' + assertCount( + 0, + (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.anonymous_classes') + ); + $this->assertFalse($rule->fix($violations[0])); + } + + public function testFixLeavesAnonymousClassOnAnotherLineAlone(): void + { + $basePath = $this->makeTemporaryDirectory('structarmed-anonymous-class-parentheses-line'); + $file = $basePath . '/Factory.php'; + + file_put_contents($file, <<<'PHP' + assertTrue( + $anonymousClassMayNotHaveEmptyParenthesesRule->fix(new RuleViolation('message', $file, 4, 'file scope')) + ); + + $this->assertSame(<<<'PHP' + $layers */ + private function makeNode( + ?string $layer = 'Source', + ?string $enclosingClassName = null, + bool $hasEmptyParentheses = true, + array $layers = [], + ): AnonymousClassNode { + return new AnonymousClassNode( + file: '/src/Factory.php', + line: 7, + extends: null, + layer: $layer, + enclosingClassName: $enclosingClassName, + hasEmptyParentheses: $hasEmptyParentheses, + layers: $layers, + ); + } +} diff --git a/tests/Util/PhpParser/AnonymousClassParenthesesTest.php b/tests/Util/PhpParser/AnonymousClassParenthesesTest.php new file mode 100644 index 00000000..3ac952fd --- /dev/null +++ b/tests/Util/PhpParser/AnonymousClassParenthesesTest.php @@ -0,0 +1,65 @@ +createForNewestSupportedVersion(); + $statements = $parser->parse($code); + $tokens = $parser->getTokens(); + + $class = (new NodeFinder())->findFirstInstanceOf($statements ?? [], Class_::class); + $this->assertInstanceOf(Class_::class, $class); + + $range = AnonymousClassParentheses::emptyTokenRange($tokens, $class); + + if ($expectedRangeText === null) { + $this->assertNull($range); + + return; + } + + $this->assertIsArray($range); + [$first, $last] = $range; + + $rangeText = ''; + for ($index = $first; $index <= $last; $index++) { + $rangeText .= $tokens[$index]->text; + } + + $this->assertSame($expectedRangeText, $rangeText); + } + + /** @return iterable */ + public static function anonymousClassProvider(): iterable + { + yield 'no parentheses' => [' [' [' [" [' [' [' [' ['assertNull(AnonymousClassParentheses::emptyTokenRange([], new Class_(null))); + } +} From aa8c1d6c583b4fb55f4cfdf763d9fcbf7c610c64 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 18:32:00 +0700 Subject: [PATCH 092/104] add more test --- ...RemoveAnonymousClassParenthesesVisitor.php | 13 ++---------- tests/Analyser/FileAnalysisProviderTest.php | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php b/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php index c747926e..6f0e3f71 100644 --- a/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php +++ b/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php @@ -51,26 +51,17 @@ public function enterNode(Node $node): ?Node return null; } + // A range always holds the `(` and `)` tokens with their text, so + // blanking it is always a change; an already-fixed class yields no range. [$first, $last] = $range; - $hasChanged = false; for ($index = $first; $index <= $last; $index++) { - if ($this->tokens[$index]->text === '') { - continue; - } - $this->tokens[$index]->text = ''; - $hasChanged = true; } // `new class(){}` keeps a space between the keyword and what follows. if (! isset($this->tokens[$last + 1]) || $this->tokens[$last + 1]->id !== T_WHITESPACE) { $this->tokens[$last]->text = ' '; - $hasChanged = true; - } - - if (! $hasChanged) { - return null; } return $node; diff --git a/tests/Analyser/FileAnalysisProviderTest.php b/tests/Analyser/FileAnalysisProviderTest.php index d23d925f..a2efe6fa 100644 --- a/tests/Analyser/FileAnalysisProviderTest.php +++ b/tests/Analyser/FileAnalysisProviderTest.php @@ -12,6 +12,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use function array_column; use function base64_encode; use function file_put_contents; use function sys_get_temp_dir; @@ -112,6 +113,26 @@ public function testReportsInvalidTagsAndInvalidAstWithoutThrowing(): void $this->assertFalse($fileAnalysis->hasSideEffects); } + public function testExposesTokensOfTheFileParsedLast(): void + { + $fileAnalysisProvider = new FileAnalysisProvider(); + + $this->assertIsArray($fileAnalysisProvider->ast($this->source('tokens() as $token) { + $tokenTexts[] = $token->text; + } + + $this->assertContains('class', $tokenTexts); + $this->assertContains('(', $tokenTexts); + + // The next parse replaces them. + $this->assertIsArray($fileAnalysisProvider->ast($this->source('assertNotContains('class', array_column($fileAnalysisProvider->tokens(), 'text')); + } + public function testParsesAstWithoutRetainingItForAnalysis(): void { $fileAnalysisProvider = new FileAnalysisProvider(); From 04d7ab1076ae1bf1a05bbe2432d7745ae8e1d8fc Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 18:38:51 +0700 Subject: [PATCH 093/104] more use case --- .../PhpParser/AnonymousClassParentheses.php | 55 +++++++++++++++---- ...lassMayNotHaveEmptyParenthesesRuleTest.php | 8 ++- .../AnonymousClassParenthesesTest.php | 8 ++- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/src/Util/PhpParser/AnonymousClassParentheses.php b/src/Util/PhpParser/AnonymousClassParentheses.php index 25d6b69e..70625119 100644 --- a/src/Util/PhpParser/AnonymousClassParentheses.php +++ b/src/Util/PhpParser/AnonymousClassParentheses.php @@ -7,7 +7,11 @@ use PhpParser\Node\Stmt\Class_; use PhpParser\Token; +use function end; + use const T_CLASS; +use const T_COMMENT; +use const T_DOC_COMMENT; use const T_WHITESPACE; /** @@ -19,18 +23,24 @@ final class AnonymousClassParentheses { /** - * The index range, from the token after `class` through `)`, of the empty - * parentheses the anonymous class carries; null when it carries none, - * passes an argument, or has a comment inside the range. + * The index range of the empty parentheses the anonymous class carries, + * including the whitespace separating them from what precedes; null when + * it carries none, passes an argument, or has a comment inside them, + * which removing the parentheses would delete. * * @param array $tokens * @return array{int, int}|null */ public static function emptyTokenRange(array $tokens, Class_ $class): ?array { - $index = $class->getStartTokenPos(); + // An attribute argument may hold `Foo::class`, another T_CLASS token, + // so the keyword is searched for after the last attribute group. + $attrGroups = $class->attrGroups; + $index = $attrGroups === [] + ? $class->getStartTokenPos() + : end($attrGroups)->getEndTokenPos() + 1; - // Attributes and modifiers (`new #[Attr] readonly class`) precede the keyword. + // Modifiers (`new readonly class`) still precede the keyword. while (isset($tokens[$index]) && $tokens[$index]->id !== T_CLASS) { $index++; } @@ -39,20 +49,28 @@ public static function emptyTokenRange(array $tokens, Class_ $class): ?array return null; } - $first = $index + 1; - $index = self::skipWhitespace($tokens, $first); + $keyword = $index; + $open = self::skipWhitespaceAndComments($tokens, $keyword + 1); - if (! isset($tokens[$index]) || $tokens[$index]->text !== '(') { + if (! isset($tokens[$open]) || $tokens[$open]->text !== '(') { return null; } - $index = self::skipWhitespace($tokens, $index + 1); + $close = self::skipWhitespace($tokens, $open + 1); - if (! isset($tokens[$index]) || $tokens[$index]->text !== ')') { + if (! isset($tokens[$close]) || $tokens[$close]->text !== ')') { return null; } - return [$first, $index]; + // The whitespace before `(` goes too, back to the keyword or to a + // comment in between, which stays. + $first = $open; + + while ($first - 1 > $keyword && $tokens[$first - 1]->id === T_WHITESPACE) { + $first--; + } + + return [$first, $close]; } /** @param array $tokens */ @@ -64,4 +82,19 @@ private static function skipWhitespace(array $tokens, int $index): int return $index; } + + /** @param array $tokens */ + private static function skipWhitespaceAndComments(array $tokens, int $index): int + { + while ( + isset($tokens[$index]) + && ($tokens[$index]->id === T_WHITESPACE + || $tokens[$index]->id === T_COMMENT + || $tokens[$index]->id === T_DOC_COMMENT) + ) { + $index++; + } + + return $index; + } } diff --git a/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php b/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php index 82a0222a..c05d25e5 100644 --- a/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php +++ b/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php @@ -121,10 +121,11 @@ public function baz(): int }; $b = new class {}; $c = new readonly class(1) {}; - $d = new #[Attr] class ( ) {}; + $d = new #[Attr(Foo::class)] class ( ) {}; $e = new class(){}; $f = [new class () {}, new class {}, new class ( ) {}]; $g = function () { return new class () {}; }; + $h = new class /* comment */ () {}; return $a; } @@ -141,7 +142,7 @@ public function baz(): int ->forRule('source.anonymous_classes'); $this->assertSame( - [11, 25, 26, 27, 27, 28], + [11, 25, 26, 27, 27, 28, 29], array_map(static fn (RuleViolation $ruleViolation): int => $ruleViolation->line, $violations) ); $this->assertTrue($violations[0]->fixable); @@ -180,10 +181,11 @@ public function baz(): int }; $b = new class {}; $c = new readonly class(1) {}; - $d = new #[Attr] class {}; + $d = new #[Attr(Foo::class)] class {}; $e = new class {}; $f = [new class {}, new class {}, new class {}]; $g = function () { return new class {}; }; + $h = new class /* comment */ {}; return $a; } diff --git a/tests/Util/PhpParser/AnonymousClassParenthesesTest.php b/tests/Util/PhpParser/AnonymousClassParenthesesTest.php index 3ac952fd..b4550fa7 100644 --- a/tests/Util/PhpParser/AnonymousClassParenthesesTest.php +++ b/tests/Util/PhpParser/AnonymousClassParenthesesTest.php @@ -53,8 +53,14 @@ public static function anonymousClassProvider(): iterable yield 'newline inside parentheses' => [" [' [' [' [ + ' [' [' [' [" [' Date: Thu, 3 Sep 2026 18:40:50 +0700 Subject: [PATCH 094/104] use existing ->isAnonymous() --- src/Analyser/AnalysisNodeCollector.php | 50 +++++++++---------- ...RemoveAnonymousClassParenthesesVisitor.php | 3 +- .../PhpParser/AnonymousClassParentheses.php | 5 +- 3 files changed, 27 insertions(+), 31 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index 1f1ae362..e2d6ebe8 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -697,32 +697,30 @@ public function leaveNode(Node $node): null array_pop($this->activeClassLikeNames); array_pop($this->functionLikeDepthAtClassLikeEntry); - if (! $node->name instanceof Identifier) { - // Anonymous classes never become ClassNodes, but the class they - // extend, the interfaces they implement, and the traits they use - // are still used within the scanned paths. - if ($node instanceof Class_) { - // Its own (nameless) entry is already popped, so the innermost - // active names are the named scopes declaring it; they also - // resolve its layer, as they do for an anonymous function. - $enclosingClassName = $this->innermostActiveClassLikeName(); - $enclosingFunctionName = $this->activeFunctionNames === [] ? null : end($this->activeFunctionNames); - [$layer, $layers] = $this->resolveLayerData($enclosingClassName ?? $enclosingFunctionName ?? ''); - - $this->anonymousClassNodes[] = new AnonymousClassNode( - file: $this->currentFile, - line: $node->getStartLine(), - extends: $node->extends instanceof Name ? $node->extends->toString() : null, - implements: $this->collectImplements($node), - traits: $this->collectTraits($node), - layer: $layer, - enclosingClassName: $enclosingClassName, - enclosingFunctionName: $enclosingFunctionName, - hasEmptyParentheses: AnonymousClassParentheses::emptyTokenRange($this->currentTokens, $node) - !== null, - layers: $layers, - ); - } + // Anonymous classes never become ClassNodes, but the class they + // extend, the interfaces they implement, and the traits they use + // are still used within the scanned paths. + if ($node instanceof Class_ && $node->isAnonymous()) { + // Its own (nameless) entry is already popped, so the innermost + // active names are the named scopes declaring it; they also + // resolve its layer, as they do for an anonymous function. + $enclosingClassName = $this->innermostActiveClassLikeName(); + $enclosingFunctionName = $this->activeFunctionNames === [] ? null : end($this->activeFunctionNames); + [$layer, $layers] = $this->resolveLayerData($enclosingClassName ?? $enclosingFunctionName ?? ''); + + $this->anonymousClassNodes[] = new AnonymousClassNode( + file: $this->currentFile, + line: $node->getStartLine(), + extends: $node->extends instanceof Name ? $node->extends->toString() : null, + implements: $this->collectImplements($node), + traits: $this->collectTraits($node), + layer: $layer, + enclosingClassName: $enclosingClassName, + enclosingFunctionName: $enclosingFunctionName, + hasEmptyParentheses: AnonymousClassParentheses::emptyTokenRange($this->currentTokens, $node) + !== null, + layers: $layers, + ); return null; } diff --git a/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php b/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php index 6f0e3f71..9ed91bfe 100644 --- a/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php +++ b/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php @@ -7,7 +7,6 @@ use Boundwize\StructArmed\Rule\Fixer\PhpParser\TokenAwareVisitorInterface; use Boundwize\StructArmed\Util\PhpParser\AnonymousClassParentheses; use PhpParser\Node; -use PhpParser\Node\Identifier; use PhpParser\Node\Stmt\Class_; use PhpParser\NodeVisitorAbstract; use PhpParser\Token; @@ -41,7 +40,7 @@ public function setTokens(array $tokens): void public function enterNode(Node $node): ?Node { - if (! $node instanceof Class_ || $node->name instanceof Identifier || $node->getStartLine() !== $this->line) { + if (! $node instanceof Class_ || ! $node->isAnonymous() || $node->getStartLine() !== $this->line) { return null; } diff --git a/src/Util/PhpParser/AnonymousClassParentheses.php b/src/Util/PhpParser/AnonymousClassParentheses.php index 70625119..aa4e1a24 100644 --- a/src/Util/PhpParser/AnonymousClassParentheses.php +++ b/src/Util/PhpParser/AnonymousClassParentheses.php @@ -8,6 +8,7 @@ use PhpParser\Token; use function end; +use function in_array; use const T_CLASS; use const T_COMMENT; @@ -88,9 +89,7 @@ private static function skipWhitespaceAndComments(array $tokens, int $index): in { while ( isset($tokens[$index]) - && ($tokens[$index]->id === T_WHITESPACE - || $tokens[$index]->id === T_COMMENT - || $tokens[$index]->id === T_DOC_COMMENT) + && (in_array($tokens[$index]->id, [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) ) { $index++; } From 7b64e572d8c64ef41b5e11fee83ff95f56cd8942 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 19:23:05 +0700 Subject: [PATCH 095/104] clean up add violations --- src/Analyser/Analyser.php | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 0ce7b46c..c522f462 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -424,32 +424,28 @@ private function evaluateNodeRules( $violations = [$violation]; } + $isFixable = $rule instanceof FixableInterface; foreach ($violations as $violation) { - $ruleViolationCollection->add($this->withRuleKey($violation, $key, $rule)); + $ruleViolationCollection->add(new RuleViolation( + message: $violation->message, + file: $violation->file, + line: $violation->line, + className: $violation->className, + layer: $violation->layer, + ruleKey: $key, + fixable: $isFixable, + methodName: $violation->methodName, + constantName: $violation->constantName, + propertyName: $violation->propertyName, + functionName: $violation->functionName, + numericLiteral: $violation->numericLiteral, + )); } } } } } - private function withRuleKey(RuleViolation $ruleViolation, string $key, object $rule): RuleViolation - { - return new RuleViolation( - message: $ruleViolation->message, - file: $ruleViolation->file, - line: $ruleViolation->line, - className: $ruleViolation->className, - layer: $ruleViolation->layer, - ruleKey: $key, - fixable: $rule instanceof FixableInterface, - methodName: $ruleViolation->methodName, - constantName: $ruleViolation->constantName, - propertyName: $ruleViolation->propertyName, - functionName: $ruleViolation->functionName, - numericLiteral: $ruleViolation->numericLiteral, - ); - } - /** * Expand `+LayerName` references in a ruleset into their concrete allowed layers. * From 7696fcc9194a59fc19589fb1743bd3fd725d33a8 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 19:39:46 +0700 Subject: [PATCH 096/104] docs: Documentation update for example of usage of AnonymousClassRuleInterface --- docs/available-rules.md | 22 +++++++++++- docs/custom-rules-and-presets.md | 59 +++++++++++++++++++++++++++++--- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/docs/available-rules.md b/docs/available-rules.md index 3612cd65..8ab531ee 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -102,7 +102,27 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`. `classNamePattern` and `excludePattern` are regular expressions matched against the fully-qualified class name. -`Psr4DirectoryExistsRule`, `Psr1PhpTagsRule`, `Psr1Utf8WithoutBomRule`, `MustUseLowercaseKeywordConstantRule`, `LargeNumericLiteralMustUseSeparatorRule`, `ExtendedClassMustBeAbstractOrInstantiatedRule`, `MustBeFinalRule`, `MustBeUsedInterfaceRule`, `MustBeUsedAbstractClassRule`, `MustBeUsedTraitRule`, `MustDeclareConstantVisibilityRule`, `MustDeclareMethodVisibilityRule`, and `MustDeclarePropertyVisibilityRule` implement `Boundwize\StructArmed\Rule\FixableInterface`, so StructArmed can automatically remove PSR-4 mappings for missing directories, normalize invalid PHP opening tags, remove UTF-8 byte order marks, lowercase `TRUE`/`FALSE`/`NULL` keyword constants, add `_` separators to large numeric literals, add the `final` or `abstract` class modifier, remove unused interfaces, abstract classes, and traits (deleting their file when only `declare`/`namespace`/`use` boilerplate remains), and add missing constant, method, or property visibility modifiers when you run `vendor/bin/structarmed analyse --fix`. +## Fixable Rules + +The following rules implement `Boundwize\StructArmed\Rule\FixableInterface` and can apply their changes when you run `vendor/bin/structarmed analyse --fix`. + +| Rule | Automatic fix | +|---|---| +| `Psr4DirectoryExistsRule` | Removes PSR-4 mappings for missing directories. | +| `Psr1PhpTagsRule` | Normalizes invalid PHP opening tags. | +| `Psr1Utf8WithoutBomRule` | Removes the UTF-8 byte order mark. | +| `MustUseLowercaseKeywordConstantRule` | Lowercases `TRUE`, `FALSE`, and `NULL` keyword constants. | +| `LargeNumericLiteralMustUseSeparatorRule` | Adds `_` separators to large numeric literals. | +| `AnonymousClassMayNotHaveEmptyParenthesesRule` | Removes empty parentheses from anonymous classes that pass no constructor arguments. | +| `ExtendedClassMustBeAbstractOrInstantiatedRule` | Adds the `abstract` modifier to an extended class that is not instantiated. | +| `MustBeFinalRule` | Adds the `final` modifier. | +| `MustBeUsedInterfaceRule` | Removes an unused interface, deleting its file when only boilerplate remains. | +| `MustBeUsedAbstractClassRule` | Removes an unused abstract class, deleting its file when only boilerplate remains. | +| `MustBeUsedTraitRule` | Removes an unused trait, deleting its file when only boilerplate remains. | +| `MustDeclareConstantVisibilityRule` | Adds a missing constant visibility modifier. | +| `MustDeclareMethodVisibilityRule` | Adds a missing method visibility modifier. | +| `MustDeclarePropertyVisibilityRule` | Adds a missing property visibility modifier. | +{: .rule-table } ## Function Rules diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index 8bc06117..0e2163da 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -177,16 +177,17 @@ The built-in [YAGNI preset](../presets/) rules follow this pattern: `MustBeUsedI Trade-off: only usage within the scanned paths is known. A class-like used solely by a consumer outside the scan — a vendor package, an unscanned directory, runtime-fed dynamic construction — is reported as if unused. Widen the scan, or use `skipRule()` and skip paths where such consumers exist. -## Analysing Functions And Closures +## Analysing Functions, Closures, And Anonymous Classes -Named functions, closures, and arrow functions are collected alongside classes: +Named functions, closures, arrow functions, and anonymous classes are collected alongside named classes: | Node | Represents | Identified by | | --- | --- | --- | | `Boundwize\StructArmed\Analyser\FunctionNode` | A named function declaration (`function foo() {}`), global or namespaced | `$functionName` (fully qualified) | | `Boundwize\StructArmed\Analyser\AnonymousFunctionNode` | A closure (`function () {}`) or arrow function (`fn () => ...`) | `$file` and `$line`, plus `$enclosingClassName` / `$enclosingFunctionName` | +| `Boundwize\StructArmed\Analyser\AnonymousClassNode` | An anonymous class declaration (`new class ... {}`) | `$file` and `$line`, plus `$enclosingClassName` / `$enclosingFunctionName` | -Both carry the body-level facts a `ClassNode` has — `$dependencies`, `$functionCalls`, `$superglobals`, `$languageConstructs`, `$layer` / `$layers` — plus `$paramCount`, `$hasReturnType`, `$cyclomaticComplexity`, and `$lineCount`. The same query helpers are available: `isInLayer()`, `dependsOn()`, `dependsOnNamespace()`, `callsFunction()`, `usesLanguageConstruct()`, and `accessesSuperglobals()`. A `FunctionNode` also has `shortName()`, `nameStartsWith()`, `nameEndsWith()`, and `nameMatches()`; an `AnonymousFunctionNode` has `$isArrowFunction`, `$isStatic`, `getType()`, and `enclosingScopeName()`. +`FunctionNode` and `AnonymousFunctionNode` both carry the body-level facts a `ClassNode` has — `$dependencies`, `$functionCalls`, `$superglobals`, `$languageConstructs`, `$layer` / `$layers` — plus `$paramCount`, `$hasReturnType`, `$cyclomaticComplexity`, and `$lineCount`. The same query helpers are available: `isInLayer()`, `dependsOn()`, `dependsOnNamespace()`, `callsFunction()`, `usesLanguageConstruct()`, and `accessesSuperglobals()`. A `FunctionNode` also has `shortName()`, `nameStartsWith()`, `nameEndsWith()`, and `nameMatches()`; an `AnonymousFunctionNode` has `$isArrowFunction`, `$isStatic`, `getType()`, and `enclosingScopeName()`. A closure declared inside a class or a named function is counted on both nodes: the enclosing `ClassNode` (or `FunctionNode`) keeps seeing everything the closure does, exactly as it sees its own method bodies, and the `AnonymousFunctionNode` reports the closure body on its own. @@ -279,7 +280,57 @@ final readonly class ClosuresMustNotAccessSuperglobalsRule implements AnonymousF } ``` -One rule class can also implement several of these interfaces at once; PHP then requires the shared methods to widen the parameter to a union type (for example `appliesTo(FunctionNode|AnonymousFunctionNode $node): bool`) and the rule branches on the node type inside. +An anonymous-class rule receives an `AnonymousClassNode` for every anonymous class in the scanned paths. For example, this rule requires anonymous classes in one layer to implement a project-specific marker interface: + +```php +isInLayer($this->layer); + } + + public function evaluate(AnonymousClassNode $anonymousClassNode): ?RuleViolation + { + if (in_array($this->interface, $anonymousClassNode->implements, true)) { + return null; + } + + return new RuleViolation( + message: sprintf( + 'Anonymous class in [%s] must implement [%s]', + $anonymousClassNode->enclosingScopeName(), + $this->interface, + ), + file: $anonymousClassNode->file, + line: $anonymousClassNode->line, + className: $anonymousClassNode->enclosingScopeName(), + layer: $anonymousClassNode->layer, + ); + } +} +``` + +Register it through `Architecture::rule()` like any other custom rule. The analyser invokes it only for anonymous classes because it implements `AnonymousClassRuleInterface`. + +One rule class can also implement several of these interfaces at once; PHP then requires the shared methods to widen the parameter to a union type (for example `appliesTo(FunctionNode|AnonymousFunctionNode|AnonymousClassNode $node): bool`) and the rule branches on the node type inside. `RuleViolation::$className` is required, so a function rule passes the function name there (and, optionally, in the dedicated `functionName` field, which the JSON report emits as `"function"`); an anonymous-function or anonymous-class rule passes `enclosingScopeName()`, which is the enclosing class-like or named function, or `FILE_SCOPE` (`'file scope'`) for one in top-level procedural code. From a4d9ab8ffb1f943aa9845b85fa4112cfc0f3fd21 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 21:09:41 +0700 Subject: [PATCH 097/104] allow recursive parents/interfaces on AnonymousClassNode --- docs/custom-rules-and-presets.md | 7 +- src/Analyser/Analyser.php | 91 ++++++++++++++++++----- src/Analyser/AnonymousClassNode.php | 39 +++++----- src/Analyser/ClassNode.php | 39 +--------- src/Analyser/LayerQueryTrait.php | 24 ++++++ src/Analyser/NodeQueryTrait.php | 6 +- src/Analyser/RecursiveParentsTrait.php | 68 +++++++++++++++++ tests/Analyser/AnalyserTest.php | 68 +++++++++++++++++ tests/Analyser/AnonymousClassNodeTest.php | 49 ++++++++++++ 9 files changed, 310 insertions(+), 81 deletions(-) create mode 100644 src/Analyser/LayerQueryTrait.php create mode 100644 src/Analyser/RecursiveParentsTrait.php create mode 100644 tests/Analyser/AnonymousClassNodeTest.php diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index 0e2163da..824b9883 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -191,7 +191,7 @@ Named functions, closures, arrow functions, and anonymous classes are collected A closure declared inside a class or a named function is counted on both nodes: the enclosing `ClassNode` (or `FunctionNode`) keeps seeing everything the closure does, exactly as it sees its own method bodies, and the `AnonymousFunctionNode` reports the closure body on its own. -Anonymous classes (`new class ... {}`) are collected the same way, as `Boundwize\StructArmed\Analyser\AnonymousClassNode`: identified by `$file` and `$line` plus `$enclosingClassName` / `$enclosingFunctionName` (with `enclosingScopeName()` and `AnonymousClassNode::FILE_SCOPE`), and carrying `$extends`, `$implements`, `$traits`, `$layer` / `$layers` with `isInLayer()`, and `$hasEmptyParentheses` — whether the declaration spells `new class () {}` although it passes no constructor argument. An anonymous class never becomes a `ClassNode`; the named class-like or function declaring it keeps seeing its body, exactly as it sees a closure's. +Anonymous classes (`new class ... {}`) are collected the same way, as `Boundwize\StructArmed\Analyser\AnonymousClassNode`: identified by `$file` and `$line` plus `$enclosingClassName` / `$enclosingFunctionName` (with `enclosingScopeName()` and `AnonymousClassNode::FILE_SCOPE`), and carrying `$extends`, `$implements`, `$traits`, `$layer` / `$layers` with `isInLayer()`, and `$hasEmptyParentheses` — whether the declaration spells `new class () {}` although it passes no constructor argument. Its parent chain is resolved like a named class's: `$parentClasses` and `$parentInterfaces` hold the direct and transitive parents found in the scanned paths, and `extendsClass()` / `implementsInterface()` answer case-insensitively through that chain, exactly as on a `ClassNode`. An anonymous class never becomes a `ClassNode`; the named class-like or function declaring it keeps seeing its body, exactly as it sees a closure's. Rules opt in to these nodes by implementing `Boundwize\StructArmed\Rule\FunctionRuleInterface`, `Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface`, and/or `Boundwize\StructArmed\Rule\AnonymousClassRuleInterface`. All share the `appliesTo()` / `evaluate()` method names with `RuleInterface`, each typed against its own node kind. Global skip paths, rule-scoped `skip()` paths, and `skipRule()` apply the same way. Function-likes and anonymous classes are not part of the declarative `ruleset()` layer-dependency check. @@ -280,7 +280,7 @@ final readonly class ClosuresMustNotAccessSuperglobalsRule implements AnonymousF } ``` -An anonymous-class rule receives an `AnonymousClassNode` for every anonymous class in the scanned paths. For example, this rule requires anonymous classes in one layer to implement a project-specific marker interface: +An anonymous-class rule receives an `AnonymousClassNode` for every anonymous class in the scanned paths. For example, this rule requires anonymous classes in one layer to implement a project-specific marker interface, directly or through the class they extend (`implementsInterface()` walks the resolved parent chain, so `new class extends BaseHandler {}` passes when `BaseHandler` implements the interface): ```php interface, $anonymousClassNode->implements, true)) { + if ($anonymousClassNode->implementsInterface($this->interface)) { return null; } diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index c522f462..2bbaa340 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -180,7 +180,7 @@ public function analyse( $withFileAnalysis, ); $classNodes = $extractionResult->classNodes; - $classNodes = $this->withRecursiveParents($classNodes); + $classNodes = $this->withRecursiveParents($classNodes, $extractionResult->anonymousClassNodes); if ($hasExtendedClassAwareRule || $hasUsedInterfaceAwareRule || $hasUsedTraitAwareRule) { $this->markClassLikeUsage( @@ -1051,10 +1051,11 @@ private function collectTraitUsers( } /** - * @param list $classNodes + * @param list $classNodes + * @param list $anonymousClassNodes * @return list */ - private function withRecursiveParents(array $classNodes): array + private function withRecursiveParents(array $classNodes, array $anonymousClassNodes): array { $parentClassMap = []; $parentInterfaceMap = []; @@ -1093,13 +1094,32 @@ private function withRecursiveParents(array $classNodes): array $classNode->setRecursiveParents($result['classes'], $result['interfaces']); } + // An anonymous class is never a parent, so it is absent from the maps and + // starts the DFS from its own `extends`/`implements` clauses instead. + foreach ($anonymousClassNodes as $anonymousClassNode) { + if ($anonymousClassNode->extends === null && $anonymousClassNode->implements === []) { + continue; + } + + $cycleDetected = false; + $result = $this->collectRecursiveParents( + $anonymousClassNode->extends !== null ? [$anonymousClassNode->extends] : [], + $anonymousClassNode->implements, + $parentClassMap, + $parentInterfaceMap, + $parentsCache, + [], + $cycleDetected + ); + + $anonymousClassNode->setRecursiveParents($result['classes'], $result['interfaces']); + } + return $classNodes; } /** - * Single DFS that collects both ancestor classes and transitively implemented/extended - * interfaces in one pass, avoiding the double traversal of the parent-class chain that - * the previous two-method approach required. + * Cached, name-keyed entry point to the parent-chain DFS for a scanned class-like. * * @param array> $parentClassMap * @param array> $parentInterfaceMap @@ -1119,11 +1139,54 @@ private function recursiveParents( return $cache[$classNameKey]; } + $hasCycle = false; + $result = $this->collectRecursiveParents( + $parentClassMap[$classNameKey] ?? [], + $parentInterfaceMap[$classNameKey] ?? [], + $parentClassMap, + $parentInterfaceMap, + $cache, + $seen, + $hasCycle + ); + + if (! $hasCycle) { + $cache[$classNameKey] = $result; + } + + $cycleDetected = $cycleDetected || $hasCycle; + + return $result; + } + + /** + * Single DFS that collects both ancestor classes and transitively implemented/extended + * interfaces in one pass, avoiding the double traversal of the parent-class chain that + * the previous two-method approach required. Seeded with a node's direct parents so an + * anonymous class, which has no name to look up in the maps, resolves its chain the + * same way a named class does. + * + * @param string[] $parentClasses + * @param string[] $parentInterfaces + * @param array> $parentClassMap + * @param array> $parentInterfaceMap + * @param array, interfaces: list}> $cache + * @param array $seen + * @return array{classes: list, interfaces: list} + */ + private function collectRecursiveParents( + array $parentClasses, + array $parentInterfaces, + array $parentClassMap, + array $parentInterfaceMap, + array &$cache, + array $seen, + bool &$hasCycle + ): array { $classesSet = []; $interfacesSet = []; - $hasCycle = false; - foreach ($parentClassMap[$classNameKey] ?? [] as $parentClass) { + foreach ($parentClasses as $parentClass) { $parentClassKey = strtolower($parentClass); if (isset($seen[$parentClassKey])) { @@ -1153,7 +1216,7 @@ private function recursiveParents( $hasCycle = $hasCycle || $childHasCycle; } - foreach ($parentInterfaceMap[$classNameKey] ?? [] as $parentInterface) { + foreach ($parentInterfaces as $parentInterface) { $parentInterfaceKey = strtolower($parentInterface); if (isset($seen[$parentInterfaceKey])) { @@ -1179,18 +1242,10 @@ private function recursiveParents( $hasCycle = $hasCycle || $childHasCycle; } - $result = [ + return [ 'classes' => array_keys($classesSet), 'interfaces' => array_keys($interfacesSet), ]; - - if (! $hasCycle) { - $cache[$classNameKey] = $result; - } - - $cycleDetected = $cycleDetected || $hasCycle; - - return $result; } /** diff --git a/src/Analyser/AnonymousClassNode.php b/src/Analyser/AnonymousClassNode.php index 3e2728ac..d5a390c6 100644 --- a/src/Analyser/AnonymousClassNode.php +++ b/src/Analyser/AnonymousClassNode.php @@ -5,7 +5,6 @@ namespace Boundwize\StructArmed\Analyser; use function array_filter; -use function in_array; /** * An anonymous class declaration (`new class ... {}`). Anonymous classes never @@ -17,9 +16,16 @@ * The class it extends, the interfaces it implements, and the traits it uses * are still used within the scanned paths, which usage-aware rules must take * into account: MustBeFinalRule must skip a class extended by an anonymous class. + * + * Its parent chain is resolved by the analyser like a named class's, so + * {@see extendsClass()} and {@see implementsInterface()} see transitive + * parents too. */ -final readonly class AnonymousClassNode +final class AnonymousClassNode { + use LayerQueryTrait; + use RecursiveParentsTrait; + /** * Scope label reported by {@see enclosingScopeName()} for an anonymous * class declared outside any class-like or named function. @@ -27,7 +33,7 @@ public const FILE_SCOPE = 'file scope'; /** @var list */ - public array $layers; + public readonly array $layers; /** * @param string[] $implements Interface names this anonymous class implements @@ -37,27 +43,26 @@ * @param bool $hasEmptyParentheses Whether `()` follows `class` although no constructor argument * is passed: `new class () {}` rather than `new class {}` * @param list $layers Layer names this anonymous class belongs to; defaults to [$layer] + * @param list $parentClasses Direct and transitive parent class names + * @param list $parentInterfaces Direct and transitive implemented interface names */ public function __construct( - public string $file, - public int $line, - public ?string $extends, - public array $implements = [], - public array $traits = [], - public ?string $layer = null, - public ?string $enclosingClassName = null, - public ?string $enclosingFunctionName = null, - public bool $hasEmptyParentheses = false, + public readonly string $file, + public readonly int $line, + public readonly ?string $extends, + public readonly array $implements = [], + public readonly array $traits = [], + public readonly ?string $layer = null, + public readonly ?string $enclosingClassName = null, + public readonly ?string $enclosingFunctionName = null, + public readonly bool $hasEmptyParentheses = false, array $layers = [], + public array $parentClasses = [], + public array $parentInterfaces = [], ) { $this->layers = $layers ?: array_filter([$this->layer]); } - public function isInLayer(string $layer): bool - { - return in_array($layer, $this->layers, true); - } - /** * Label of the innermost named scope declaring this anonymous class — * the enclosing class-like, else the enclosing named function — or diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index f8aee928..0e504276 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -8,13 +8,13 @@ use function preg_match; use function str_ends_with; use function str_starts_with; -use function strcasecmp; use function strrpos; use function substr; final class ClassNode { use NodeQueryTrait; + use RecursiveParentsTrait; /** @var list */ public readonly array $layers; @@ -93,16 +93,6 @@ public function getType(): string return 'Class'; } - /** - * @param list $parentClasses - * @param list $parentInterfaces - */ - public function setRecursiveParents(array $parentClasses, array $parentInterfaces): void - { - $this->parentClasses = $parentClasses; - $this->parentInterfaces = $parentInterfaces; - } - /** * Whether another scanned class extends this class. Computed by the analyser * for rules implementing ExtendedClassAwareRuleInterface; false otherwise. @@ -176,6 +166,7 @@ public function nameMatches(string $pattern, bool $isFullName = false): bool /** * Implemented directly, extended directly (for interfaces), or via any parent class or interface. + * Overrides the trait method to also look at `$interfaceExtends`, which only a named interface has. */ public function implementsInterface(string $interface): bool { @@ -184,32 +175,6 @@ public function implementsInterface(string $interface): bool || $this->matchesAnyClassLike($interface, $this->parentInterfaces); } - public function extendsClass(string $class): bool - { - if ($this->extends !== null && strcasecmp($this->extends, $class) === 0) { - return true; - } - - return $this->matchesAnyClassLike($class, $this->parentClasses); - } - - /** - * Class-like names are case-insensitive in PHP. This matching is kept - * separate from dependencies, which may also contain constants. - * - * @param string[] $classLikes - */ - private function matchesAnyClassLike(string $needle, array $classLikes): bool - { - foreach ($classLikes as $classLike) { - if (strcasecmp($classLike, $needle) === 0) { - return true; - } - } - - return false; - } - public function constructorParamCount(): int { foreach ($this->methods as $method) { diff --git a/src/Analyser/LayerQueryTrait.php b/src/Analyser/LayerQueryTrait.php new file mode 100644 index 00000000..e36bad37 --- /dev/null +++ b/src/Analyser/LayerQueryTrait.php @@ -0,0 +1,24 @@ + $layers All layer names this node belongs to; assigned once in each node's constructor + */ +trait LayerQueryTrait +{ + public function isInLayer(string $layer): bool + { + return in_array($layer, $this->layers, true); + } +} diff --git a/src/Analyser/NodeQueryTrait.php b/src/Analyser/NodeQueryTrait.php index 4c58608a..2e0c7d04 100644 --- a/src/Analyser/NodeQueryTrait.php +++ b/src/Analyser/NodeQueryTrait.php @@ -18,7 +18,6 @@ * * @internal * - * @property list $layers All layer names this node belongs to; assigned once in each node's constructor * @property-read list $dependencies Fully-qualified class, function, or constant dependencies * @property-read string[] $functionCalls Functions called within this node * @property-read string[] $superglobals Superglobals accessed ($_GET, $_POST, etc.) @@ -26,10 +25,7 @@ */ trait NodeQueryTrait { - public function isInLayer(string $layer): bool - { - return in_array($layer, $this->layers, true); - } + use LayerQueryTrait; public function dependsOn(string $class): bool { diff --git a/src/Analyser/RecursiveParentsTrait.php b/src/Analyser/RecursiveParentsTrait.php new file mode 100644 index 00000000..4ddab395 --- /dev/null +++ b/src/Analyser/RecursiveParentsTrait.php @@ -0,0 +1,68 @@ + $parentClasses Direct and transitive parent class names + * @property list $parentInterfaces Direct and transitive implemented or extended interface names + */ +trait RecursiveParentsTrait +{ + /** + * @param list $parentClasses + * @param list $parentInterfaces + */ + public function setRecursiveParents(array $parentClasses, array $parentInterfaces): void + { + $this->parentClasses = $parentClasses; + $this->parentInterfaces = $parentInterfaces; + } + + /** + * Implemented directly, or via any parent class or interface. + */ + public function implementsInterface(string $interface): bool + { + return $this->matchesAnyClassLike($interface, $this->implements) + || $this->matchesAnyClassLike($interface, $this->parentInterfaces); + } + + public function extendsClass(string $class): bool + { + if ($this->extends !== null && strcasecmp($this->extends, $class) === 0) { + return true; + } + + return $this->matchesAnyClassLike($class, $this->parentClasses); + } + + /** + * Class-like names are case-insensitive in PHP. This matching is kept + * separate from dependencies, which may also contain constants. + * + * @param string[] $classLikes + */ + private function matchesAnyClassLike(string $needle, array $classLikes): bool + { + foreach ($classLikes as $classLike) { + if (strcasecmp($classLike, $needle) === 0) { + return true; + } + } + + return false; + } +} diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index ed05d360..1c7f6d89 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -6,6 +6,7 @@ use Boundwize\StructArmed\Analyser\Analyser; use Boundwize\StructArmed\Analyser\AnalyserOptions; +use Boundwize\StructArmed\Analyser\AnonymousClassNode; use Boundwize\StructArmed\Analyser\AnonymousFunctionNode; use Boundwize\StructArmed\Analyser\FileAnalysisProvider; use Boundwize\StructArmed\Analyser\FunctionNode; @@ -24,6 +25,7 @@ use Boundwize\StructArmed\Preset\Presets\Psr4Preset; use Boundwize\StructArmed\Preset\Presets\YagniPreset; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; +use Boundwize\StructArmed\Rule\AnonymousClassRuleInterface; use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface; use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface; use Boundwize\StructArmed\Rule\FunctionRuleInterface; @@ -55,6 +57,7 @@ use function realpath; use function rename; use function sort; +use function sprintf; use function str_replace; use function symlink; use function unlink; @@ -353,6 +356,71 @@ public function testAnonymousClassRulesAreEvaluatedAgainstAnonymousClasses(): vo } } + public function testAnonymousClassNodesResolveRecursiveParents(): void + { + $basePath = $this->makeTempProject([ + 'src/Contract.php' => ' ' ' <<<'PHP' + extendsClass('App\\RootHandler') + && $anonymousClassNode->implementsInterface('App\\Contract') + ) { + return null; + } + + return new RuleViolation( + message: sprintf( + 'Anonymous class in [%s] must implement [App\\Contract]', + $anonymousClassNode->enclosingScopeName() + ), + file: $anonymousClassNode->file, + line: $anonymousClassNode->line, + className: $anonymousClassNode->enclosingScopeName(), + layer: $anonymousClassNode->layer, + ); + } + }; + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('anonymous_classes.contract', $rule); + + foreach ([AnalyserOptions::sequential(), AnalyserOptions::parallel(2)] as $analyserOptions) { + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, $analyserOptions) + ->forRule('anonymous_classes.contract'); + + // Only the anonymous class with no parent chain is flagged: the one + // extending BaseHandler reaches RootHandler and Contract transitively. + $this->assertCount(1, $violations); + $this->assertSame(5, $violations[0]->line); + $this->assertSame( + 'Anonymous class in [App\\Factory] must implement [App\\Contract]', + $violations[0]->message + ); + } + } + public function testAnonymousClassRuleViolationsSurviveTheAnalysisNodeCache(): void { $basePath = $this->makeTempProject($this->anonymousClassRuleProjectFiles()); diff --git a/tests/Analyser/AnonymousClassNodeTest.php b/tests/Analyser/AnonymousClassNodeTest.php new file mode 100644 index 00000000..b6456c22 --- /dev/null +++ b/tests/Analyser/AnonymousClassNodeTest.php @@ -0,0 +1,49 @@ +assertSame([], $anonymousClassNode->parentClasses); + $this->assertSame([], $anonymousClassNode->parentInterfaces); + $this->assertTrue($anonymousClassNode->extendsClass('App\\Support\\BaseClass')); + $this->assertFalse($anonymousClassNode->extendsClass('App\\Support\\RootClass')); + $this->assertTrue($anonymousClassNode->implementsInterface('App\\Contracts\\FooInterface')); + $this->assertFalse($anonymousClassNode->implementsInterface('App\\Contracts\\RootInterface')); + + $anonymousClassNode->setRecursiveParents( + ['App\\Support\\baseclass', 'App\\Support\\rootclass'], + ['App\\Contracts\\foointerface', 'App\\Contracts\\rootinterface'], + ); + + $this->assertSame(['App\\Support\\baseclass', 'App\\Support\\rootclass'], $anonymousClassNode->parentClasses); + $this->assertTrue($anonymousClassNode->extendsClass('App\\Support\\RootClass')); + $this->assertFalse($anonymousClassNode->extendsClass('App\\Support\\OtherClass')); + $this->assertTrue($anonymousClassNode->implementsInterface('App\\Contracts\\RootInterface')); + $this->assertFalse($anonymousClassNode->implementsInterface('App\\Contracts\\OtherInterface')); + } + + public function testExtendsClassWithoutParentIsAlwaysFalse(): void + { + $anonymousClassNode = new AnonymousClassNode(file: '/src/helpers.php', line: 3, extends: null); + + $this->assertFalse($anonymousClassNode->extendsClass('App\\Support\\BaseClass')); + $this->assertFalse($anonymousClassNode->implementsInterface('App\\Contracts\\FooInterface')); + } +} From 95296148eeb1fd360a4d8cfe0927785bde59d535 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 4 Sep 2026 06:33:50 +0700 Subject: [PATCH 098/104] Sync AnonymousClassNode with ClassNode: members, body deps, and isReadonly --- docs/custom-rules-and-presets.md | 2 +- src/Analyser/Analyser.php | 84 ++++++---- src/Analyser/AnalysisNodeCollector.php | 130 ++++++++------- src/Analyser/AnonymousClassNode.php | 43 +++-- src/Analyser/ClassLikeAnalysis.php | 2 +- src/Analyser/ClassNode.php | 12 +- src/Analyser/LayerQueryTrait.php | 24 --- src/Analyser/MemberQueryTrait.php | 29 ++++ src/Analyser/NodeQueryTrait.php | 16 +- src/Cache/AnalysisResultCache.php | 167 ++++++++++--------- tests/Analyser/AnalysisNodeCollectorTest.php | 79 +++++++++ tests/Analyser/AnonymousClassNodeTest.php | 47 ++++++ tests/Cache/AnalysisResultCacheTest.php | 20 +++ 13 files changed, 434 insertions(+), 221 deletions(-) delete mode 100644 src/Analyser/LayerQueryTrait.php create mode 100644 src/Analyser/MemberQueryTrait.php diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index 824b9883..fe7de6b4 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -191,7 +191,7 @@ Named functions, closures, arrow functions, and anonymous classes are collected A closure declared inside a class or a named function is counted on both nodes: the enclosing `ClassNode` (or `FunctionNode`) keeps seeing everything the closure does, exactly as it sees its own method bodies, and the `AnonymousFunctionNode` reports the closure body on its own. -Anonymous classes (`new class ... {}`) are collected the same way, as `Boundwize\StructArmed\Analyser\AnonymousClassNode`: identified by `$file` and `$line` plus `$enclosingClassName` / `$enclosingFunctionName` (with `enclosingScopeName()` and `AnonymousClassNode::FILE_SCOPE`), and carrying `$extends`, `$implements`, `$traits`, `$layer` / `$layers` with `isInLayer()`, and `$hasEmptyParentheses` — whether the declaration spells `new class () {}` although it passes no constructor argument. Its parent chain is resolved like a named class's: `$parentClasses` and `$parentInterfaces` hold the direct and transitive parents found in the scanned paths, and `extendsClass()` / `implementsInterface()` answer case-insensitively through that chain, exactly as on a `ClassNode`. An anonymous class never becomes a `ClassNode`; the named class-like or function declaring it keeps seeing its body, exactly as it sees a closure's. +Anonymous classes (`new class ... {}`) are collected the same way, as `Boundwize\StructArmed\Analyser\AnonymousClassNode`: identified by `$file` and `$line` plus `$enclosingClassName` / `$enclosingFunctionName` (with `enclosingScopeName()` and `AnonymousClassNode::FILE_SCOPE`), and carrying `$extends`, `$implements`, `$traits`, `$isReadonly`, `$layer` / `$layers`, and `$hasEmptyParentheses` — whether the declaration spells `new class () {}` although it passes no constructor argument. It carries the same body-level facts and query helpers as a `ClassNode` — `$dependencies`, `$functionCalls`, `$superglobals`, and `$languageConstructs`, with `isInLayer()`, `dependsOn()`, `dependsOnNamespace()`, `callsFunction()`, `usesLanguageConstruct()`, and `accessesSuperglobals()` — and its own members: `$methods`, `$constants`, `$properties`, and `constructorParamCount()`. Its parent chain is resolved like a named class's: `$parentClasses` and `$parentInterfaces` hold the direct and transitive parents found in the scanned paths, and `extendsClass()` / `implementsInterface()` answer case-insensitively through that chain, exactly as on a `ClassNode`. An anonymous class never becomes a `ClassNode`; the named class-like or function declaring it keeps seeing its body, exactly as it sees a closure's, while the members belong to the anonymous class alone. Rules opt in to these nodes by implementing `Boundwize\StructArmed\Rule\FunctionRuleInterface`, `Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface`, and/or `Boundwize\StructArmed\Rule\AnonymousClassRuleInterface`. All share the `appliesTo()` / `evaluate()` method names with `RuleInterface`, each typed against its own node kind. Global skip paths, rule-scoped `skip()` paths, and `skipRule()` apply the same way. Function-likes and anonymous classes are not part of the declarative `ruleset()` layer-dependency check. diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 2bbaa340..c71bb41a 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -761,6 +761,38 @@ private function dependenciesForInheritanceDependency( return $resolvedDependencies; } + /** + * A node's own inheritance-clause names (and the imports that exist for + * them) are structural relations, not value references. Excluding them + * keeps "referenced" meaningful for the unresolved dynamic instantiation + * check: a class extended by a child is not thereby a possible + * `new $class` target. The usage-aware deletion rules are unaffected — + * each combines this flag with its structural extended/implemented/trait + * marking. + * + * @param list $dependencies + * @param array $clauseNames The node's own name, if any, and its extends, implements, and traits + * @param array $used + */ + private function markDependenciesUsed(array $dependencies, array $clauseNames, array &$used): void + { + $excludedKeys = []; + + foreach ($clauseNames as $clauseName) { + if ($clauseName !== null) { + $excludedKeys[strtolower($clauseName)] = true; + } + } + + foreach ($dependencies as $dependency) { + $dependencyKey = strtolower($dependency); + + if (! isset($excludedKeys[$dependencyKey])) { + $used[$dependencyKey] = true; + } + } + } + /** * Collect and apply class-like usage flags with one collection pass and one * application pass over the class nodes. Extended classes use the recursive @@ -796,36 +828,22 @@ private function markClassLikeUsage( $used[strtolower($trait)] = true; } - // A node's own inheritance-clause names (and the imports that - // exist for them) are structural relations, not value references. - // Excluding them keeps "referenced" meaningful for the unresolved - // dynamic instantiation check below: a class extended by a child - // is not thereby a possible `new $class` target. The usage-aware - // deletion rules are unaffected — each combines this flag with its - // structural extended/implemented/trait marking. - $excludedKeys = [strtolower($classNode->className) => true]; - - if ($classNode->extends !== null) { - $excludedKeys[strtolower($classNode->extends)] = true; - } - - foreach ([$classNode->implements, $classNode->interfaceExtends, $classNode->traits] as $clauseNames) { - foreach ($clauseNames as $clauseName) { - $excludedKeys[strtolower($clauseName)] = true; - } - } - - foreach ($classNode->dependencies as $dependency) { - $dependencyKey = strtolower($dependency); - - if (! isset($excludedKeys[$dependencyKey])) { - $used[$dependencyKey] = true; - } - } + $this->markDependenciesUsed( + $classNode->dependencies, + [ + $classNode->className, + $classNode->extends, + ...$classNode->implements, + ...$classNode->interfaceExtends, + ...$classNode->traits, + ], + $used, + ); } // Anonymous classes have no ClassNode of their own, so their inheritance - // and trait-use relationships are tracked separately. + // and trait-use relationships, and their body references, are tracked + // separately. foreach ($extractionResult->anonymousClassNodes as $anonymousClassNode) { if ($markExtended && $anonymousClassNode->extends !== null) { $extended[strtolower($anonymousClassNode->extends)] = true; @@ -840,11 +858,17 @@ private function markClassLikeUsage( foreach ($anonymousClassNode->traits as $trait) { $used[strtolower($trait)] = true; } + + $this->markDependenciesUsed( + $anonymousClassNode->dependencies, + [$anonymousClassNode->extends, ...$anonymousClassNode->implements, ...$anonymousClassNode->traits], + $used, + ); } - // References made outside any named class-like scope — procedural - // functions, top-level statements, top-level anonymous class bodies — - // have no ClassNode either, so they are tracked per file. + // References made outside any class-like scope — procedural functions + // and top-level statements — have no ClassNode either, so they are + // tracked per file. foreach ($extractionResult->fileReferences as $references) { foreach ($references as $reference) { $used[strtolower($reference)] = true; diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index e2d6ebe8..bc5c0421 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -325,6 +325,15 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract /** @var ClassLike[] */ private array $fileClassLikes = []; + /** + * The named scopes declaring each anonymous class left in the current + * file — innermost class-like name, innermost function name — keyed by + * the class node's object id and read once its node is built. + * + * @var array + */ + private array $anonymousClassEnclosingNames = []; + /** @var array */ private array $fileFunctions = []; @@ -408,6 +417,7 @@ public function setCurrentFile(string $file, array $tokens = []): void $this->numericLiterals = []; $this->currentNamespaceUses = []; $this->fileClassLikes = []; + $this->anonymousClassEnclosingNames = []; $this->fileFunctions = []; $this->classLikeAnalysis = []; $this->activeClassLikeAnalyses = []; @@ -444,9 +454,8 @@ public function getAnonymousClassNodes(): array } /** - * References to class-likes made outside any named class-like scope, per - * file — procedural functions, top-level statements, and top-level - * anonymous class bodies. + * References to class-likes made outside any class-like scope, per file — + * procedural functions and top-level statements. * * @return array> */ @@ -597,10 +606,7 @@ public function enterNode(Node $node): null $this->activeClassLikeScopes[] = $this->createClassLikeScope($node, $classLikeName); $this->activeClassLikeNames[] = $classLikeName; $this->functionLikeDepthAtClassLikeEntry[] = count($this->activeFunctionLikeAnalyses); - - if ($classLikeName !== null) { - $this->startClassLikeAnalysis($node); - } + $this->startClassLikeAnalysis($node); return null; } @@ -699,30 +705,16 @@ public function leaveNode(Node $node): null // Anonymous classes never become ClassNodes, but the class they // extend, the interfaces they implement, and the traits they use - // are still used within the scanned paths. + // are still used within the scanned paths, and their members and + // body facts are collected like a named class's. if ($node instanceof Class_ && $node->isAnonymous()) { // Its own (nameless) entry is already popped, so the innermost // active names are the named scopes declaring it; they also // resolve its layer, as they do for an anonymous function. - $enclosingClassName = $this->innermostActiveClassLikeName(); - $enclosingFunctionName = $this->activeFunctionNames === [] ? null : end($this->activeFunctionNames); - [$layer, $layers] = $this->resolveLayerData($enclosingClassName ?? $enclosingFunctionName ?? ''); - - $this->anonymousClassNodes[] = new AnonymousClassNode( - file: $this->currentFile, - line: $node->getStartLine(), - extends: $node->extends instanceof Name ? $node->extends->toString() : null, - implements: $this->collectImplements($node), - traits: $this->collectTraits($node), - layer: $layer, - enclosingClassName: $enclosingClassName, - enclosingFunctionName: $enclosingFunctionName, - hasEmptyParentheses: AnonymousClassParentheses::emptyTokenRange($this->currentTokens, $node) - !== null, - layers: $layers, - ); - - return null; + $this->anonymousClassEnclosingNames[spl_object_id($node)] = [ + $this->innermostActiveClassLikeName(), + $this->activeFunctionNames === [] ? null : end($this->activeFunctionNames), + ]; } $this->fileClassLikes[] = $node; @@ -735,7 +727,11 @@ traits: $this->collectTraits($node), public function afterTraverse(array $nodes): null { foreach ($this->fileClassLikes as $fileClassLike) { - $this->collectClassLike($fileClassLike); + if ($fileClassLike instanceof Class_ && $fileClassLike->isAnonymous()) { + $this->collectAnonymousClass($fileClassLike); + } else { + $this->collectClassLike($fileClassLike); + } } foreach ($this->fileFunctionLikeAnalyses as $fileFunctionLikeAnalysis) { @@ -753,6 +749,7 @@ public function afterTraverse(array $nodes): null } $this->fileClassLikes = []; + $this->anonymousClassEnclosingNames = []; $this->classLikeAnalysis = []; $this->activeClassLikeAnalyses = []; $this->activeClassLikeScopes = []; @@ -766,12 +763,19 @@ public function afterTraverse(array $nodes): null return null; } + /** + * A named class-like seeds its dependencies with the namespace imports. + * An anonymous class does not: like a function-like's, its file's imports + * belong to the file (and the named class-like declaring it), not to it. + */ private function startClassLikeAnalysis(ClassLike $classLike): void { $classLikeId = spl_object_id($classLike); $classLikeAnalysis = new ClassLikeAnalysis($classLike instanceof Interface_); - $classLikeAnalysis->dependencies = $this->currentNamespaceUses; + if ($classLike->name instanceof Identifier) { + $classLikeAnalysis->dependencies = $this->currentNamespaceUses; + } $this->classLikeAnalysis[$classLikeId] = $classLikeAnalysis; $this->activeClassLikeAnalyses[] = $classLikeAnalysis; @@ -779,15 +783,10 @@ private function startClassLikeAnalysis(ClassLike $classLike): void /** * The analysis of the class-like declaring the member being entered: the - * innermost active class-like. An anonymous class (null name) starts no - * analysis, so its members are not collected. + * innermost active class-like, named or anonymous. */ private function declaringClassLikeAnalysis(): ?ClassLikeAnalysis { - if (end($this->activeClassLikeNames) === null) { - return null; - } - $analysis = end($this->activeClassLikeAnalyses); return $analysis instanceof ClassLikeAnalysis ? $analysis : null; @@ -1022,10 +1021,9 @@ private function collectNodeAnalysis(Node $node): void } if ($this->activeClassLikeAnalyses === []) { - // Outside any named class-like scope — procedural functions, - // top-level statements, top-level anonymous class bodies — a - // class-like reference still keeps the referenced class-like - // alive. + // Outside any class-like scope — procedural functions and + // top-level statements — a class-like reference still keeps + // the referenced class-like alive. $this->currentFileReferences[$name] = true; } @@ -1444,6 +1442,37 @@ enumBackingType: $classLike instanceof Enum_ && $classLike->scalarType instan ); } + private function collectAnonymousClass(Class_ $class): void + { + $classLikeId = spl_object_id($class); + $analysis = $this->collectClassLikeAnalysis($classLikeId); + [$enclosingClassName, $enclosingFunctionName] = $this->anonymousClassEnclosingNames[$classLikeId]; + [$layer, $layers] = $this->resolveLayerData( + $enclosingClassName ?? $enclosingFunctionName ?? '' + ); + + $this->anonymousClassNodes[] = new AnonymousClassNode( + file: $this->currentFile, + line: $class->getStartLine(), + extends: $class->extends instanceof Name ? $class->extends->toString() : null, + implements: $this->collectImplements($class), + traits: $analysis['traits'], + layer: $layer, + enclosingClassName: $enclosingClassName, + enclosingFunctionName: $enclosingFunctionName, + hasEmptyParentheses: AnonymousClassParentheses::emptyTokenRange($this->currentTokens, $class) !== null, + layers: $layers, + isReadonly: $class->isReadonly(), + dependencies: $analysis['dependencies'], + methods: $analysis['methods'], + constants: $analysis['constants'], + properties: $analysis['properties'], + functionCalls: $analysis['functionCalls'], + superglobals: $analysis['superglobals'], + languageConstructs: $analysis['languageConstructs'], + ); + } + private function collectFunctionLike(FunctionLikeAnalysis $functionLikeAnalysis): void { $functionLike = $functionLikeAnalysis->functionLike; @@ -1637,29 +1666,6 @@ private function collectInterfaceExtends(ClassLike $classLike): array return $parents; } - /** - * Traits used by an anonymous class; named class-likes collect theirs in - * collectMembers(). - * - * @return string[] - */ - private function collectTraits(Class_ $class): array - { - $traits = []; - - foreach ($class->stmts as $stmt) { - if (! $stmt instanceof TraitUse) { - continue; - } - - foreach ($stmt->traits as $trait) { - $traits[] = $trait->toString(); - } - } - - return $traits; - } - private function resolveVisibilityName(ClassMethod|ClassConst|Property|Param $node): string { if ($node->isProtected()) { diff --git a/src/Analyser/AnonymousClassNode.php b/src/Analyser/AnonymousClassNode.php index d5a390c6..c0a5d75a 100644 --- a/src/Analyser/AnonymousClassNode.php +++ b/src/Analyser/AnonymousClassNode.php @@ -20,10 +20,18 @@ * Its parent chain is resolved by the analyser like a named class's, so * {@see extendsClass()} and {@see implementsInterface()} see transitive * parents too. + * + * Its members and body-level facts are collected like a named class's. The + * body-level facts of an anonymous class declared inside a class-like or + * named function are also counted on that enclosing node, exactly as the + * body of a closure is: a rule that only inspects the enclosing node keeps + * seeing everything the anonymous class does. Its members belong to the + * anonymous class alone. */ final class AnonymousClassNode { - use LayerQueryTrait; + use MemberQueryTrait; + use NodeQueryTrait; use RecursiveParentsTrait; /** @@ -36,15 +44,22 @@ final class AnonymousClassNode public readonly array $layers; /** - * @param string[] $implements Interface names this anonymous class implements - * @param string[] $traits Trait names this anonymous class uses - * @param string|null $enclosingClassName Innermost named class-like this anonymous class is declared in - * @param string|null $enclosingFunctionName Innermost named function this anonymous class is declared in - * @param bool $hasEmptyParentheses Whether `()` follows `class` although no constructor argument - * is passed: `new class () {}` rather than `new class {}` - * @param list $layers Layer names this anonymous class belongs to; defaults to [$layer] - * @param list $parentClasses Direct and transitive parent class names - * @param list $parentInterfaces Direct and transitive implemented interface names + * @param string[] $implements Interface names this anonymous class implements + * @param string[] $traits Trait names this anonymous class uses + * @param string|null $enclosingClassName Innermost named class-like this anonymous class is declared in + * @param string|null $enclosingFunctionName Innermost named function this anonymous class is declared in + * @param bool $hasEmptyParentheses Whether `()` follows `class` although no constructor argument + * is passed: `new class () {}` rather than `new class {}` + * @param list $layers Layer names this anonymous class belongs to; defaults to [$layer] + * @param list $parentClasses Direct and transitive parent class names + * @param list $parentInterfaces Direct and transitive implemented interface names + * @param list $dependencies Fully-qualified class, function, or constant dependencies + * @param MethodNode[] $methods Methods of this anonymous class + * @param ConstantNode[] $constants Constants of this anonymous class + * @param PropertyNode[] $properties Properties of this anonymous class + * @param string[] $functionCalls Functions called within this anonymous class + * @param string[] $superglobals Superglobals accessed ($_GET, $_POST, etc.) + * @param string[] $languageConstructs Language constructs used (exit, die, etc.) */ public function __construct( public readonly string $file, @@ -59,6 +74,14 @@ public function __construct( array $layers = [], public array $parentClasses = [], public array $parentInterfaces = [], + public readonly bool $isReadonly = false, + public readonly array $dependencies = [], + public readonly array $methods = [], + public readonly array $constants = [], + public readonly array $properties = [], + public readonly array $functionCalls = [], + public readonly array $superglobals = [], + public readonly array $languageConstructs = [], ) { $this->layers = $layers ?: array_filter([$this->layer]); } diff --git a/src/Analyser/ClassLikeAnalysis.php b/src/Analyser/ClassLikeAnalysis.php index c6bd5ab7..ec591251 100644 --- a/src/Analyser/ClassLikeAnalysis.php +++ b/src/Analyser/ClassLikeAnalysis.php @@ -7,7 +7,7 @@ use PhpParser\Node\Name; /** - * Facts collected while traversing a named class-like: body-level references + * Facts collected while traversing a class-like: body-level references * plus its members, each recorded as the traverser passes the declaring node. * * @internal diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index 0e504276..9dfa9cfe 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -13,6 +13,7 @@ final class ClassNode { + use MemberQueryTrait; use NodeQueryTrait; use RecursiveParentsTrait; @@ -174,15 +175,4 @@ public function implementsInterface(string $interface): bool || $this->matchesAnyClassLike($interface, $this->interfaceExtends) || $this->matchesAnyClassLike($interface, $this->parentInterfaces); } - - public function constructorParamCount(): int - { - foreach ($this->methods as $method) { - if ($method->isConstructor()) { - return $method->paramCount; - } - } - - return 0; - } } diff --git a/src/Analyser/LayerQueryTrait.php b/src/Analyser/LayerQueryTrait.php deleted file mode 100644 index e36bad37..00000000 --- a/src/Analyser/LayerQueryTrait.php +++ /dev/null @@ -1,24 +0,0 @@ - $layers All layer names this node belongs to; assigned once in each node's constructor - */ -trait LayerQueryTrait -{ - public function isInLayer(string $layer): bool - { - return in_array($layer, $this->layers, true); - } -} diff --git a/src/Analyser/MemberQueryTrait.php b/src/Analyser/MemberQueryTrait.php new file mode 100644 index 00000000..dd4edc95 --- /dev/null +++ b/src/Analyser/MemberQueryTrait.php @@ -0,0 +1,29 @@ +methods as $method) { + if ($method->isConstructor()) { + return $method->paramCount; + } + } + + return 0; + } +} diff --git a/src/Analyser/NodeQueryTrait.php b/src/Analyser/NodeQueryTrait.php index 2e0c7d04..22a499d9 100644 --- a/src/Analyser/NodeQueryTrait.php +++ b/src/Analyser/NodeQueryTrait.php @@ -10,14 +10,15 @@ use function strcasecmp; /** - * Query helpers shared by {@see ClassNode}, {@see FunctionNode}, and - * {@see AnonymousFunctionNode}. All three nodes carry the same body-level - * facts — layers, dependencies, function calls, superglobals, language - * constructs — so rules can ask the same questions of a function body that - * they ask of a class-like. + * Query helpers shared by {@see ClassNode}, {@see AnonymousClassNode}, + * {@see FunctionNode}, and {@see AnonymousFunctionNode}. All four nodes carry + * the same body-level facts — layers, dependencies, function calls, + * superglobals, language constructs — so rules can ask the same questions of + * a function body that they ask of a class-like. * * @internal * + * @property list $layers All layer names this node belongs to; assigned once in each node's constructor * @property-read list $dependencies Fully-qualified class, function, or constant dependencies * @property-read string[] $functionCalls Functions called within this node * @property-read string[] $superglobals Superglobals accessed ($_GET, $_POST, etc.) @@ -25,7 +26,10 @@ */ trait NodeQueryTrait { - use LayerQueryTrait; + public function isInLayer(string $layer): bool + { + return in_array($layer, $this->layers, true); + } public function dependsOn(string $class): bool { diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 7bf76efe..67a7b2fc 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -66,7 +66,7 @@ final class AnalysisResultCache * their shape or naming changes: it is recorded in the metadata marker, * so a cache written by an older format is cleared on its next use. */ - public const FORMAT_VERSION = 6; + public const FORMAT_VERSION = 7; private readonly string $cacheDirectory; @@ -567,18 +567,37 @@ private function fileInstantiationsFromPayload(array $payload): ?array */ private function anonymousClassNodeToArray(AnonymousClassNode $anonymousClassNode): array { - return [ + $node = [ 'file' => $anonymousClassNode->file, 'line' => $anonymousClassNode->line, 'extends' => $anonymousClassNode->extends, - 'implements' => $anonymousClassNode->implements, - 'traits' => $anonymousClassNode->traits, 'layer' => $anonymousClassNode->layer, 'enclosingClassName' => $anonymousClassNode->enclosingClassName, 'enclosingFunctionName' => $anonymousClassNode->enclosingFunctionName, 'hasEmptyParentheses' => $anonymousClassNode->hasEmptyParentheses, - 'layers' => $anonymousClassNode->layers, + 'isReadonly' => $anonymousClassNode->isReadonly, + ]; + + $lists = [ + 'implements' => array_values($anonymousClassNode->implements), + 'traits' => array_values($anonymousClassNode->traits), + 'layers' => $anonymousClassNode->layers, + 'dependencies' => $anonymousClassNode->dependencies, + 'methods' => array_map($this->methodNodeToArray(...), $anonymousClassNode->methods), + 'constants' => array_map($this->constantNodeToArray(...), $anonymousClassNode->constants), + 'properties' => array_map($this->propertyNodeToArray(...), $anonymousClassNode->properties), + 'functionCalls' => array_values($anonymousClassNode->functionCalls), + 'superglobals' => array_values($anonymousClassNode->superglobals), + 'languageConstructs' => array_values($anonymousClassNode->languageConstructs), ]; + + foreach ($lists as $key => $list) { + if ($list !== []) { + $node[$key] = $list; + } + } + + return $node; } /** @@ -610,6 +629,11 @@ private function anonymousClassNodesFromPayload(array $payload): ?array $enclosingFunctionName = $rawNode['enclosingFunctionName'] ?? null; $hasEmptyParentheses = $rawNode['hasEmptyParentheses'] ?? false; $layers = $rawNode['layers'] ?? []; + $isReadonly = $rawNode['isReadonly'] ?? false; + $dependencies = $rawNode['dependencies'] ?? []; + $functionCalls = $rawNode['functionCalls'] ?? []; + $superglobals = $rawNode['superglobals'] ?? []; + $languageConstructs = $rawNode['languageConstructs'] ?? []; if ( ! is_string($file) @@ -619,6 +643,7 @@ private function anonymousClassNodesFromPayload(array $payload): ?array || ($enclosingClassName !== null && ! is_string($enclosingClassName)) || ($enclosingFunctionName !== null && ! is_string($enclosingFunctionName)) || ! is_bool($hasEmptyParentheses) + || ! is_bool($isReadonly) ) { return null; } @@ -627,10 +652,22 @@ private function anonymousClassNodesFromPayload(array $payload): ?array ! $this->isStringArray($implements) || ! $this->isStringArray($traits) || ! $this->isStringArray($layers) + || ! $this->isStringArray($dependencies) + || ! $this->isStringArray($functionCalls) + || ! $this->isStringArray($superglobals) + || ! $this->isStringArray($languageConstructs) ) { return null; } + $methods = $this->memberNodesFromArray($rawNode['methods'] ?? [], $this->methodNodeFromArray(...)); + $constants = $this->memberNodesFromArray($rawNode['constants'] ?? [], $this->constantNodeFromArray(...)); + $properties = $this->memberNodesFromArray($rawNode['properties'] ?? [], $this->propertyNodeFromArray(...)); + + if ($methods === null || $constants === null || $properties === null) { + return null; + } + $anonymousClassNodes[] = new AnonymousClassNode( file: $file, line: $line, @@ -642,6 +679,14 @@ traits: $traits, enclosingFunctionName: $enclosingFunctionName, hasEmptyParentheses: $hasEmptyParentheses, layers: array_values($layers), + isReadonly: $isReadonly, + dependencies: array_values($dependencies), + methods: $methods, + constants: $constants, + properties: $properties, + functionCalls: array_values($functionCalls), + superglobals: array_values($superglobals), + languageConstructs: array_values($languageConstructs), ); } @@ -988,10 +1033,6 @@ private function classNodeFromArray(array $node, string $file): ?ClassNode $parentClasses = $node['parentClasses'] ?? []; $parentInterfaces = $node['parentInterfaces'] ?? []; $traits = $node['traits'] ?? []; - $rawMethods = $node['methods'] ?? []; - $rawConstants = $node['constants'] ?? []; - $rawProperties = $node['properties'] ?? []; - $rawEnumCases = $node['enumCases'] ?? []; $enumBackingType = $node['enumBackingType'] ?? null; $functionCalls = $node['functionCalls'] ?? []; $superglobals = $node['superglobals'] ?? []; @@ -1015,9 +1056,6 @@ private function classNodeFromArray(array $node, string $file): ?ClassNode || ! $this->isStringArray($parentClasses) || ! $this->isStringArray($parentInterfaces) || ! $this->isStringArray($traits) - || ! is_array($rawMethods) - || ! is_array($rawConstants) - || ! is_array($rawProperties) || ! $this->isStringArray($functionCalls) || ! $this->isStringArray($superglobals) || ! $this->isStringArray($languageConstructs) @@ -1026,74 +1064,21 @@ private function classNodeFromArray(array $node, string $file): ?ClassNode return null; } - $methods = []; - - foreach ($rawMethods as $rawMethod) { - if (! is_array($rawMethod)) { - return null; - } - - $methodNode = $this->methodNodeFromArray($rawMethod); - - if (! $methodNode instanceof MethodNode) { - return null; - } - - $methods[] = $methodNode; - } - - $constants = []; - - foreach ($rawConstants as $rawConstant) { - if (! is_array($rawConstant)) { - return null; - } - - $constantNode = $this->constantNodeFromArray($rawConstant); - - if (! $constantNode instanceof ConstantNode) { - return null; - } - - $constants[] = $constantNode; - } - - $properties = []; - - foreach ($rawProperties as $rawProperty) { - if (! is_array($rawProperty)) { - return null; - } - - $propertyNode = $this->propertyNodeFromArray($rawProperty); - - if (! $propertyNode instanceof PropertyNode) { - return null; - } - - $properties[] = $propertyNode; - } + $methods = $this->memberNodesFromArray($node['methods'] ?? [], $this->methodNodeFromArray(...)); + $constants = $this->memberNodesFromArray($node['constants'] ?? [], $this->constantNodeFromArray(...)); + $properties = $this->memberNodesFromArray($node['properties'] ?? [], $this->propertyNodeFromArray(...)); + $enumCases = $this->memberNodesFromArray($node['enumCases'] ?? [], $this->enumCaseNodeFromArray(...)); - if (! is_array($rawEnumCases) || ($enumBackingType !== null && ! is_string($enumBackingType))) { + if ( + $methods === null + || $constants === null + || $properties === null + || $enumCases === null + || ($enumBackingType !== null && ! is_string($enumBackingType)) + ) { return null; } - $enumCases = []; - - foreach ($rawEnumCases as $rawEnumCase) { - if (! is_array($rawEnumCase)) { - return null; - } - - $enumCaseNode = $this->enumCaseNodeFromArray($rawEnumCase); - - if (! $enumCaseNode instanceof EnumCaseNode) { - return null; - } - - $enumCases[] = $enumCaseNode; - } - return new ClassNode( className: $className, file: $file, @@ -1124,6 +1109,36 @@ enumBackingType: $enumBackingType, ); } + /** + * @template TMember of MethodNode|ConstantNode|PropertyNode|EnumCaseNode + * @param callable(array): (TMember|null) $memberFromArray + * @return list|null + */ + private function memberNodesFromArray(mixed $rawMembers, callable $memberFromArray): ?array + { + if (! is_array($rawMembers)) { + return null; + } + + $members = []; + + foreach ($rawMembers as $rawMember) { + if (! is_array($rawMember)) { + return null; + } + + $member = $memberFromArray($rawMember); + + if ($member === null) { + return null; + } + + $members[] = $member; + } + + return $members; + } + /** * Members are stored as positional tuples: a class has many of them, * and their field names would otherwise be repeated for every one. diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index 5b1290f9..c3dc1c0f 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -720,6 +720,85 @@ public function testCollectsTopLevelAnonymousClassNodeInFileWithoutNamedClasses( $this->assertSame('App\BaseHandler', $anonymousClassNodes[0]->extends); } + public function testCollectsAnonymousClassMembersAndBodyFactsLikeANamedClass(): void + { + $analysisNodeCollector = $this->makeCollector(<<<'PHP' + getAnonymousClassNodes(); + $classNodes = $analysisNodeCollector->getClassNodes(); + + $this->assertCount(1, $anonymousClassNodes); + $anonymousClassNode = $anonymousClassNodes[0]; + + $this->assertTrue($anonymousClassNode->isReadonly); + $this->assertSame(['App\Helper'], $anonymousClassNode->traits); + $this->assertSame(['LIMIT'], array_column($anonymousClassNode->constants, 'name')); + $this->assertSame(['count', 'clock'], array_column($anonymousClassNode->properties, 'name')); + $this->assertSame(['__construct', '__toString'], array_column($anonymousClassNode->methods, 'name')); + $this->assertSame(1, $anonymousClassNode->constructorParamCount()); + $this->assertSame(['strtoupper'], $anonymousClassNode->functionCalls); + $this->assertSame(['$_GET'], $anonymousClassNode->superglobals); + $this->assertSame(['isset', 'exit'], $anonymousClassNode->languageConstructs); + $this->assertTrue($anonymousClassNode->dependsOn('Stringable')); + $this->assertTrue($anonymousClassNode->dependsOn('App\Helper')); + $this->assertTrue($anonymousClassNode->dependsOn('App\Support\Clock')); + $this->assertTrue($anonymousClassNode->dependsOn('App\Other')); + // The file's imports belong to the file, not to the anonymous class. + $this->assertFalse($anonymousClassNode->dependsOn('App\Support\Unused')); + + // The enclosing class keeps seeing the anonymous class body, as it + // sees a closure's, but the members belong to the anonymous class. + $this->assertCount(1, $classNodes); + $this->assertSame(['make'], array_column($classNodes[0]->methods, 'name')); + $this->assertSame([], $classNodes[0]->constants); + $this->assertSame([], $classNodes[0]->properties); + $this->assertSame([], $classNodes[0]->traits); + $this->assertTrue($classNodes[0]->dependsOn('App\Other')); + $this->assertTrue($classNodes[0]->dependsOn('App\Support\Unused')); + $this->assertSame(['strtoupper'], $classNodes[0]->functionCalls); + $this->assertSame(['$_GET'], $classNodes[0]->superglobals); + $this->assertSame(['isset', 'exit'], $classNodes[0]->languageConstructs); + } + + public function testTopLevelAnonymousClassBodyReferencesAreItsOwnDependencies(): void + { + $analysisNodeCollector = $this->makeCollector(<<<'PHP' + getAnonymousClassNodes(); + + $this->assertCount(1, $anonymousClassNodes); + $this->assertSame(['App\BaseHandler', 'App\Other'], $anonymousClassNodes[0]->dependencies); + // Resolved once the whole file is traversed, like a named class's. + $this->assertSame(['App\helper'], $anonymousClassNodes[0]->functionCalls); + $this->assertSame([], $analysisNodeCollector->getFileReferences()); + } + public function testCollectsAnonymousClassNodeWithoutExtends(): void { $anonymousClassNodes = $this->collectAnonymousClassNodes('assertFalse($anonymousClassNode->extendsClass('App\\Support\\BaseClass')); $this->assertFalse($anonymousClassNode->implementsInterface('App\\Contracts\\FooInterface')); } + + public function testCarriesMembersAndBodyFactsLikeAClassNode(): void + { + $anonymousClassNode = new AnonymousClassNode( + file: '/src/HandlerFactory.php', + line: 7, + extends: null, + layer: 'Source', + isReadonly: true, + dependencies: ['App\\Support\\Clock'], + methods: [new MethodNode('__construct', 'public', false, false, 2, 1, 3)], + constants: [new ConstantNode('LIMIT')], + properties: [new PropertyNode('clock', 'private', true)], + functionCalls: ['strlen'], + superglobals: ['$_GET'], + languageConstructs: ['die'], + ); + + $this->assertTrue($anonymousClassNode->isReadonly); + $this->assertTrue($anonymousClassNode->isInLayer('Source')); + $this->assertTrue($anonymousClassNode->dependsOn('App\\Support\\Clock')); + $this->assertTrue($anonymousClassNode->dependsOnNamespace('App\\Support')); + $this->assertTrue($anonymousClassNode->callsFunction('STRLEN')); + $this->assertTrue($anonymousClassNode->accessesSuperglobals()); + $this->assertTrue($anonymousClassNode->usesLanguageConstruct('exit')); + $this->assertSame(2, $anonymousClassNode->constructorParamCount()); + $this->assertSame('LIMIT', $anonymousClassNode->constants[0]->name); + $this->assertSame('clock', $anonymousClassNode->properties[0]->name); + } + + public function testMembersAndBodyFactsDefaultToEmpty(): void + { + $anonymousClassNode = new AnonymousClassNode(file: '/src/helpers.php', line: 3, extends: null); + + $this->assertFalse($anonymousClassNode->isReadonly); + $this->assertSame([], $anonymousClassNode->methods); + $this->assertSame([], $anonymousClassNode->constants); + $this->assertSame([], $anonymousClassNode->properties); + $this->assertSame(0, $anonymousClassNode->constructorParamCount()); + $this->assertFalse($anonymousClassNode->dependsOn('App\\Support\\Clock')); + $this->assertFalse($anonymousClassNode->callsFunction('strlen')); + $this->assertFalse($anonymousClassNode->accessesSuperglobals()); + $this->assertFalse($anonymousClassNode->usesLanguageConstruct('exit')); + } } diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index 782789b7..ae31bd73 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -780,6 +780,14 @@ traits: ['App\Helper'], enclosingClassName: 'App\HandlerFactory', hasEmptyParentheses: true, layers: ['Source', 'Shared'], + isReadonly: true, + dependencies: ['App\BaseHandler', 'App\Contract'], + methods: [new MethodNode('__construct', 'public', false, false, 1, 1, 3, true, 8)], + constants: [new ConstantNode('LIMIT', 'public', true, 9)], + properties: [new PropertyNode('clock', 'private', true, 8)], + functionCalls: ['strtoupper'], + superglobals: ['$_GET'], + languageConstructs: ['exit'], ), new AnonymousClassNode( file: $sourceFile, @@ -1092,6 +1100,18 @@ public static function corruptedAnonymousClassNodesProvider(): Iterator yield 'entry with invalid traits' => [ [['file' => '/Foo.php', 'line' => 7, 'extends' => null, 'traits' => 'invalid']], ]; + yield 'entry with invalid isReadonly' => [ + [['file' => '/Foo.php', 'line' => 7, 'extends' => null, 'isReadonly' => 'yes']], + ]; + yield 'entry with invalid dependencies' => [ + [['file' => '/Foo.php', 'line' => 7, 'extends' => null, 'dependencies' => [1]]], + ]; + yield 'entry with invalid methods' => [ + [['file' => '/Foo.php', 'line' => 7, 'extends' => null, 'methods' => ['invalid']]], + ]; + yield 'entry with invalid method tuple' => [ + [['file' => '/Foo.php', 'line' => 7, 'extends' => null, 'methods' => [['x']]]], + ]; } public function testLoadClassNodesRejectsCorruptedFileReferencesPayload(): void From 0e9a5c08374065b6b40ac8f89a1c19471780b723 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 4 Sep 2026 06:39:30 +0700 Subject: [PATCH 099/104] update docs --- src/Analyser/ExtractionResult.php | 2 +- src/Cache/AnalysisResultCache.php | 2 +- tests/Analyser/AnalysisNodeCollectorTest.php | 28 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExtractionResult.php b/src/Analyser/ExtractionResult.php index 0b8f4141..94efb6c0 100644 --- a/src/Analyser/ExtractionResult.php +++ b/src/Analyser/ExtractionResult.php @@ -11,7 +11,7 @@ * @param array $fileAnalyses * @param list $anonymousClassNodes * @param array> $fileReferences Class-like references made outside any - * named class-like scope, per file + * class-like scope, per file * @param array> $fileInstantiations Class-like instantiations (`new X`, * with self/static/parent resolved), per file * @param list $functionNodes diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 67a7b2fc..0c3c293f 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -419,7 +419,7 @@ public function storeExtractionResult(array $files, string $namespace, Extractio * @param list $classNodes * @param list $anonymousClassNodes * @param list $fileReferences Class-like references made outside any - * named class-like scope in this file + * class-like scope in this file * @param list $fileInstantiations Class-like instantiations in this file * @param list $functionNodes * @param list $anonymousFunctionNodes diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index c3dc1c0f..1c5552d3 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -799,6 +799,34 @@ function helper(string $class): string { return $class; } $this->assertSame([], $analysisNodeCollector->getFileReferences()); } + public function testNestedAnonymousClassesKeepTheirOwnMembersAndShareBodyDependencies(): void + { + $anonymousClassNodes = $this->collectAnonymousClassNodes(<<<'PHP' + assertCount(2, $anonymousClassNodes); + [$inner, $outer] = $anonymousClassNodes; + + $this->assertSame(['inner'], array_column($inner->methods, 'name')); + $this->assertSame(['outer'], array_column($outer->methods, 'name')); + $this->assertNull($outer->enclosingClassName); + $this->assertNull($inner->enclosingClassName); + + // The inner body is counted on both, like a closure's on its enclosing scopes. + $this->assertSame(['App\Clock'], $inner->dependencies); + $this->assertSame(['App\Clock'], $outer->dependencies); + } + public function testCollectsAnonymousClassNodeWithoutExtends(): void { $anonymousClassNodes = $this->collectAnonymousClassNodes(' Date: Fri, 4 Sep 2026 07:03:46 +0700 Subject: [PATCH 100/104] add more test --- tests/Analyser/AnalysisNodeCollectorTest.php | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index 1c5552d3..a36c0fb4 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -10,8 +10,17 @@ use Boundwize\StructArmed\Analyser\ClassNode; use Boundwize\StructArmed\Analyser\EnumCaseNode; use Boundwize\StructArmed\LayerResolver\Resolvers\NamespaceLayerResolver; +use PhpParser\Modifiers; +use PhpParser\Node\Const_; +use PhpParser\Node\Name; +use PhpParser\Node\PropertyItem; +use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt\Class_; +use PhpParser\Node\Stmt\ClassConst; use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\EnumCase; +use PhpParser\Node\Stmt\Property; +use PhpParser\Node\Stmt\TraitUse; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; use PhpParser\ParserFactory; @@ -2086,4 +2095,22 @@ public function testIgnoresClassMethodNodesOutsideTrackedClassLike(): void $this->assertSame([], $analysisNodeCollector->getClassNodes()); } + + public function testIgnoresMemberNodesOutsideTrackedClassLike(): void + { + $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); + $enumCase = new EnumCase('Orphan'); + + $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php'); + + $analysisNodeCollector->enterNode(new Property(Modifiers::PUBLIC, [new PropertyItem('orphan')])); + $analysisNodeCollector->enterNode(new ClassConst([new Const_('ORPHAN', new Int_(1))])); + $analysisNodeCollector->enterNode(new TraitUse([new Name('OrphanTrait')])); + $analysisNodeCollector->enterNode($enumCase); + $analysisNodeCollector->leaveNode($enumCase); + + $this->assertSame([], $analysisNodeCollector->getClassNodes()); + $this->assertSame([], $analysisNodeCollector->getAnonymousClassNodes()); + } } From fb536160039a75733eada768a1770646857d1bdd Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 4 Sep 2026 08:34:42 +0700 Subject: [PATCH 101/104] update documentation of make use of TokenAwareVisitorInterface --- docs/custom-rules-and-presets.md | 56 ++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index fe7de6b4..7c826933 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -389,6 +389,60 @@ Built-in rules follow the same pattern: `MustBeFinalRule` returns `AddFinalClass Keep fixers deterministic and narrowly scoped. A failed or skipped fix should return `false` so StructArmed can leave the violation in the report. +## Fixing Syntax The AST Does Not Record + +Some facts a rule reports exist only in the source text, not in any PHP-Parser node. The empty `()` of `new class () {}` is one: both `new class {}` and `new class () {}` parse to the same node with an empty argument list. A visitor that only edits nodes cannot remove those parentheses, and re-printing the whole class to drop them would reformat its body. + +Implement `Boundwize\StructArmed\Rule\Fixer\PhpParser\TokenAwareVisitorInterface` on the fixer visitor for such cases. It extends `PhpParser\NodeVisitor` with one method: + +```php +/** @param array $tokens */ +public function setTokens(array $tokens): void; +``` + +`PhpParserFixerProcessor` calls `setTokens()` with the tokens the file was parsed into before traversing with that visitor. The tokens are the same objects the format-preserving printer copies unchanged code from, so editing a `Token` object's `text` changes the printed file without any node being re-printed. + +```diff ++ use Boundwize\StructArmed\Rule\Fixer\PhpParser\TokenAwareVisitorInterface; + use PhpParser\Node; + use PhpParser\NodeVisitorAbstract; ++ use PhpParser\Token; + +- final class RemoveSomethingVisitor extends NodeVisitorAbstract ++ final class RemoveSomethingVisitor extends NodeVisitorAbstract implements TokenAwareVisitorInterface + { ++ /** @var array */ ++ private array $tokens = []; ++ ++ public function setTokens(array $tokens): void ++ { ++ $this->tokens = $tokens; ++ } ++ + public function enterNode(Node $node): ?Node + { + // ... locate the target node ... + ++ for ($index = $node->getStartTokenPos(); $index <= $node->getEndTokenPos(); $index++) { ++ if ($this->tokens[$index]->text === '(' || $this->tokens[$index]->text === ')') { ++ $this->tokens[$index]->text = ''; ++ } ++ } + + return $node; + } + } +``` + +Use `getStartTokenPos()` and `getEndTokenPos()` on the node to find the token range it spans, then walk that range and edit only the tokens the fix targets. Return the node from `enterNode()` unchanged; the fix lives in the tokens, not in the node. + +The built-in `AnonymousClassMayNotHaveEmptyParenthesesRule` follows this shape. It returns `Boundwize\StructArmed\Rule\Fixer\PhpParser\Class_\RemoveAnonymousClassParenthesesVisitor` from `createFixerVisitor()`, and that visitor uses `Boundwize\StructArmed\Util\PhpParser\AnonymousClassParentheses::emptyTokenRange()` to locate the `()` tokens and blank them. + +- Edit token `text` in place; do not replace, add, or remove entries in the token array, since the printer matches tokens to nodes by index. +- Keep the whitespace PHP needs. Blanking a token that separated two words may require leaving a single space in its place. +- Leave a comment inside the edited range alone, or skip the fix when removing the tokens would delete it. +- A rule may still combine a token edit with a node edit in the same visitor; the token edit reaches the output only for nodes the printer keeps unchanged. + ## Custom Presets A custom preset is a class that implements `Boundwize\StructArmed\Preset\PresetInterface`. Inside `apply()`, add the layers and rules you want to reuse. @@ -450,3 +504,5 @@ Use `rule()` when one project needs one extra check. Use a custom `RuleInterface` class when the check itself is new behavior; add `FunctionRuleInterface` / `AnonymousFunctionRuleInterface` / `AnonymousClassRuleInterface` when it must also cover named functions, closures, or anonymous classes. Use a custom `PresetInterface` class when several layers and rules should be applied together or reused across repositories. + +Use `AbstractPhpParserFixableRule` with a `PhpParser\NodeVisitor` when a rule can rewrite the offending file; add `TokenAwareVisitorInterface` to that visitor when the fix targets punctuation or whitespace PHP-Parser records in no node. From 89089d4362c75513af77609ed1984c20a3a691a9 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 4 Sep 2026 08:47:20 +0700 Subject: [PATCH 102/104] refactor TokenAwareVisitorInterface into AbstractTokenAwareVisitor --- docs/custom-rules-and-presets.md | 24 +++++++------------ ...face.php => AbstractTokenAwareVisitor.php} | 14 +++++++---- ...RemoveAnonymousClassParenthesesVisitor.php | 14 ++--------- .../PhpParser/PhpParserFixerProcessor.php | 2 +- 4 files changed, 22 insertions(+), 32 deletions(-) rename src/Rule/Fixer/PhpParser/{TokenAwareVisitorInterface.php => AbstractTokenAwareVisitor.php} (58%) diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index 7c826933..43b0515d 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -393,32 +393,26 @@ Keep fixers deterministic and narrowly scoped. A failed or skipped fix should re Some facts a rule reports exist only in the source text, not in any PHP-Parser node. The empty `()` of `new class () {}` is one: both `new class {}` and `new class () {}` parse to the same node with an empty argument list. A visitor that only edits nodes cannot remove those parentheses, and re-printing the whole class to drop them would reformat its body. -Implement `Boundwize\StructArmed\Rule\Fixer\PhpParser\TokenAwareVisitorInterface` on the fixer visitor for such cases. It extends `PhpParser\NodeVisitor` with one method: +Extend `Boundwize\StructArmed\Rule\Fixer\PhpParser\AbstractTokenAwareVisitor` for such cases. It is a `PhpParser\NodeVisitorAbstract` that holds the tokens the file was parsed into in a protected `$tokens` property: ```php +/** @var array */ +protected array $tokens = []; + /** @param array $tokens */ public function setTokens(array $tokens): void; ``` -`PhpParserFixerProcessor` calls `setTokens()` with the tokens the file was parsed into before traversing with that visitor. The tokens are the same objects the format-preserving printer copies unchanged code from, so editing a `Token` object's `text` changes the printed file without any node being re-printed. +`PhpParserFixerProcessor` calls `setTokens()` before traversing with that visitor. The tokens are the same objects the format-preserving printer copies unchanged code from, so editing a `Token` object's `text` changes the printed file without any node being re-printed. ```diff -+ use Boundwize\StructArmed\Rule\Fixer\PhpParser\TokenAwareVisitorInterface; ++ use Boundwize\StructArmed\Rule\Fixer\PhpParser\AbstractTokenAwareVisitor; use PhpParser\Node; - use PhpParser\NodeVisitorAbstract; -+ use PhpParser\Token; +- use PhpParser\NodeVisitorAbstract; - final class RemoveSomethingVisitor extends NodeVisitorAbstract -+ final class RemoveSomethingVisitor extends NodeVisitorAbstract implements TokenAwareVisitorInterface ++ final class RemoveSomethingVisitor extends AbstractTokenAwareVisitor { -+ /** @var array */ -+ private array $tokens = []; -+ -+ public function setTokens(array $tokens): void -+ { -+ $this->tokens = $tokens; -+ } -+ public function enterNode(Node $node): ?Node { // ... locate the target node ... @@ -505,4 +499,4 @@ Use a custom `RuleInterface` class when the check itself is new behavior; add `F Use a custom `PresetInterface` class when several layers and rules should be applied together or reused across repositories. -Use `AbstractPhpParserFixableRule` with a `PhpParser\NodeVisitor` when a rule can rewrite the offending file; add `TokenAwareVisitorInterface` to that visitor when the fix targets punctuation or whitespace PHP-Parser records in no node. +Use `AbstractPhpParserFixableRule` with a `PhpParser\NodeVisitor` when a rule can rewrite the offending file; extend `AbstractTokenAwareVisitor` for that visitor when the fix targets punctuation or whitespace PHP-Parser records in no node. diff --git a/src/Rule/Fixer/PhpParser/TokenAwareVisitorInterface.php b/src/Rule/Fixer/PhpParser/AbstractTokenAwareVisitor.php similarity index 58% rename from src/Rule/Fixer/PhpParser/TokenAwareVisitorInterface.php rename to src/Rule/Fixer/PhpParser/AbstractTokenAwareVisitor.php index d4fb8cc7..3280662d 100644 --- a/src/Rule/Fixer/PhpParser/TokenAwareVisitorInterface.php +++ b/src/Rule/Fixer/PhpParser/AbstractTokenAwareVisitor.php @@ -4,7 +4,7 @@ namespace Boundwize\StructArmed\Rule\Fixer\PhpParser; -use PhpParser\NodeVisitor; +use PhpParser\NodeVisitorAbstract; use PhpParser\Token; /** @@ -14,8 +14,14 @@ * printer assembles every unchanged node from these tokens, so an edited * token text reaches the fixed file without any node being re-printed. */ -interface TokenAwareVisitorInterface extends NodeVisitor +abstract class AbstractTokenAwareVisitor extends NodeVisitorAbstract { - /** @param array $tokens The mutable tokens the file being fixed was parsed into */ - public function setTokens(array $tokens): void; + /** @var array The mutable tokens the file being fixed was parsed into */ + protected array $tokens = []; + + /** @param array $tokens */ + public function setTokens(array $tokens): void + { + $this->tokens = $tokens; + } } diff --git a/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php b/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php index 9ed91bfe..4488a9f3 100644 --- a/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php +++ b/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php @@ -4,12 +4,10 @@ namespace Boundwize\StructArmed\Rule\Fixer\PhpParser\Class_; -use Boundwize\StructArmed\Rule\Fixer\PhpParser\TokenAwareVisitorInterface; +use Boundwize\StructArmed\Rule\Fixer\PhpParser\AbstractTokenAwareVisitor; use Boundwize\StructArmed\Util\PhpParser\AnonymousClassParentheses; use PhpParser\Node; use PhpParser\Node\Stmt\Class_; -use PhpParser\NodeVisitorAbstract; -use PhpParser\Token; use const T_WHITESPACE; @@ -23,21 +21,13 @@ * rule's own condition here — instead of trusting the line alone — means * every class this visitor changes is one the rule flags. */ -final class RemoveAnonymousClassParenthesesVisitor extends NodeVisitorAbstract implements TokenAwareVisitorInterface +final class RemoveAnonymousClassParenthesesVisitor extends AbstractTokenAwareVisitor { - /** @var array */ - private array $tokens = []; - public function __construct( private readonly int $line, ) { } - public function setTokens(array $tokens): void - { - $this->tokens = $tokens; - } - public function enterNode(Node $node): ?Node { if (! $node instanceof Class_ || ! $node->isAnonymous() || $node->getStartLine() !== $this->line) { diff --git a/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php b/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php index 13880d6d..f0450cdf 100644 --- a/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php +++ b/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php @@ -60,7 +60,7 @@ public function process(string $file, NodeVisitor|array $nodeVisitors, bool $rem foreach ($nodeVisitors as $nodeVisitor) { // A token edit lands in the output through the same tokens the // format-preserving printer copies unchanged code from. - if ($nodeVisitor instanceof TokenAwareVisitorInterface) { + if ($nodeVisitor instanceof AbstractTokenAwareVisitor) { $nodeVisitor->setTokens($tokens); } From d5ad761f9c7a99023b5f9050aa9d62c0647df67f Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 4 Sep 2026 08:54:23 +0700 Subject: [PATCH 103/104] refactor LayerAwareRuleInterface into AbstractLayerAwareRule --- docs/custom-rules-and-presets.md | 51 +++++++++++++++++++++ src/Analyser/Analyser.php | 4 +- src/Rule/AbstractLayerAwareRule.php | 24 ++++++++++ src/Rule/LayerAwareRuleInterface.php | 13 ------ src/Rule/Rules/Layer/MayNotDependOnRule.php | 13 +----- tests/Rule/Layer/MayNotDependOnRuleTest.php | 6 +-- 6 files changed, 82 insertions(+), 29 deletions(-) create mode 100644 src/Rule/AbstractLayerAwareRule.php delete mode 100644 src/Rule/LayerAwareRuleInterface.php diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index 43b0515d..6840b9c3 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -177,6 +177,55 @@ The built-in [YAGNI preset](../presets/) rules follow this pattern: `MustBeUsedI Trade-off: only usage within the scanned paths is known. A class-like used solely by a consumer outside the scan — a vendor package, an unscanned directory, runtime-fed dynamic construction — is reported as if unused. Widen the scan, or use `skipRule()` and skip paths where such consumers exist. +## Reading Other Classes' Layers In A Custom Rule + +A rule only receives the node it is evaluating. When a check depends on the layer of *another* class — the layer a dependency belongs to, for instance — extend `Boundwize\StructArmed\Rule\AbstractLayerAwareRule`. Before any class is evaluated, the analyser injects the map of every scanned class into its protected `$classNodeMap` property, keyed by fully qualified class name: + +```php +isInLayer('Controller'); + } + + public function evaluate(ClassNode $classNode): ?RuleViolation + { + foreach ($classNode->dependencies as $dependency) { + $dependencyNode = $this->classNodeMap[$dependency] ?? null; + + if (! $dependencyNode instanceof ClassNode || $dependencyNode->isInLayer('Application')) { + continue; + } + + return new RuleViolation( + message: sprintf('Controller [%s] must not depend on [%s]', $classNode->className, $dependency), + file: $classNode->file, + line: $classNode->line, + className: $classNode->className, + layer: $classNode->layer, + ); + } + + return null; + } +} +``` + +The base class holds the property and its `injectClassNodeMap()` setter, so the rule adds nothing but the check itself. A dependency outside the scanned paths — a vendor class, a PHP built-in — has no entry in the map, so fall back to path or namespace matching for those, or skip them as above. The built-in `MayNotDependOnRule` follows this pattern: it reads the dependency's layers from the map first and falls back to the `toPath` prefix only when the dependency was not scanned. + + ## Analysing Functions, Closures, And Anonymous Classes Named functions, closures, arrow functions, and anonymous classes are collected alongside named classes: @@ -497,6 +546,8 @@ Use `rule()` when one project needs one extra check. Use a custom `RuleInterface` class when the check itself is new behavior; add `FunctionRuleInterface` / `AnonymousFunctionRuleInterface` / `AnonymousClassRuleInterface` when it must also cover named functions, closures, or anonymous classes. +Extend `AbstractLayerAwareRule` when a rule must know the layer of a class other than the one under evaluation, such as the layer a dependency lives in. + Use a custom `PresetInterface` class when several layers and rules should be applied together or reused across repositories. Use `AbstractPhpParserFixableRule` with a `PhpParser\NodeVisitor` when a rule can rewrite the offending file; extend `AbstractTokenAwareVisitor` for that visitor when the fix targets punctuation or whitespace PHP-Parser records in no node. diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index c71bb41a..d5e73326 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -13,6 +13,7 @@ use Boundwize\StructArmed\File\SkipPathMatcher; use Boundwize\StructArmed\LayerResolver\ChainLayerResolver; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; +use Boundwize\StructArmed\Rule\AbstractLayerAwareRule; use Boundwize\StructArmed\Rule\AnonymousClassRuleInterface; use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface; use Boundwize\StructArmed\Rule\ComposerJsonRuleInterface; @@ -20,7 +21,6 @@ use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface; use Boundwize\StructArmed\Rule\FixableInterface; use Boundwize\StructArmed\Rule\FunctionRuleInterface; -use Boundwize\StructArmed\Rule\LayerAwareRuleInterface; use Boundwize\StructArmed\Rule\MultipleProjectRuleViolationInterface; use Boundwize\StructArmed\Rule\MultipleRuleViolationInterface; use Boundwize\StructArmed\Rule\ProjectRuleInterface; @@ -120,7 +120,7 @@ public function analyse( $anonymousClassNodeRules[$key] = $rule; } - if ($rule instanceof LayerAwareRuleInterface) { + if ($rule instanceof AbstractLayerAwareRule) { $layerAwareRules[] = $rule; } diff --git a/src/Rule/AbstractLayerAwareRule.php b/src/Rule/AbstractLayerAwareRule.php new file mode 100644 index 00000000..46b58704 --- /dev/null +++ b/src/Rule/AbstractLayerAwareRule.php @@ -0,0 +1,24 @@ + class name → class node */ + protected array $classNodeMap = []; + + /** @param array $classNodeMap */ + public function injectClassNodeMap(array $classNodeMap): void + { + $this->classNodeMap = $classNodeMap; + } +} diff --git a/src/Rule/LayerAwareRuleInterface.php b/src/Rule/LayerAwareRuleInterface.php deleted file mode 100644 index 6f275c05..00000000 --- a/src/Rule/LayerAwareRuleInterface.php +++ /dev/null @@ -1,13 +0,0 @@ - $classNodeMap class name → class node */ - public function injectClassNodeMap(array $classNodeMap): void; -} diff --git a/src/Rule/Rules/Layer/MayNotDependOnRule.php b/src/Rule/Rules/Layer/MayNotDependOnRule.php index ed1a91ed..b2af9772 100644 --- a/src/Rule/Rules/Layer/MayNotDependOnRule.php +++ b/src/Rule/Rules/Layer/MayNotDependOnRule.php @@ -5,7 +5,7 @@ namespace Boundwize\StructArmed\Rule\Rules\Layer; use Boundwize\StructArmed\Analyser\ClassNode; -use Boundwize\StructArmed\Rule\LayerAwareRuleInterface; +use Boundwize\StructArmed\Rule\AbstractLayerAwareRule; use Boundwize\StructArmed\Rule\MultipleRuleViolationInterface; use Boundwize\StructArmed\Rule\RuleViolation; use Boundwize\StructArmed\Util\Path; @@ -15,13 +15,10 @@ use function str_contains; use function str_starts_with; -final class MayNotDependOnRule implements MultipleRuleViolationInterface, LayerAwareRuleInterface +final class MayNotDependOnRule extends AbstractLayerAwareRule implements MultipleRuleViolationInterface { private readonly string $normalisedToPath; - /** @var array */ - private array $classNodeMap = []; - public function __construct( private readonly string $from, private readonly string $to, @@ -30,12 +27,6 @@ public function __construct( $this->normalisedToPath = Path::normalise($toPath ?? $to); } - /** @param array $classNodeMap */ - public function injectClassNodeMap(array $classNodeMap): void - { - $this->classNodeMap = $classNodeMap; - } - public function appliesTo(ClassNode $classNode): bool { return $classNode->isInLayer($this->from); diff --git a/tests/Rule/Layer/MayNotDependOnRuleTest.php b/tests/Rule/Layer/MayNotDependOnRuleTest.php index 8d8671b2..12d1380b 100644 --- a/tests/Rule/Layer/MayNotDependOnRuleTest.php +++ b/tests/Rule/Layer/MayNotDependOnRuleTest.php @@ -5,7 +5,7 @@ namespace Boundwize\StructArmed\Tests\Rule\Layer; use Boundwize\StructArmed\Analyser\ClassNode; -use Boundwize\StructArmed\Rule\LayerAwareRuleInterface; +use Boundwize\StructArmed\Rule\AbstractLayerAwareRule; use Boundwize\StructArmed\Rule\Rules\Layer\MayNotDependOnRule; use Boundwize\StructArmed\Rule\RuleViolation; use PHPUnit\Framework\Attributes\CoversClass; @@ -164,10 +164,10 @@ public function testReportsMultipleViolationsWhenMultipleForbiddenDependencies() $this->assertStringContainsString('App\Infrastructure\B', $violations[1]->message); } - public function testImplementsLayerAwareRuleInterface(): void + public function testExtendsAbstractLayerAwareRule(): void { $this->assertInstanceOf( - LayerAwareRuleInterface::class, + AbstractLayerAwareRule::class, new MayNotDependOnRule(from: 'Domain', to: 'Infrastructure') ); } From 5d7151963d840aa5f1eadf9ce38f4acf9d9ae89a Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 4 Sep 2026 08:58:09 +0700 Subject: [PATCH 104/104] add getDependencyNode() method --- docs/custom-rules-and-presets.md | 6 +++--- src/Rule/AbstractLayerAwareRule.php | 6 ++++++ src/Rule/Rules/Layer/MayNotDependOnRule.php | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index 6840b9c3..531f04b1 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -179,7 +179,7 @@ Trade-off: only usage within the scanned paths is known. A class-like used solel ## Reading Other Classes' Layers In A Custom Rule -A rule only receives the node it is evaluating. When a check depends on the layer of *another* class — the layer a dependency belongs to, for instance — extend `Boundwize\StructArmed\Rule\AbstractLayerAwareRule`. Before any class is evaluated, the analyser injects the map of every scanned class into its protected `$classNodeMap` property, keyed by fully qualified class name: +A rule only receives the node it is evaluating. When a check depends on the layer of *another* class — the layer a dependency belongs to, for instance — extend `Boundwize\StructArmed\Rule\AbstractLayerAwareRule`. Before any class is evaluated, the analyser injects the map of every scanned class into its protected `$classNodeMap` property, keyed by fully qualified class name, and `getDependencyNode()` looks a class up in it: ```php dependencies as $dependency) { - $dependencyNode = $this->classNodeMap[$dependency] ?? null; + $dependencyNode = $this->getDependencyNode($dependency); if (! $dependencyNode instanceof ClassNode || $dependencyNode->isInLayer('Application')) { continue; @@ -223,7 +223,7 @@ final class ControllerMayOnlyDependOnApplicationRule extends AbstractLayerAwareR } ``` -The base class holds the property and its `injectClassNodeMap()` setter, so the rule adds nothing but the check itself. A dependency outside the scanned paths — a vendor class, a PHP built-in — has no entry in the map, so fall back to path or namespace matching for those, or skip them as above. The built-in `MayNotDependOnRule` follows this pattern: it reads the dependency's layers from the map first and falls back to the `toPath` prefix only when the dependency was not scanned. +The base class holds the property, its `injectClassNodeMap()` setter, and the `getDependencyNode()` lookup, so the rule adds nothing but the check itself. A dependency outside the scanned paths — a vendor class, a PHP built-in — has no entry in the map, so `getDependencyNode()` returns null for it; fall back to path or namespace matching for those, or skip them as above. The built-in `MayNotDependOnRule` follows this pattern: it reads the dependency's layers from the map first and falls back to the `toPath` prefix only when the dependency was not scanned. ## Analysing Functions, Closures, And Anonymous Classes diff --git a/src/Rule/AbstractLayerAwareRule.php b/src/Rule/AbstractLayerAwareRule.php index 46b58704..4faf2b40 100644 --- a/src/Rule/AbstractLayerAwareRule.php +++ b/src/Rule/AbstractLayerAwareRule.php @@ -21,4 +21,10 @@ public function injectClassNodeMap(array $classNodeMap): void { $this->classNodeMap = $classNodeMap; } + + /** The scanned node of a dependency, or null when it lies outside the scanned paths */ + protected function getDependencyNode(string $dependency): ?ClassNode + { + return $this->classNodeMap[$dependency] ?? null; + } } diff --git a/src/Rule/Rules/Layer/MayNotDependOnRule.php b/src/Rule/Rules/Layer/MayNotDependOnRule.php index b2af9772..d7eed8e1 100644 --- a/src/Rule/Rules/Layer/MayNotDependOnRule.php +++ b/src/Rule/Rules/Layer/MayNotDependOnRule.php @@ -73,7 +73,7 @@ className: $classNode->className, private function isInForbiddenLayer(string $dependency): bool { // Priority 1: Use the scanned dependency node if available - $dependencyNode = $this->classNodeMap[$dependency] ?? null; + $dependencyNode = $this->getDependencyNode($dependency); if ($dependencyNode instanceof ClassNode && $dependencyNode->layers !== []) { return in_array($this->to, $dependencyNode->layers, true);