From 99e4e7536e245149eb7d2f97d2f68755af144a84 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 6 Sep 2026 22:48:38 +0700 Subject: [PATCH 1/2] refactor: Simplify NamespaceLayerResolver with a longest-first path list --- .../Resolvers/NamespaceLayerResolver.php | 53 ++++++++----------- tests/Analyser/FunctionLikeCollectionTest.php | 2 +- .../NamespaceLayerResolverTest.php | 4 +- 3 files changed, 25 insertions(+), 34 deletions(-) diff --git a/src/LayerResolver/Resolvers/NamespaceLayerResolver.php b/src/LayerResolver/Resolvers/NamespaceLayerResolver.php index 4ec15878..78be91bb 100644 --- a/src/LayerResolver/Resolvers/NamespaceLayerResolver.php +++ b/src/LayerResolver/Resolvers/NamespaceLayerResolver.php @@ -7,8 +7,10 @@ use Boundwize\StructArmed\LayerResolver\LayerResolverInterface; use Boundwize\StructArmed\Util\Path; +use function in_array; use function str_starts_with; use function strlen; +use function usort; /** * Resolves a layer by matching the file path against registered layer paths. @@ -22,11 +24,12 @@ /** * 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. + * descendant matches. Longest path first, so the first match is the most + * specific layer; equal lengths keep declaration order. * - * @var array> + * @var list [layerPath, layerName] */ - private array $normalisedLayers; + private array $layerPaths; /** * @param array> $layers Map of layer name → path prefixes @@ -35,56 +38,44 @@ public function __construct( array $layers, string $basePath, ) { - $normalisedLayers = []; + $layerPaths = []; - foreach ($layers as $layerName => $layerPaths) { - foreach ((array) $layerPaths as $layerPath) { - $normalisedLayers[$layerName][] = Path::normalise( - Path::resolve($layerPath, $basePath), - canonicalise: true - ) . '/'; + foreach ($layers as $layerName => $paths) { + foreach ((array) $paths as $path) { + $layerPath = Path::normalise(Path::resolve($path, $basePath), canonicalise: true) . '/'; + $layerPaths[] = [$layerPath, $layerName]; } } - $this->normalisedLayers = $normalisedLayers; + usort($layerPaths, static fn (array $a, array $b): int => strlen($b[0]) <=> strlen($a[0])); + + $this->layerPaths = $layerPaths; } public function resolve(string $className, string $filePath): ?string { $pathWithSlash = Path::normalise($filePath, canonicalise: true) . '/'; - $matchedLayer = null; - $matchedLength = -1; - - foreach ($this->normalisedLayers as $layerName => $layerPaths) { - foreach ($layerPaths as $layerPath) { - if (str_starts_with($pathWithSlash, $layerPath)) { - $length = strlen($layerPath); - if ($length > $matchedLength) { - $matchedLayer = $layerName; - $matchedLength = $length; - } - } + foreach ($this->layerPaths as [$layerPath, $layerName]) { + if (str_starts_with($pathWithSlash, $layerPath)) { + return $layerName; } } - return $matchedLayer; + return null; } /** - * @return int[]|string[] + * @return list */ public function resolveAll(string $className, string $filePath): array { $pathWithSlash = Path::normalise($filePath, canonicalise: true) . '/'; $matched = []; - foreach ($this->normalisedLayers as $layerName => $layerPaths) { - foreach ($layerPaths as $layerPath) { - if (str_starts_with($pathWithSlash, $layerPath)) { - $matched[] = $layerName; - break; - } + foreach ($this->layerPaths as [$layerPath, $layerName]) { + if (str_starts_with($pathWithSlash, $layerPath) && ! in_array($layerName, $matched, true)) { + $matched[] = $layerName; } } diff --git a/tests/Analyser/FunctionLikeCollectionTest.php b/tests/Analyser/FunctionLikeCollectionTest.php index 2035890b..9c95f4d0 100644 --- a/tests/Analyser/FunctionLikeCollectionTest.php +++ b/tests/Analyser/FunctionLikeCollectionTest.php @@ -113,7 +113,7 @@ public function testSelectsMostSpecificLayerWhenMultipleLayersMatch(): void $functionNode = $analysisNodeCollector->getFunctionNodes()[0]; $this->assertSame('Domain', $functionNode->layer); - $this->assertSame(['Source', 'Domain'], $functionNode->layers); + $this->assertSame(['Domain', 'Source'], $functionNode->layers); } public function testCollectsFunctionDependenciesWithoutSeedingNamespaceImports(): void diff --git a/tests/LayerResolver/NamespaceLayerResolverTest.php b/tests/LayerResolver/NamespaceLayerResolverTest.php index 7d0b4052..c5b26e4e 100644 --- a/tests/LayerResolver/NamespaceLayerResolverTest.php +++ b/tests/LayerResolver/NamespaceLayerResolverTest.php @@ -185,7 +185,7 @@ public function testReusesCachedMatchesForSameFilePath(): void $filePath = $this->basePath . '/src/Domain/Order.php'; $this->assertSame('Domain', $chainLayerResolver->resolve('App\\Domain\\Order', $filePath)); - $this->assertSame(['Source', 'Domain'], $chainLayerResolver->resolveAll('App\\Domain\\Order', $filePath)); + $this->assertSame(['Domain', 'Source'], $chainLayerResolver->resolveAll('App\\Domain\\Order', $filePath)); } public function testChainResolverCachesResolveResult(): void @@ -247,7 +247,7 @@ public function testChainResolverResolveHitsCacheAfterResolveAll(): void $layers = $chainLayerResolver->resolveAll('App\\Domain\\Order', $filePath); $layer = $chainLayerResolver->resolve('App\\Domain\\Order', $filePath); - $this->assertSame(['Source', 'Domain'], $layers); + $this->assertSame(['Domain', 'Source'], $layers); $this->assertSame('Domain', $layer); } From f6399df5a33648d21b9156c1429ffa3d2919678e Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 6 Sep 2026 22:56:04 +0700 Subject: [PATCH 2/2] fix --- .../Resolvers/NamespaceLayerResolver.php | 46 ++++++++++++------- tests/Analyser/FunctionLikeCollectionTest.php | 2 +- .../NamespaceLayerResolverTest.php | 4 +- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/LayerResolver/Resolvers/NamespaceLayerResolver.php b/src/LayerResolver/Resolvers/NamespaceLayerResolver.php index 78be91bb..5b8d3506 100644 --- a/src/LayerResolver/Resolvers/NamespaceLayerResolver.php +++ b/src/LayerResolver/Resolvers/NamespaceLayerResolver.php @@ -7,7 +7,6 @@ use Boundwize\StructArmed\LayerResolver\LayerResolverInterface; use Boundwize\StructArmed\Util\Path; -use function in_array; use function str_starts_with; use function strlen; use function usort; @@ -24,12 +23,20 @@ /** * 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. Longest path first, so the first match is the most - * specific layer; equal lengths keep declaration order. + * descendant matches. Kept in declaration order for resolveAll(). + * + * @var array> + */ + private array $normalisedLayers; + + /** + * The same paths flattened and sorted longest first, so resolve() can + * return on the first match: it is always the most specific layer. + * Equal lengths keep declaration order. * * @var list [layerPath, layerName] */ - private array $layerPaths; + private array $layerPathsLongestFirst; /** * @param array> $layers Map of layer name → path prefixes @@ -38,25 +45,29 @@ public function __construct( array $layers, string $basePath, ) { - $layerPaths = []; + $normalisedLayers = []; + $layerPathsLongestFirst = []; + + foreach ($layers as $layerName => $layerPaths) { + foreach ((array) $layerPaths as $layerPath) { + $normalisedPath = Path::normalise(Path::resolve($layerPath, $basePath), canonicalise: true) . '/'; - foreach ($layers as $layerName => $paths) { - foreach ((array) $paths as $path) { - $layerPath = Path::normalise(Path::resolve($path, $basePath), canonicalise: true) . '/'; - $layerPaths[] = [$layerPath, $layerName]; + $normalisedLayers[$layerName][] = $normalisedPath; + $layerPathsLongestFirst[] = [$normalisedPath, $layerName]; } } - usort($layerPaths, static fn (array $a, array $b): int => strlen($b[0]) <=> strlen($a[0])); + usort($layerPathsLongestFirst, static fn (array $a, array $b): int => strlen($b[0]) <=> strlen($a[0])); - $this->layerPaths = $layerPaths; + $this->normalisedLayers = $normalisedLayers; + $this->layerPathsLongestFirst = $layerPathsLongestFirst; } public function resolve(string $className, string $filePath): ?string { $pathWithSlash = Path::normalise($filePath, canonicalise: true) . '/'; - foreach ($this->layerPaths as [$layerPath, $layerName]) { + foreach ($this->layerPathsLongestFirst as [$layerPath, $layerName]) { if (str_starts_with($pathWithSlash, $layerPath)) { return $layerName; } @@ -66,16 +77,19 @@ public function resolve(string $className, string $filePath): ?string } /** - * @return list + * @return int[]|string[] */ public function resolveAll(string $className, string $filePath): array { $pathWithSlash = Path::normalise($filePath, canonicalise: true) . '/'; $matched = []; - foreach ($this->layerPaths as [$layerPath, $layerName]) { - if (str_starts_with($pathWithSlash, $layerPath) && ! in_array($layerName, $matched, true)) { - $matched[] = $layerName; + foreach ($this->normalisedLayers as $layerName => $layerPaths) { + foreach ($layerPaths as $layerPath) { + if (str_starts_with($pathWithSlash, $layerPath)) { + $matched[] = $layerName; + break; + } } } diff --git a/tests/Analyser/FunctionLikeCollectionTest.php b/tests/Analyser/FunctionLikeCollectionTest.php index 9c95f4d0..2035890b 100644 --- a/tests/Analyser/FunctionLikeCollectionTest.php +++ b/tests/Analyser/FunctionLikeCollectionTest.php @@ -113,7 +113,7 @@ public function testSelectsMostSpecificLayerWhenMultipleLayersMatch(): void $functionNode = $analysisNodeCollector->getFunctionNodes()[0]; $this->assertSame('Domain', $functionNode->layer); - $this->assertSame(['Domain', 'Source'], $functionNode->layers); + $this->assertSame(['Source', 'Domain'], $functionNode->layers); } public function testCollectsFunctionDependenciesWithoutSeedingNamespaceImports(): void diff --git a/tests/LayerResolver/NamespaceLayerResolverTest.php b/tests/LayerResolver/NamespaceLayerResolverTest.php index c5b26e4e..7d0b4052 100644 --- a/tests/LayerResolver/NamespaceLayerResolverTest.php +++ b/tests/LayerResolver/NamespaceLayerResolverTest.php @@ -185,7 +185,7 @@ public function testReusesCachedMatchesForSameFilePath(): void $filePath = $this->basePath . '/src/Domain/Order.php'; $this->assertSame('Domain', $chainLayerResolver->resolve('App\\Domain\\Order', $filePath)); - $this->assertSame(['Domain', 'Source'], $chainLayerResolver->resolveAll('App\\Domain\\Order', $filePath)); + $this->assertSame(['Source', 'Domain'], $chainLayerResolver->resolveAll('App\\Domain\\Order', $filePath)); } public function testChainResolverCachesResolveResult(): void @@ -247,7 +247,7 @@ public function testChainResolverResolveHitsCacheAfterResolveAll(): void $layers = $chainLayerResolver->resolveAll('App\\Domain\\Order', $filePath); $layer = $chainLayerResolver->resolve('App\\Domain\\Order', $filePath); - $this->assertSame(['Domain', 'Source'], $layers); + $this->assertSame(['Source', 'Domain'], $layers); $this->assertSame('Domain', $layer); }