From b52856c626724466eb4c23482e43614bce6177b6 Mon Sep 17 00:00:00 2001 From: Michele Orselli Date: Tue, 31 Mar 2026 22:52:12 +0200 Subject: [PATCH 1/6] wip --- src/Analyzer/FQCNToFilePathResolver.php | 121 +++++++++++++++++ src/Analyzer/ScannedFQCNResolver.php | 173 ++++++++++++++++++++++++ src/CLI/Runner.php | 77 +++++++++++ 3 files changed, 371 insertions(+) create mode 100644 src/Analyzer/FQCNToFilePathResolver.php create mode 100644 src/Analyzer/ScannedFQCNResolver.php diff --git a/src/Analyzer/FQCNToFilePathResolver.php b/src/Analyzer/FQCNToFilePathResolver.php new file mode 100644 index 00000000..25fba533 --- /dev/null +++ b/src/Analyzer/FQCNToFilePathResolver.php @@ -0,0 +1,121 @@ +> + */ + private array $psr4Map; + + /** + * @param array> $psr4Map + */ + private function __construct(array $psr4Map) + { + uksort($psr4Map, static fn (string $a, string $b): int => \strlen($b) - \strlen($a)); + $this->psr4Map = $psr4Map; + } + + public static function create(): self + { + $composerJsonPath = self::findComposerJson((string) getcwd()); + + if (null === $composerJsonPath) { + return new self([]); + } + + return self::fromComposerJson($composerJsonPath); + } + + public static function fromComposerJson(string $composerJsonPath): self + { + $content = file_get_contents($composerJsonPath); + + if (false === $content) { + return new self([]); + } + + $data = json_decode($content, true); + + if (!\is_array($data)) { + return new self([]); + } + + $baseDir = \dirname($composerJsonPath); + $psr4Map = []; + + foreach (['autoload', 'autoload-dev'] as $section) { + if (!isset($data[$section]['psr-4']) || !\is_array($data[$section]['psr-4'])) { + continue; + } + + foreach ($data[$section]['psr-4'] as $prefix => $dirs) { + if (\is_string($dirs)) { + $dirs = [$dirs]; + } + + if (!isset($psr4Map[$prefix])) { + $psr4Map[$prefix] = []; + } + + foreach ($dirs as $dir) { + $psr4Map[$prefix][] = $baseDir.\DIRECTORY_SEPARATOR.rtrim($dir, '/\\'); + } + } + } + + return new self($psr4Map); + } + + public function resolve(string $fqcn): ?string + { + $fqcn = ltrim($fqcn, '\\'); + + foreach ($this->psr4Map as $prefix => $baseDirs) { + if (0 !== strncmp($fqcn, $prefix, \strlen($prefix))) { + continue; + } + + $relativeClass = substr($fqcn, \strlen($prefix)); + $relativeFile = str_replace('\\', \DIRECTORY_SEPARATOR, $relativeClass).'.php'; + + foreach ($baseDirs as $baseDir) { + $realPath = realpath($baseDir.\DIRECTORY_SEPARATOR.$relativeFile); + + if (false !== $realPath) { + return $realPath; + } + } + } + + return null; + } + + private static function findComposerJson(string $startDir): ?string + { + $dir = $startDir; + + while (true) { + $candidate = $dir.\DIRECTORY_SEPARATOR.'composer.json'; + + if (file_exists($candidate)) { + return $candidate; + } + + $parent = \dirname($dir); + + if ($parent === $dir) { + return null; + } + + $dir = $parent; + } + } +} diff --git a/src/Analyzer/ScannedFQCNResolver.php b/src/Analyzer/ScannedFQCNResolver.php new file mode 100644 index 00000000..55bdd159 --- /dev/null +++ b/src/Analyzer/ScannedFQCNResolver.php @@ -0,0 +1,173 @@ + FQCN → absolute file path */ + private array $index = []; + + /** + * @param list $directories absolute paths of directories to scan + */ + public function __construct(array $directories) + { + if ([] === $directories) { + return; + } + + $finder = (new Finder()) + ->files() + ->in($directories) + ->name('*.php') + ->ignoreUnreadableDirs(true) + ->ignoreVCS(true); + + /** @var SplFileInfo $file */ + foreach ($finder as $file) { + $realPath = $file->getRealPath(); + if (false === $realPath) { + continue; + } + + foreach ($this->extractFQCNs($file->getContents()) as $fqcn) { + $this->index[$fqcn] = $realPath; + } + } + } + + /** + * Build a resolver by scanning the directories listed in a composer.json + * autoload and autoload-dev psr-4/classmap sections. + */ + public static function fromComposerJson(string $composerJsonPath): self + { + $content = file_get_contents($composerJsonPath); + + if (false === $content) { + return new self([]); + } + + $data = json_decode($content, true); + + if (!\is_array($data)) { + return new self([]); + } + + $baseDir = \dirname($composerJsonPath); + $directories = []; + + foreach (['autoload', 'autoload-dev'] as $section) { + if (!isset($data[$section]) || !\is_array($data[$section])) { + continue; + } + + foreach (['psr-4', 'psr-0'] as $standard) { + if (!isset($data[$section][$standard]) || !\is_array($data[$section][$standard])) { + continue; + } + + foreach ($data[$section][$standard] as $dirs) { + foreach ((array) $dirs as $dir) { + $abs = $baseDir.\DIRECTORY_SEPARATOR.rtrim((string) $dir, '/\\'); + if (is_dir($abs)) { + $directories[] = $abs; + } + } + } + } + + if (isset($data[$section]['classmap']) && \is_array($data[$section]['classmap'])) { + foreach ($data[$section]['classmap'] as $path) { + $abs = $baseDir.\DIRECTORY_SEPARATOR.rtrim((string) $path, '/\\'); + if (is_dir($abs)) { + $directories[] = $abs; + } + } + } + } + + return new self(array_values(array_unique($directories))); + } + + /** + * Walk up from $startDir until composer.json is found, then delegate to + * fromComposerJson(). Returns an empty resolver when not found. + */ + public static function create(): self + { + $composerJsonPath = self::findComposerJson((string) getcwd()); + + if (null === $composerJsonPath) { + return new self([]); + } + + return self::fromComposerJson($composerJsonPath); + } + + public function resolve(string $fqcn): ?string + { + return $this->index[ltrim($fqcn, '\\')] ?? null; + } + + /** + * @return list all FQCNs declared in the given PHP source + */ + private function extractFQCNs(string $source): array + { + $namespace = ''; + + if (preg_match('/^\s*namespace\s+([\w\\\\]+)\s*[;{]/m', $source, $nsMatch)) { + $namespace = $nsMatch[1]; + } + + if (0 === preg_match_all( + '/^\s*(?:(?:abstract|final|readonly)\s+)*(?:class|interface|trait|enum)\s+(\w+)/m', + $source, + $classMatches + )) { + return []; + } + + $fqcns = []; + + foreach ($classMatches[1] as $className) { + $fqcns[] = '' !== $namespace ? $namespace.'\\'.$className : $className; + } + + return $fqcns; + } + + private static function findComposerJson(string $startDir): ?string + { + $dir = $startDir; + + while (true) { + $candidate = $dir.\DIRECTORY_SEPARATOR.'composer.json'; + + if (file_exists($candidate)) { + return $candidate; + } + + $parent = \dirname($dir); + + if ($parent === $dir) { + return null; + } + + $dir = $parent; + } + } +} diff --git a/src/CLI/Runner.php b/src/CLI/Runner.php index c6c0bd16..51b6a5bc 100644 --- a/src/CLI/Runner.php +++ b/src/CLI/Runner.php @@ -7,6 +7,7 @@ use Arkitect\Analyzer\ClassDescription; use Arkitect\Analyzer\FileParserFactory; use Arkitect\Analyzer\FilesToParse; +use Arkitect\Analyzer\FQCNToFilePathResolver; use Arkitect\Analyzer\ParsedFiles; use Arkitect\Analyzer\Parser; use Arkitect\Analyzer\ParsingErrors; @@ -119,6 +120,12 @@ protected function collectFilesToParse(ClassSetRules $classSetRule): FilesToPars protected function collectParsedFiles(FilesToParse $filesToParse, Parser $fileParser, Progress $progress): ParsedFiles { $parsedFiles = new ParsedFiles(); + /** @var array $parsedAbsolutePaths */ + $parsedAbsolutePaths = []; + /** @var array $fqcnsQueue */ + $fqcnsQueue = []; + /** @var array $resolvedFQCNs */ + $resolvedFQCNs = []; /** @var SplFileInfo $file */ foreach ($filesToParse as $file) { @@ -128,9 +135,51 @@ protected function collectParsedFiles(FilesToParse $filesToParse, Parser $filePa $parsedFiles->add($file->getRelativePathname(), $result); + $realPath = $file->getRealPath(); + if (false !== $realPath) { + $parsedAbsolutePaths[$realPath] = true; + } + + /** @var ClassDescription $classDescription */ + foreach ($result->classDescriptions() as $classDescription) { + $this->collectExtensionPoints($classDescription, $fqcnsQueue, $resolvedFQCNs); + } + $progress->endParsingFile($file->getRelativePathname()); } + $resolver = FQCNToFilePathResolver::create(); + + while (!empty($fqcnsQueue)) { + $fqcn = array_shift($fqcnsQueue); + + if (isset($resolvedFQCNs[$fqcn])) { + continue; + } + $resolvedFQCNs[$fqcn] = true; + + $absolutePath = $resolver->resolve($fqcn); + + if (null === $absolutePath || isset($parsedAbsolutePaths[$absolutePath])) { + continue; + } + + $parsedAbsolutePaths[$absolutePath] = true; + + $content = file_get_contents($absolutePath); + if (false === $content) { + continue; + } + + $result = $fileParser->parse($content, $absolutePath); + $parsedFiles->add($absolutePath, $result); + + /** @var ClassDescription $classDescription */ + foreach ($result->classDescriptions() as $classDescription) { + $this->collectExtensionPoints($classDescription, $fqcnsQueue, $resolvedFQCNs); + } + } + return $parsedFiles; } @@ -169,4 +218,32 @@ protected function doRun(Config $config, Progress $progress): array return [$violations, $parsingErrors]; } + + /** + * @param array $queue + * @param array $resolved + */ + private function collectExtensionPoints(ClassDescription $classDescription, array &$queue, array $resolved): void + { + foreach ($classDescription->getInterfaces() as $interface) { + $fqcn = $interface->toString(); + if (!isset($resolved[$fqcn])) { + $queue[] = $fqcn; + } + } + + foreach ($classDescription->getExtends() as $extends) { + $fqcn = $extends->toString(); + if (!isset($resolved[$fqcn])) { + $queue[] = $fqcn; + } + } + + foreach ($classDescription->getTraits() as $trait) { + $fqcn = $trait->toString(); + if (!isset($resolved[$fqcn])) { + $queue[] = $fqcn; + } + } + } } From 6169d871e2ae0b98a8e4b433b82559306ad23c0f Mon Sep 17 00:00:00 2001 From: Michele Orselli Date: Wed, 1 Apr 2026 21:32:48 +0200 Subject: [PATCH 2/6] wip --- src/Analyzer/ClassDescription.php | 11 ++++ src/Analyzer/FQCNToFilePathResolver.php | 6 +- src/Analyzer/FilesToParse.php | 9 ++- src/CLI/Runner.php | 83 ++++--------------------- tests/E2E/Cli/CheckCommandTest.php | 2 + tests/E2E/Smoke/RunArkitectBinTest.php | 2 +- tests/Utils/TestRunner.php | 9 ++- 7 files changed, 45 insertions(+), 77 deletions(-) diff --git a/src/Analyzer/ClassDescription.php b/src/Analyzer/ClassDescription.php index 7d70b603..c7cfd5ca 100644 --- a/src/Analyzer/ClassDescription.php +++ b/src/Analyzer/ClassDescription.php @@ -228,4 +228,15 @@ public function hasTrait(string $pattern): bool false ); } + + public function getExtensionPoints(): array + { + $interfaces = array_map(static fn (FullyQualifiedClassName $interface) => $interface->toString(), $this->getInterfaces()); + + $extends = array_map(static fn (FullyQualifiedClassName $extends) => $extends->toString(), $this->getExtends()); + + $traits = array_map(static fn (FullyQualifiedClassName $trait) => $trait->toString(), $this->getTraits()); + + return array_merge($interfaces, $extends, $traits); + } } diff --git a/src/Analyzer/FQCNToFilePathResolver.php b/src/Analyzer/FQCNToFilePathResolver.php index 25fba533..b04e6d6c 100644 --- a/src/Analyzer/FQCNToFilePathResolver.php +++ b/src/Analyzer/FQCNToFilePathResolver.php @@ -4,6 +4,8 @@ namespace Arkitect\Analyzer; +use Symfony\Component\Finder\SplFileInfo; + class FQCNToFilePathResolver { /** @@ -74,7 +76,7 @@ public static function fromComposerJson(string $composerJsonPath): self return new self($psr4Map); } - public function resolve(string $fqcn): ?string + public function resolve(string $fqcn): ?SplFileInfo { $fqcn = ltrim($fqcn, '\\'); @@ -90,7 +92,7 @@ public function resolve(string $fqcn): ?string $realPath = realpath($baseDir.\DIRECTORY_SEPARATOR.$relativeFile); if (false !== $realPath) { - return $realPath; + return new SplFileInfo($realPath, $relativeFile, $baseDir); } } } diff --git a/src/Analyzer/FilesToParse.php b/src/Analyzer/FilesToParse.php index 34b0fdd3..e1bec725 100644 --- a/src/Analyzer/FilesToParse.php +++ b/src/Analyzer/FilesToParse.php @@ -7,16 +7,21 @@ use Symfony\Component\Finder\SplFileInfo; /** + * An absolute path indexed collection of files to be parsed. + * * @template-implements \IteratorAggregate */ class FilesToParse implements \IteratorAggregate { - /** @var array */ + /** @var array */ private array $files = []; public function add(SplFileInfo $file): void { - $this->files[] = $file; + // for vfsStream based filesystems (the one used in tests) getRealPath returns null + $key = $file->getRealPath() ?: $file->getPathname(); + + $this->files[$key] = $file; } public function getIterator(): \Traversable diff --git a/src/CLI/Runner.php b/src/CLI/Runner.php index 51b6a5bc..ca8ef316 100644 --- a/src/CLI/Runner.php +++ b/src/CLI/Runner.php @@ -93,6 +93,7 @@ public function checkRulesOnParsedFiles( foreach ($classSetRule->getRules() as $rule) { $rule->check($classDescription, $fileViolations); + // workaround to avoid collecting all violations if we want to stop on first failure if ($stopOnFailure && $fileViolations->count() > 0) { $violations->merge($fileViolations); @@ -120,12 +121,7 @@ protected function collectFilesToParse(ClassSetRules $classSetRule): FilesToPars protected function collectParsedFiles(FilesToParse $filesToParse, Parser $fileParser, Progress $progress): ParsedFiles { $parsedFiles = new ParsedFiles(); - /** @var array $parsedAbsolutePaths */ - $parsedAbsolutePaths = []; - /** @var array $fqcnsQueue */ - $fqcnsQueue = []; - /** @var array $resolvedFQCNs */ - $resolvedFQCNs = []; + $resolver = FQCNToFilePathResolver::create(); /** @var SplFileInfo $file */ foreach ($filesToParse as $file) { @@ -135,49 +131,22 @@ protected function collectParsedFiles(FilesToParse $filesToParse, Parser $filePa $parsedFiles->add($file->getRelativePathname(), $result); - $realPath = $file->getRealPath(); - if (false !== $realPath) { - $parsedAbsolutePaths[$realPath] = true; - } - - /** @var ClassDescription $classDescription */ + // collect extension points to parse them as well foreach ($result->classDescriptions() as $classDescription) { - $this->collectExtensionPoints($classDescription, $fqcnsQueue, $resolvedFQCNs); - } + $fqcnToResolve = $classDescription->getExtensionPoints(); - $progress->endParsingFile($file->getRelativePathname()); - } - - $resolver = FQCNToFilePathResolver::create(); + foreach ($fqcnToResolve as $fqcn) { + $fileToParse = $resolver->resolve($fqcn); - while (!empty($fqcnsQueue)) { - $fqcn = array_shift($fqcnsQueue); - - if (isset($resolvedFQCNs[$fqcn])) { - continue; - } - $resolvedFQCNs[$fqcn] = true; - - $absolutePath = $resolver->resolve($fqcn); - - if (null === $absolutePath || isset($parsedAbsolutePaths[$absolutePath])) { - continue; - } - - $parsedAbsolutePaths[$absolutePath] = true; + if (null === $fileToParse) { + continue; // throw an error? + } - $content = file_get_contents($absolutePath); - if (false === $content) { - continue; + $filesToParse->add($fileToParse); + } } - $result = $fileParser->parse($content, $absolutePath); - $parsedFiles->add($absolutePath, $result); - - /** @var ClassDescription $classDescription */ - foreach ($result->classDescriptions() as $classDescription) { - $this->collectExtensionPoints($classDescription, $fqcnsQueue, $resolvedFQCNs); - } + $progress->endParsingFile($file->getRelativePathname()); } return $parsedFiles; @@ -218,32 +187,4 @@ protected function doRun(Config $config, Progress $progress): array return [$violations, $parsingErrors]; } - - /** - * @param array $queue - * @param array $resolved - */ - private function collectExtensionPoints(ClassDescription $classDescription, array &$queue, array $resolved): void - { - foreach ($classDescription->getInterfaces() as $interface) { - $fqcn = $interface->toString(); - if (!isset($resolved[$fqcn])) { - $queue[] = $fqcn; - } - } - - foreach ($classDescription->getExtends() as $extends) { - $fqcn = $extends->toString(); - if (!isset($resolved[$fqcn])) { - $queue[] = $fqcn; - } - } - - foreach ($classDescription->getTraits() as $trait) { - $fqcn = $trait->toString(); - if (!isset($resolved[$fqcn])) { - $queue[] = $fqcn; - } - } - } } diff --git a/tests/E2E/Cli/CheckCommandTest.php b/tests/E2E/Cli/CheckCommandTest.php index c60ef9c5..9b7b0db7 100644 --- a/tests/E2E/Cli/CheckCommandTest.php +++ b/tests/E2E/Cli/CheckCommandTest.php @@ -290,6 +290,8 @@ protected function runCheck( $input['--ignore-baseline-linenumbers'] = true; } + $input['--no-cache'] = true; + // false = option not set, null = option set but without value, string = option with value if (false !== $generateBaseline) { $input['--generate-baseline'] = $generateBaseline; diff --git a/tests/E2E/Smoke/RunArkitectBinTest.php b/tests/E2E/Smoke/RunArkitectBinTest.php index 428f0025..79cd5592 100644 --- a/tests/E2E/Smoke/RunArkitectBinTest.php +++ b/tests/E2E/Smoke/RunArkitectBinTest.php @@ -103,7 +103,7 @@ public function test_only_violations_are_printed_on_stdout(): void $binPath = $this->phparkitect; $configFilePath = __DIR__.'/../_fixtures/configMvc.php'; - $process = Process::fromShellCommandline("php {$binPath} check --config=$configFilePath --format=gitlab > $tmpFile"); + $process = Process::fromShellCommandline("php {$binPath} check --config=$configFilePath --format=gitlab --no-cache > $tmpFile"); $process->run(); $fileContent = file_get_contents($tmpFile); diff --git a/tests/Utils/TestRunner.php b/tests/Utils/TestRunner.php index cc6603a2..53f726e0 100644 --- a/tests/Utils/TestRunner.php +++ b/tests/Utils/TestRunner.php @@ -52,7 +52,14 @@ public function run(string $srcPath, ArchRule ...$rules): void $classSetRules = ClassSetRules::create(ClassSet::fromDir($srcPath), ...$rules); - (new Runner())->check($classSetRules, new VoidProgress(), $this->fileParser, $this->violations, $this->parsingErrors, false); + (new Runner())->check( + $classSetRules, + new VoidProgress(), + $this->fileParser, + $this->violations, + $this->parsingErrors, + false + ); } public function getViolations(): Violations From 55c1dff2cabf12b9dfc6a80e08728f1662c431cc Mon Sep 17 00:00:00 2001 From: Michele Orselli Date: Tue, 7 Apr 2026 21:38:27 +0200 Subject: [PATCH 3/6] Use SplQueue for recursive extension point collection in FilesToParse Replace the associative array in FilesToParse with an SplQueue + seen-set so that files added during parsing (resolved interfaces, traits, parent classes) are actually visited. The previous foreach-based iteration created an ArrayIterator snapshot at loop start, silently dropping all dynamically added files. Runner::collectParsedFiles now uses isEmpty()/shift() directly; the seen-set in FilesToParse persists across dequeues, making the redundant $parsedRealPaths guard in the consumer unnecessary. Co-Authored-By: Claude Sonnet 4.6 --- src/Analyzer/FilesToParse.php | 36 +++++++++++++++++++++++++---------- src/CLI/Runner.php | 13 ++++++------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/Analyzer/FilesToParse.php b/src/Analyzer/FilesToParse.php index e1bec725..49f1c14e 100644 --- a/src/Analyzer/FilesToParse.php +++ b/src/Analyzer/FilesToParse.php @@ -7,25 +7,41 @@ use Symfony\Component\Finder\SplFileInfo; /** - * An absolute path indexed collection of files to be parsed. - * - * @template-implements \IteratorAggregate + * A deduplicated FIFO queue of files to be parsed. */ -class FilesToParse implements \IteratorAggregate +class FilesToParse { - /** @var array */ - private array $files = []; + /** @var \SplQueue */ + private \SplQueue $queue; + + /** @var array */ + private array $seen = []; + + public function __construct() + { + $this->queue = new \SplQueue(); + } public function add(SplFileInfo $file): void { - // for vfsStream based filesystems (the one used in tests) getRealPath returns null $key = $file->getRealPath() ?: $file->getPathname(); - $this->files[$key] = $file; + if (isset($this->seen[$key])) { + return; + } + + $this->seen[$key] = true; + $this->queue->enqueue($file); } - public function getIterator(): \Traversable + public function isEmpty(): bool { - return new \ArrayIterator($this->files); + return $this->queue->isEmpty(); } + + public function shift(): SplFileInfo + { + return $this->queue->dequeue(); + } + } diff --git a/src/CLI/Runner.php b/src/CLI/Runner.php index ca8ef316..fa86864a 100644 --- a/src/CLI/Runner.php +++ b/src/CLI/Runner.php @@ -123,23 +123,22 @@ protected function collectParsedFiles(FilesToParse $filesToParse, Parser $filePa $parsedFiles = new ParsedFiles(); $resolver = FQCNToFilePathResolver::create(); - /** @var SplFileInfo $file */ - foreach ($filesToParse as $file) { + while (!$filesToParse->isEmpty()) { + $file = $filesToParse->shift(); + $progress->startParsingFile($file->getRelativePathname()); $result = $fileParser->parse($file->getContents(), $file->getRelativePathname()); $parsedFiles->add($file->getRelativePathname(), $result); - // collect extension points to parse them as well + // recursively collect extension points (interfaces, traits, parent classes) foreach ($result->classDescriptions() as $classDescription) { - $fqcnToResolve = $classDescription->getExtensionPoints(); - - foreach ($fqcnToResolve as $fqcn) { + foreach ($classDescription->getExtensionPoints() as $fqcn) { $fileToParse = $resolver->resolve($fqcn); if (null === $fileToParse) { - continue; // throw an error? + continue; } $filesToParse->add($fileToParse); From f7f02f4f7589e5c42e3ccae01d769b1335f4a74c Mon Sep 17 00:00:00 2001 From: Michele Orselli Date: Tue, 7 Apr 2026 22:33:55 +0200 Subject: [PATCH 4/6] wip --- src/Analyzer/ClassDescription.php | 23 +++ src/Analyzer/ClassDescriptionIndex.php | 117 +++++++++++ src/Analyzer/ParsedFiles.php | 20 -- src/CLI/Runner.php | 36 ++-- .../Analyzer/ClassDescriptionIndexTest.php | 193 ++++++++++++++++++ 5 files changed, 346 insertions(+), 43 deletions(-) create mode 100644 src/Analyzer/ClassDescriptionIndex.php delete mode 100644 src/Analyzer/ParsedFiles.php create mode 100644 tests/Unit/Analyzer/ClassDescriptionIndexTest.php diff --git a/src/Analyzer/ClassDescription.php b/src/Analyzer/ClassDescription.php index c7cfd5ca..c99b457b 100644 --- a/src/Analyzer/ClassDescription.php +++ b/src/Analyzer/ClassDescription.php @@ -229,6 +229,29 @@ public function hasTrait(string $pattern): bool ); } + /** + * @param list $additionalDependencies + */ + public function withAdditionalDependencies(array $additionalDependencies): self + { + return new self( + $this->FQCN, + array_merge($this->dependencies, $additionalDependencies), + $this->interfaces, + $this->extends, + $this->final, + $this->readonly, + $this->abstract, + $this->interface, + $this->trait, + $this->enum, + $this->docBlock, + $this->attributes, + $this->traits, + $this->filePath + ); + } + public function getExtensionPoints(): array { $interfaces = array_map(static fn (FullyQualifiedClassName $interface) => $interface->toString(), $this->getInterfaces()); diff --git a/src/Analyzer/ClassDescriptionIndex.php b/src/Analyzer/ClassDescriptionIndex.php new file mode 100644 index 00000000..523ac32e --- /dev/null +++ b/src/Analyzer/ClassDescriptionIndex.php @@ -0,0 +1,117 @@ + keyed by relative file path */ + private array $results = []; + + /** @var array keyed by FQCN */ + private array $index = []; + + public function add(string $relativeFilePath, ParserResult $result): void + { + $this->results[$relativeFilePath] = $result; + + foreach ($result->classDescriptions() as $classDescription) { + $this->index[$classDescription->getFQCN()] = $classDescription; + } + } + + public function enrich(): void + { + /** @var array> $resolvedDeps */ + $resolvedDeps = []; + /** @var array $visiting */ + $visiting = []; + + foreach (array_keys($this->index) as $fqcn) { + $extra = $this->resolveDeps($fqcn, $resolvedDeps, $visiting); + + if ([] !== $extra) { + $this->index[$fqcn] = $this->index[$fqcn]->withAdditionalDependencies($extra); + } + } + } + + public function get(string $fqcn): ?ClassDescription + { + return $this->index[$fqcn] ?? null; + } + + /** + * Returns the enriched ClassDescriptions for all classes defined in the given file. + * + * @return iterable + */ + public function getClassDescriptionsFor(string $relativeFilePath): iterable + { + $result = $this->results[$relativeFilePath] ?? null; + + if (null === $result) { + return; + } + + foreach ($result->classDescriptions() as $classDescription) { + yield $this->index[$classDescription->getFQCN()] ?? $classDescription; + } + } + + public function getParsingErrorsFor(string $relativeFilePath): ParsingErrors + { + return $this->results[$relativeFilePath]?->parsingErrors() ?? new ParsingErrors(); + } + + /** + * @param array> $resolvedDeps + * @param array $visiting + * + * @return list + */ + private function resolveDeps(string $fqcn, array &$resolvedDeps, array &$visiting): array + { + if (isset($visiting[$fqcn])) { + return []; + } + + if (isset($resolvedDeps[$fqcn])) { + return $resolvedDeps[$fqcn]; + } + + $visiting[$fqcn] = true; + + $cd = $this->index[$fqcn] ?? null; + + if (null === $cd) { + unset($visiting[$fqcn]); + + return []; + } + + /** @var array $extra keyed by FQCN for dedup */ + $extra = []; + + foreach ($cd->getExtensionPoints() as $ep) { + $epCd = $this->index[$ep] ?? null; + + if (null !== $epCd) { + foreach ($epCd->getDependencies() as $dep) { + $extra[$dep->getFQCN()->toString()] ??= $dep; + } + } + + foreach ($this->resolveDeps($ep, $resolvedDeps, $visiting) as $dep) { + $extra[$dep->getFQCN()->toString()] ??= $dep; + } + } + + unset($visiting[$fqcn]); + + $resolvedDeps[$fqcn] = array_values($extra); + + return $resolvedDeps[$fqcn]; + } +} diff --git a/src/Analyzer/ParsedFiles.php b/src/Analyzer/ParsedFiles.php deleted file mode 100644 index abf96d18..00000000 --- a/src/Analyzer/ParsedFiles.php +++ /dev/null @@ -1,20 +0,0 @@ -data[$relativeFilePath] = $result; - } - - public function get(string $relativeFilePath): ?ParserResult - { - return $this->data[$relativeFilePath] ?? null; - } -} diff --git a/src/CLI/Runner.php b/src/CLI/Runner.php index fa86864a..15dbdea1 100644 --- a/src/CLI/Runner.php +++ b/src/CLI/Runner.php @@ -4,11 +4,10 @@ namespace Arkitect\CLI; -use Arkitect\Analyzer\ClassDescription; +use Arkitect\Analyzer\ClassDescriptionIndex; use Arkitect\Analyzer\FileParserFactory; use Arkitect\Analyzer\FilesToParse; use Arkitect\Analyzer\FQCNToFilePathResolver; -use Arkitect\Analyzer\ParsedFiles; use Arkitect\Analyzer\Parser; use Arkitect\Analyzer\ParsingErrors; use Arkitect\ClassSetRules; @@ -52,17 +51,13 @@ public function check( // first step: collect all files to parse $filesToParse = $this->collectFilesToParse($classSetRule); - // second step: parse all files and collect results - $parsedFiles = $this->collectParsedFiles( - $filesToParse, - $fileParser, - $progress - ); + // second step: parse all files, resolve extension points recursively, enrich deps + $classDescriptionIndex = $this->collectParsedFiles($filesToParse, $fileParser, $progress); // third step: check all rules on all files $this->checkRulesOnParsedFiles( $classSetRule, - $parsedFiles, + $classDescriptionIndex, $violations, $parsingErrors, $stopOnFailure @@ -71,25 +66,18 @@ public function check( public function checkRulesOnParsedFiles( ClassSetRules $classSetRule, - ParsedFiles $parsedFiles, + ClassDescriptionIndex $classDescriptionIndex, Violations $violations, ParsingErrors $parsingErrors, bool $stopOnFailure, ): void { /** @var SplFileInfo $file */ foreach ($classSetRule->getClassSet() as $file) { - $result = $parsedFiles->get($file->getRelativePathname()); - - if (null === $result) { - continue; // this should not happen - } - - $parsingErrors->merge($result->parsingErrors()); + $parsingErrors->merge($classDescriptionIndex->getParsingErrorsFor($file->getRelativePathname())); $fileViolations = new Violations(); - /** @var ClassDescription $classDescription */ - foreach ($result->classDescriptions() as $classDescription) { + foreach ($classDescriptionIndex->getClassDescriptionsFor($file->getRelativePathname()) as $classDescription) { foreach ($classSetRule->getRules() as $rule) { $rule->check($classDescription, $fileViolations); @@ -118,9 +106,9 @@ protected function collectFilesToParse(ClassSetRules $classSetRule): FilesToPars return $filesToParse; } - protected function collectParsedFiles(FilesToParse $filesToParse, Parser $fileParser, Progress $progress): ParsedFiles + protected function collectParsedFiles(FilesToParse $filesToParse, Parser $fileParser, Progress $progress): ClassDescriptionIndex { - $parsedFiles = new ParsedFiles(); + $classDescriptionIndex = new ClassDescriptionIndex(); $resolver = FQCNToFilePathResolver::create(); while (!$filesToParse->isEmpty()) { @@ -130,7 +118,7 @@ protected function collectParsedFiles(FilesToParse $filesToParse, Parser $filePa $result = $fileParser->parse($file->getContents(), $file->getRelativePathname()); - $parsedFiles->add($file->getRelativePathname(), $result); + $classDescriptionIndex->add($file->getRelativePathname(), $result); // recursively collect extension points (interfaces, traits, parent classes) foreach ($result->classDescriptions() as $classDescription) { @@ -148,7 +136,9 @@ protected function collectParsedFiles(FilesToParse $filesToParse, Parser $filePa $progress->endParsingFile($file->getRelativePathname()); } - return $parsedFiles; + $classDescriptionIndex->enrich(); + + return $classDescriptionIndex; } protected function doRun(Config $config, Progress $progress): array diff --git a/tests/Unit/Analyzer/ClassDescriptionIndexTest.php b/tests/Unit/Analyzer/ClassDescriptionIndexTest.php new file mode 100644 index 00000000..b29bf84d --- /dev/null +++ b/tests/Unit/Analyzer/ClassDescriptionIndexTest.php @@ -0,0 +1,193 @@ +setFilePath('src/A.php') + ->setClassName('App\A') + ->addDependency(new ClassDependency('App\Dep', 1)) + ->build(); + + $index = $this->parsedFilesFrom(['src/A.php' => [$cd]]); + + $enriched = $index->get('App\A'); + self::assertNotNull($enriched); + self::assertCount(1, $enriched->getDependencies()); + self::assertEquals('App\Dep', $enriched->getDependencies()[0]->getFQCN()->toString()); + } + + public function test_class_inherits_deps_from_parent(): void + { + $cdA = (new ClassDescriptionBuilder()) + ->setFilePath('src/A.php') + ->setClassName('App\A') + ->addExtends('App\B', 1) + ->build(); + + $cdB = (new ClassDescriptionBuilder()) + ->setFilePath('src/B.php') + ->setClassName('App\B') + ->addDependency(new ClassDependency('App\C', 1)) + ->build(); + + $index = $this->parsedFilesFrom([ + 'src/A.php' => [$cdA], + 'src/B.php' => [$cdB], + ]); + + $enriched = $index->get('App\A'); + self::assertNotNull($enriched); + + $fqcns = $this->depFqcns($enriched->getDependencies()); + self::assertContains('App\B', $fqcns); + self::assertContains('App\C', $fqcns); + } + + public function test_interface_chain_is_resolved_transitively(): void + { + // A implements B, B extends C, C has its own dep D + $cdA = (new ClassDescriptionBuilder()) + ->setFilePath('src/A.php') + ->setClassName('App\A') + ->addInterface('App\B', 1) + ->build(); + + $cdB = (new ClassDescriptionBuilder()) + ->setFilePath('src/B.php') + ->setClassName('App\B') + ->addInterface('App\C', 1) + ->setInterface(true) + ->build(); + + $cdC = (new ClassDescriptionBuilder()) + ->setFilePath('src/C.php') + ->setClassName('App\C') + ->addDependency(new ClassDependency('App\D', 1)) + ->setInterface(true) + ->build(); + + $index = $this->parsedFilesFrom([ + 'src/A.php' => [$cdA], + 'src/B.php' => [$cdB], + 'src/C.php' => [$cdC], + ]); + + $enriched = $index->get('App\A'); + self::assertNotNull($enriched); + + $fqcns = $this->depFqcns($enriched->getDependencies()); + self::assertContains('App\B', $fqcns); + self::assertContains('App\C', $fqcns); + self::assertContains('App\D', $fqcns); + } + + public function test_diamond_inheritance_does_not_duplicate_deps(): void + { + // A extends B and C, B extends D, C extends D — D's deps should appear once + $cdA = (new ClassDescriptionBuilder()) + ->setFilePath('src/A.php') + ->setClassName('App\A') + ->addExtends('App\B', 1) + ->addInterface('App\C', 2) + ->build(); + + $cdB = (new ClassDescriptionBuilder()) + ->setFilePath('src/B.php') + ->setClassName('App\B') + ->addExtends('App\D', 1) + ->build(); + + $cdC = (new ClassDescriptionBuilder()) + ->setFilePath('src/C.php') + ->setClassName('App\C') + ->addInterface('App\D', 1) + ->setInterface(true) + ->build(); + + $cdD = (new ClassDescriptionBuilder()) + ->setFilePath('src/D.php') + ->setClassName('App\D') + ->addDependency(new ClassDependency('App\Shared', 1)) + ->build(); + + $index = $this->parsedFilesFrom([ + 'src/A.php' => [$cdA], + 'src/B.php' => [$cdB], + 'src/C.php' => [$cdC], + 'src/D.php' => [$cdD], + ]); + + $enriched = $index->get('App\A'); + self::assertNotNull($enriched); + + $fqcns = $this->depFqcns($enriched->getDependencies()); + self::assertContains('App\Shared', $fqcns); + self::assertCount(1, array_filter($fqcns, static fn (string $f): bool => 'App\Shared' === $f)); + } + + public function test_cycle_does_not_cause_infinite_loop(): void + { + $cdA = (new ClassDescriptionBuilder()) + ->setFilePath('src/A.php') + ->setClassName('App\A') + ->addInterface('App\B', 1) + ->build(); + + $cdB = (new ClassDescriptionBuilder()) + ->setFilePath('src/B.php') + ->setClassName('App\B') + ->addInterface('App\A', 1) + ->setInterface(true) + ->build(); + + $index = $this->parsedFilesFrom([ + 'src/A.php' => [$cdA], + 'src/B.php' => [$cdB], + ]); + + // Must not throw or loop forever + + self::assertNotNull($index->get('App\A')); + self::assertNotNull($index->get('App\B')); + } + + // --- helpers --- + + private function parsedFilesFrom(array $map): ClassDescriptionIndex + { + $index = new ClassDescriptionIndex(); + + foreach ($map as $path => $classDescriptions) { + $index->add( + $path, + ParserResult::withClassDescriptions(new ClassDescriptions($classDescriptions)) + ); + } + + $index->enrich(); + + return $index; + } + + /** @return list */ + private function depFqcns(array $deps): array + { + return array_values(array_map( + static fn (ClassDependency $d): string => $d->getFQCN()->toString(), + $deps + )); + } +} From d62ada2e6306e1b5e9436f6ef3b2415355f470ed Mon Sep 17 00:00:00 2001 From: Michele Orselli Date: Tue, 7 Apr 2026 23:04:22 +0200 Subject: [PATCH 5/6] csfix --- src/Analyzer/FilesToParse.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Analyzer/FilesToParse.php b/src/Analyzer/FilesToParse.php index 49f1c14e..d9f266ff 100644 --- a/src/Analyzer/FilesToParse.php +++ b/src/Analyzer/FilesToParse.php @@ -43,5 +43,4 @@ public function shift(): SplFileInfo { return $this->queue->dequeue(); } - } From a8b112f7332c9f321ebfba9153511bf54839d5d8 Mon Sep 17 00:00:00 2001 From: Michele Orselli Date: Tue, 7 Apr 2026 23:11:46 +0200 Subject: [PATCH 6/6] wip --- src/Analyzer/FilesToParse.php | 9 ++++----- src/CLI/Runner.php | 15 +++++++-------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/Analyzer/FilesToParse.php b/src/Analyzer/FilesToParse.php index d9f266ff..7c9c4d34 100644 --- a/src/Analyzer/FilesToParse.php +++ b/src/Analyzer/FilesToParse.php @@ -34,13 +34,12 @@ public function add(SplFileInfo $file): void $this->queue->enqueue($file); } - public function isEmpty(): bool + public function next(): ?SplFileInfo { - return $this->queue->isEmpty(); - } + if ($this->queue->isEmpty()) { + return null; + } - public function shift(): SplFileInfo - { return $this->queue->dequeue(); } } diff --git a/src/CLI/Runner.php b/src/CLI/Runner.php index 15dbdea1..49bf47d3 100644 --- a/src/CLI/Runner.php +++ b/src/CLI/Runner.php @@ -51,10 +51,13 @@ public function check( // first step: collect all files to parse $filesToParse = $this->collectFilesToParse($classSetRule); - // second step: parse all files, resolve extension points recursively, enrich deps + // second step: parse all files and collect class descriptions (and parsing errors) in an index $classDescriptionIndex = $this->collectParsedFiles($filesToParse, $fileParser, $progress); - // third step: check all rules on all files + // third step: enrich class descriptions with resolved dependencies + $classDescriptionIndex->enrich(); + + // fourth step: check all rules on all files $this->checkRulesOnParsedFiles( $classSetRule, $classDescriptionIndex, @@ -111,16 +114,14 @@ protected function collectParsedFiles(FilesToParse $filesToParse, Parser $filePa $classDescriptionIndex = new ClassDescriptionIndex(); $resolver = FQCNToFilePathResolver::create(); - while (!$filesToParse->isEmpty()) { - $file = $filesToParse->shift(); - + while ($file = $filesToParse->next()) { $progress->startParsingFile($file->getRelativePathname()); $result = $fileParser->parse($file->getContents(), $file->getRelativePathname()); $classDescriptionIndex->add($file->getRelativePathname(), $result); - // recursively collect extension points (interfaces, traits, parent classes) + // collect extension points (interfaces, traits, parent classes) and add them to the queue foreach ($result->classDescriptions() as $classDescription) { foreach ($classDescription->getExtensionPoints() as $fqcn) { $fileToParse = $resolver->resolve($fqcn); @@ -136,8 +137,6 @@ protected function collectParsedFiles(FilesToParse $filesToParse, Parser $filePa $progress->endParsingFile($file->getRelativePathname()); } - $classDescriptionIndex->enrich(); - return $classDescriptionIndex; }