From f0f4189f5a27fc8365e4f45d9146b47e62860684 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 29 Sep 2026 23:36:07 +0700 Subject: [PATCH 01/20] refactor: Remove unused RuleViolationCollection::forLayer() and toJson() --- src/Rule/RuleViolationCollection.php | 19 ------------------- tests/Analyser/AnalyserTest.php | 3 +-- tests/Rule/RuleViolationTest.php | 25 ------------------------- 3 files changed, 1 insertion(+), 46 deletions(-) diff --git a/src/Rule/RuleViolationCollection.php b/src/Rule/RuleViolationCollection.php index cfdcbe38..4f32a3c0 100644 --- a/src/Rule/RuleViolationCollection.php +++ b/src/Rule/RuleViolationCollection.php @@ -13,9 +13,6 @@ use function array_map; use function array_values; use function count; -use function json_encode; - -use const JSON_INVALID_UTF8_SUBSTITUTE; /** * @implements IteratorAggregate @@ -57,17 +54,6 @@ public function getIterator(): Traversable return new ArrayIterator($this->violations); } - /** @return RuleViolation[] */ - public function forLayer(string $layer): array - { - return array_values( - array_filter( - $this->violations, - static fn(RuleViolation $ruleViolation): bool => $ruleViolation->layer === $layer - ) - ); - } - /** @return RuleViolation[] */ public function forRule(string $ruleKey): array { @@ -87,9 +73,4 @@ public function toArray(): array $this->violations )); } - - public function toJson(): string - { - return (string) json_encode($this->toArray(), JSON_INVALID_UTF8_SUBSTITUTE); - } } diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index 2d08ce39..186c756d 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -563,8 +563,7 @@ public function testAnalyserReturnsNoViolationsForValidCode(): void $ruleViolationCollection = $analyser->analyse($architecture); // Order.php is a valid entity — should produce no layer violations - $this->assertEmpty($ruleViolationCollection->forLayer('Application')); - $this->assertEmpty($ruleViolationCollection->forLayer('Infrastructure')); + $this->assertTrue($ruleViolationCollection->isEmpty()); } public function testAnalyserDetectsViolationsInBadCode(): void diff --git a/tests/Rule/RuleViolationTest.php b/tests/Rule/RuleViolationTest.php index 8eca2001..279e959f 100644 --- a/tests/Rule/RuleViolationTest.php +++ b/tests/Rule/RuleViolationTest.php @@ -10,8 +10,6 @@ use PHPUnit\Framework\TestCase; use function iterator_to_array; -use function json_decode; -use function json_encode; #[CoversClass(RuleViolation::class)] #[CoversClass(RuleViolationCollection::class)] @@ -163,33 +161,10 @@ public function testCollectionFiltersAndSerializesViolations(): void $this->assertFalse($collection->isEmpty()); $this->assertTrue($collection->hasViolations()); $this->assertCount(2, $collection); - $this->assertSame([$ruleViolation], $collection->forLayer('Domain')); $this->assertSame([$app], $collection->forRule('app.rule')); - $this->assertSame(json_encode($collection->toArray()), $collection->toJson()); - $this->assertSame($collection->toArray(), json_decode($collection->toJson(), true)); $this->assertSame([$ruleViolation, $app], iterator_to_array($collection)); } - public function testCollectionSerializesInvalidUtf8Text(): void - { - $ruleViolationCollection = new RuleViolationCollection(); - $ruleViolationCollection->add(new RuleViolation( - message: "Invalid byte \xB1", - file: '/src/File.php', - line: 7, - className: 'App\\Domain\\File', - layer: 'Domain', - ruleKey: 'domain.rule', - )); - - $data = json_decode($ruleViolationCollection->toJson(), true); - - $this->assertIsArray($data); - $this->assertIsArray($data[0]); - $this->assertIsString($data[0]['message']); - $this->assertStringContainsString("\xEF\xBF\xBD", $data[0]['message']); - } - private function violation(string $ruleKey, string $layer): RuleViolation { return new RuleViolation( From f52d23adb441a651bbc2a1119cb78ec7336eecbf Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 30 Sep 2026 21:50:33 +0700 Subject: [PATCH 02/20] fix: Recognise global function fallback in namespaced top level calls --- src/Analyser/AnalysisNodeCollector.php | 6 ++-- tests/Analyser/AnalyserTest.php | 30 ++++++++++++++++++++ tests/Analyser/AnalysisNodeCollectorTest.php | 17 ++++++----- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index e4245678..c8f60de6 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -1106,8 +1106,9 @@ private function collectNodeAnalysis(Node $node): void if ($this->activeClassLikeAnalyses === [] && $this->activeFunctionLikeAnalyses === []) { // A top-level unqualified call in a namespace is not a FullyQualified - // node, so it is recorded here under the namespaced name PHP tries - // first, keeping the called function alive. + // node, so it is recorded here under both names PHP may call: the + // namespaced name it tries first and the global name it falls back + // to, keeping the called function alive. if ( $node instanceof FuncCall && $node->name instanceof Name @@ -1117,6 +1118,7 @@ private function collectNodeAnalysis(Node $node): void if ($namespacedName instanceof Name) { $this->currentFileReferences[$namespacedName->toString()] = true; + $this->currentFileReferences[$node->name->toString()] = true; } } diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index 186c756d..8faa5e77 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -1102,6 +1102,36 @@ function used_helper(): void {} $this->assertSame(['unused_helper'], $this->violationClassNames($violations)); } + public function testMustBeUsedFunctionRuleRecognizesGlobalFallbackCallFromNamespacedTopLevel(): void + { + $basePath = $this->makeTempProject([ + 'src/helpers.php' => <<<'PHP' + <<<'PHP' + withPreset(Preset::YAGNI(sourcePaths: ['src/'])); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule(YagniPreset::FUNCTION_MUST_BE_USED); + + // App\used_helper() is not declared, so PHP falls back to the global + // function at runtime: the top-level call keeps it alive. + $this->assertSame(['unused_helper'], $this->violationClassNames($violations)); + } + public function testYagniRulesDoNotFlagAbstractionsReferencedAsDependencies(): void { $checker = 'assertSame( - ['/fake/path/Foo.php' => ['App\interface_exists', 'App\Contract']], + ['/fake/path/Foo.php' => ['App\interface_exists', 'interface_exists', 'App\Contract']], $analysisNodeCollector->getFileReferences() ); } @@ -293,14 +294,15 @@ function_exists(); // The function_exists() argument probes for a function, it does not // use it; other function-name strings stay references. The top-level - // guard call itself is recorded under its namespaced name. + // guard call itself is recorded under its namespaced and global + // fallback names. $this->assertSame( - ['/fake/path/Foo.php' => ['App\function_exists', 'App\kept_helper']], + ['/fake/path/Foo.php' => ['App\function_exists', 'function_exists', 'App\kept_helper']], $analysisNodeCollector->getFileReferences() ); } - public function testCollectsTopLevelUnqualifiedFunctionCallAsNamespacedFileReference(): void + public function testCollectsTopLevelUnqualifiedFunctionCallAsNamespacedAndGlobalFileReference(): void { $analysisNodeCollector = $this->makeCollector(<<<'PHP' assertSame( - ['/fake/path/Foo.php' => ['App\helper']], + ['/fake/path/Foo.php' => ['App\helper', 'helper']], $analysisNodeCollector->getFileReferences() ); } From fdd337ce9baf9202783a64351aeb6719eea5eebb Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 30 Sep 2026 22:16:12 +0700 Subject: [PATCH 03/20] 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 5e508c85..c16e53c7 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.18.2 — Architecture Enforcement + StructArmed 0.18.3 — Architecture Enforcement =============================================== diff --git a/docs/assets/structarmed-showoff.svg b/docs/assets/structarmed-showoff.svg index 5b1ee4bb..8db1154f 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.18.2 — Architecture Enforcement + StructArmed 0.18.3 — Architecture Enforcement =============================================== From 370322d5e1d0538f718b7504581b7c07f6642f00 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 1 Oct 2026 17:39:22 +0700 Subject: [PATCH 04/20] refactor: rename $class parameter to $dependency on ClassNode::usesClass() --- src/Analyser/ClassNode.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index a4f6b8d7..dce76e6a 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -78,14 +78,14 @@ public function __construct( * Whether the class depends on $class as a class-like: a function or * constant of the same name does not count. */ - public function usesClass(string $class): bool + public function usesClass(string $dependency): bool { - if (! $this->dependsOn($class, isCaseSensitive: false)) { + if (! $this->dependsOn($dependency, isCaseSensitive: false)) { return false; } foreach ($this->nonClassDependencies as $nonClassDependency) { - if (strcasecmp($nonClassDependency, $class) === 0) { + if (strcasecmp($nonClassDependency, $dependency) === 0) { return false; } } From 3523b24b351e81e61d09c67df552a199f4877da9 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 1 Oct 2026 17:40:19 +0700 Subject: [PATCH 05/20] refactor: rename $class parameter to $dependency on ClassNode::usesClass() --- src/Analyser/ClassNode.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index dce76e6a..7a32c463 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -75,7 +75,7 @@ public function __construct( } /** - * Whether the class depends on $class as a class-like: a function or + * Whether the class depends on $dependency as a class-like: a function or * constant of the same name does not count. */ public function usesClass(string $dependency): bool From 3043fe123fd967093524b6dbd03fe5f15274c0ae Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 1 Oct 2026 17:43:11 +0700 Subject: [PATCH 06/20] Revert "refactor: rename $class parameter to $dependency on ClassNode::usesClass()" --- src/Analyser/ClassNode.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index 7a32c463..a4f6b8d7 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -75,17 +75,17 @@ public function __construct( } /** - * Whether the class depends on $dependency as a class-like: a function or + * Whether the class depends on $class as a class-like: a function or * constant of the same name does not count. */ - public function usesClass(string $dependency): bool + public function usesClass(string $class): bool { - if (! $this->dependsOn($dependency, isCaseSensitive: false)) { + if (! $this->dependsOn($class, isCaseSensitive: false)) { return false; } foreach ($this->nonClassDependencies as $nonClassDependency) { - if (strcasecmp($nonClassDependency, $dependency) === 0) { + if (strcasecmp($nonClassDependency, $class) === 0) { return false; } } From 5fe3c34de9f576d6d2f7c6f928be7c9bf77379aa Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 1 Oct 2026 17:44:17 +0700 Subject: [PATCH 07/20] refactor: rename $class parameter to $dependency on NodeQueryTrait::dependsOn() --- src/Analyser/NodeQueryTrait.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Analyser/NodeQueryTrait.php b/src/Analyser/NodeQueryTrait.php index 76b78f65..dd64ef74 100644 --- a/src/Analyser/NodeQueryTrait.php +++ b/src/Analyser/NodeQueryTrait.php @@ -31,14 +31,14 @@ public function isInLayer(string $layer): bool return in_array($layer, $this->layers, true); } - public function dependsOn(string $class, bool $isCaseSensitive = true): bool + public function dependsOn(string $dependency, bool $isCaseSensitive = true): bool { if ($isCaseSensitive) { - return in_array($class, $this->dependencies, true); + return in_array($dependency, $this->dependencies, true); } - foreach ($this->dependencies as $dependency) { - if (strcasecmp($dependency, $class) === 0) { + foreach ($this->dependencies as $existingDependency) { + if (strcasecmp($existingDependency, $dependency) === 0) { return true; } } From 32a64803ab40acfa456737c5b4fb94c1515c7688 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 1 Oct 2026 18:57:30 +0700 Subject: [PATCH 08/20] perf: Optimize ruleset expansion and simplify dependency checks --- src/Analyser/Analyser.php | 84 ++++++++++++++------------------- tests/Analyser/AnalyserTest.php | 71 ++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 49 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 30b680fb..4478e73d 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -36,7 +36,6 @@ use function array_filter; use function array_key_exists; use function array_keys; -use function array_merge; use function array_unique; use function array_values; use function count; @@ -266,8 +265,6 @@ private function doAnalyse( $this->addViolations($violations, $key, $isFixable, $ruleViolationCollection); } - // Evaluate declarative ruleset alongside class rules, but buffer its - // violations so report ordering remains class rules before ruleset. $rulesetAllowedLayerMaps = $this->rulesetAllowedLayerMaps($architecture->getRuleset()); $classViolationSkipMaps = []; @@ -276,19 +273,18 @@ private function doAnalyse( $classViolationSkipMaps[$skipClassName] = array_fill_keys($skippedDependencies, true); } - $globalSkipPathMatcher = SkipPathMatcher::compile($this->basePath, $globalSkipPaths); - $ruleSkipMatchers = $this->ruleSkipMatchers($nodeRules, $ruleSkipPaths); - $rulesetSkipPaths = $architecture->getRulesetSkipPaths(); - $rulesetSkipPathMatcher = SkipPathMatcher::compile($this->basePath, $rulesetSkipPaths); - $rulesetViolationCollection = new RuleViolationCollection(); - $hasRuleset = $rulesetAllowedLayerMaps !== []; - $scanScopeLayerMap = $hasRuleset ? $this->scanScopeLayerMap($architecture) : []; - $hasLayerAwareRules = $layerAwareRules !== []; - $classDependencyMaps = $this->classDependencyMaps($classNodes, $hasRuleset, $hasLayerAwareRules); - $dependencyMap = $classDependencyMaps['dependencies']; - $inheritanceDependencyMap = $classDependencyMaps['inheritanceDependencies']; - $classLayerMap = $classDependencyMaps['classLayerMap']; - $classPrimaryLayerMap = $classDependencyMaps['classPrimaryLayerMap']; + $globalSkipPathMatcher = SkipPathMatcher::compile($this->basePath, $globalSkipPaths); + $ruleSkipMatchers = $this->ruleSkipMatchers($nodeRules, $ruleSkipPaths); + $rulesetSkipPaths = $architecture->getRulesetSkipPaths(); + $rulesetSkipPathMatcher = SkipPathMatcher::compile($this->basePath, $rulesetSkipPaths); + $hasRuleset = $rulesetAllowedLayerMaps !== []; + $scanScopeLayerMap = $hasRuleset ? $this->scanScopeLayerMap($architecture) : []; + $hasLayerAwareRules = $layerAwareRules !== []; + $classDependencyMaps = $this->classDependencyMaps($classNodes, $hasRuleset, $hasLayerAwareRules); + $dependencyMap = $classDependencyMaps['dependencies']; + $inheritanceDependencyMap = $classDependencyMaps['inheritanceDependencies']; + $classLayerMap = $classDependencyMaps['classLayerMap']; + $classPrimaryLayerMap = $classDependencyMaps['classPrimaryLayerMap']; $resolvedInheritedDependencies = []; @@ -312,7 +308,7 @@ private function doAnalyse( $ruleViolationCollection ); - // Declarative ruleset dependency checks, per class node. + // Append ruleset violations after node rules to preserve report ordering. foreach ($hasRuleset ? $classNodes : [] as $classNode) { if ($globalSkipPathMatcher->isSkipped($classNode->file)) { continue; @@ -348,6 +344,11 @@ private function doAnalyse( $primaryLayer = $classPrimaryLayerMap[$dependency] ?? null; + // Same primary layer is always allowed, including PSR-4 scan scopes. + if ($primaryLayer === $classNode->layer) { + continue; + } + if ($primaryLayer !== null && ! array_key_exists($primaryLayer, $scanScopeLayerMap)) { // Scanned dep in a specific layer (not a PSR4 catch-all): keep every // layer collected at scan time, including path-based ones. @@ -362,14 +363,6 @@ private function doAnalyse( continue; } - // Same primary layer is always allowed. This check is not redundant - // with the loop below: for a dep whose primary layer is a PSR-4 - // catch-all, $depLayers is re-resolved without its file path and - // therefore need not contain the primary layer. - if ($primaryLayer === $classNode->layer) { - continue; - } - // A dependency is permitted when any of its layers is the class's own // layer (shared secondary layer) or is explicitly allowed, regardless // of whether the dependency was scanned or resolved without a file path. @@ -385,7 +378,7 @@ private function doAnalyse( $violatingLayer = $primaryLayer ?? $depLayers[0]; - $rulesetViolationCollection->add(new RuleViolation( + $ruleViolationCollection->add(new RuleViolation( message: sprintf( '%s [%s] in layer [%s] must not depend on [%s] which belongs to layer [%s]', $classNode->getType(), @@ -403,8 +396,6 @@ className: $classNode->className, } } - $ruleViolationCollection->merge($rulesetViolationCollection); - return $ruleViolationCollection; } @@ -490,7 +481,8 @@ private function addViolations( * instead of in_array()/array_intersect() scans. * * `+LayerName` means: include `LayerName` itself and all layers that `LayerName` is allowed to depend on. - * References to unknown layers expand to nothing. Circular references are skipped. + * Unknown layers have no further dependencies. Each reference is expanded once per layer, + * so shared references and cycles do not repeat work. * * @param array> $ruleset * @return array> @@ -500,48 +492,42 @@ private function rulesetAllowedLayerMaps(array $ruleset): array $allowedLayerMaps = []; foreach ($ruleset as $layer => $allowedLayers) { - $resolving = [$layer => true]; - $allowedLayerMaps[$layer] = array_fill_keys( - $this->expandRulesetLayer($allowedLayers, $ruleset, $resolving), - true - ); + $expandedLayers = [$layer => true]; + $allowedLayerMaps[$layer] = $this->expandAllowedLayers($allowedLayers, $ruleset, $expandedLayers); } return $allowedLayerMaps; } /** - * @param list $allowedLayers + * @param list $allowedLayers * @param array> $ruleset - * @param array $resolving Layers currently being expanded (circular-reference guard). - * @return list + * @param array $expandedLayers Shared across branches to expand each reference only once. + * @return array */ - private function expandRulesetLayer(array $allowedLayers, array $ruleset, array $resolving): array + private function expandAllowedLayers(array $allowedLayers, array $ruleset, array &$expandedLayers): array { - $expanded = []; + $allowedLayerMap = []; foreach ($allowedLayers as $allowedLayer) { if (! str_starts_with($allowedLayer, '+')) { - $expanded[] = $allowedLayer; + $allowedLayerMap[$allowedLayer] = true; continue; } $referencedLayer = substr($allowedLayer, 1); - if (isset($resolving[$referencedLayer])) { + if (isset($expandedLayers[$referencedLayer])) { continue; } - // Include the referenced layer itself, then recursively its allowed layers. - $expanded[] = $referencedLayer; - $referencedAllowed = $ruleset[$referencedLayer] ?? []; - $expanded = array_merge( - $expanded, - $this->expandRulesetLayer($referencedAllowed, $ruleset, $resolving + [$referencedLayer => true]) - ); + $expandedLayers[$referencedLayer] = true; + $allowedLayerMap[$referencedLayer] = true; + + $allowedLayerMap += $this->expandAllowedLayers($ruleset[$referencedLayer] ?? [], $ruleset, $expandedLayers); } - return array_values(array_unique($expanded)); + return $allowedLayerMap; } /** diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index 3a95a820..66e720a1 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -6548,6 +6548,77 @@ public function __construct( $this->assertStringContainsString('Database', $violations[0]->message); } + public function testRulesetSharedReferencesPreserveAllowedLayersAndViolationOrder(): void + { + $basePath = $this->makeTempProject([ + 'src/A/ClassA.php' => <<<'PHP' + <<<'PHP' + layerPattern('A', '/^App\\\\A\\\\/') + ->layerPattern('B', '/^App\\\\B\\\\/') + ->layerPattern('Shared', '/^App\\\\Shared\\\\/') + ->layerPattern('Format', '/^App\\\\Format\\\\/') + ->layerPattern('Database', '/^App\\\\Database\\\\/') + ->ruleset([ + 'A' => ['Shared', '+Shared', '+B', '+Shared', 'Shared'], + 'B' => ['+A', '+Shared'], + 'Shared' => ['Format', '+B'], + ]) + ->rule('a.must_be_final', new MustBeFinalRule('A')) + ->rule('b.must_be_final', new MustBeFinalRule('B')); + + $ruleViolationCollection = (new Analyser($basePath))->analyse( + $architecture, + analyserOptions: AnalyserOptions::sequential(), + files: [$basePath . '/src/A/ClassA.php', $basePath . '/src/B/ClassB.php'], + ); + + $violations = []; + + foreach ($ruleViolationCollection as $violation) { + $violations[] = [$violation->ruleKey, $violation->className]; + } + + $this->assertSame([ + ['a.must_be_final', 'App\\A\\ClassA'], + ['b.must_be_final', 'App\\B\\ClassB'], + ['ruleset.A', 'App\\A\\ClassA'], + ['ruleset.B', 'App\\B\\ClassB'], + ], $violations); + $this->assertStringContainsString( + 'App\\Database\\QueryBuilder', + $ruleViolationCollection->forRule('ruleset.A')[0]->message, + ); + $this->assertStringContainsString( + 'App\\Database\\QueryBuilder', + $ruleViolationCollection->forRule('ruleset.B')[0]->message, + ); + } + public function testMayNotDependOnRuleViolationIsDetectedViaAnalyser(): void { $basePath = $this->makeTempProject([ From c7635785d3b664b354227140484a0b6eec0335c1 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 1 Oct 2026 19:03:15 +0700 Subject: [PATCH 09/20] keep merge behaviour --- src/Analyser/Analyser.php | 46 +++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 4478e73d..32a1c064 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -265,6 +265,8 @@ private function doAnalyse( $this->addViolations($violations, $key, $isFixable, $ruleViolationCollection); } + // Evaluate declarative ruleset alongside class rules, but buffer its + // violations so report ordering remains class rules before ruleset. $rulesetAllowedLayerMaps = $this->rulesetAllowedLayerMaps($architecture->getRuleset()); $classViolationSkipMaps = []; @@ -273,18 +275,19 @@ private function doAnalyse( $classViolationSkipMaps[$skipClassName] = array_fill_keys($skippedDependencies, true); } - $globalSkipPathMatcher = SkipPathMatcher::compile($this->basePath, $globalSkipPaths); - $ruleSkipMatchers = $this->ruleSkipMatchers($nodeRules, $ruleSkipPaths); - $rulesetSkipPaths = $architecture->getRulesetSkipPaths(); - $rulesetSkipPathMatcher = SkipPathMatcher::compile($this->basePath, $rulesetSkipPaths); - $hasRuleset = $rulesetAllowedLayerMaps !== []; - $scanScopeLayerMap = $hasRuleset ? $this->scanScopeLayerMap($architecture) : []; - $hasLayerAwareRules = $layerAwareRules !== []; - $classDependencyMaps = $this->classDependencyMaps($classNodes, $hasRuleset, $hasLayerAwareRules); - $dependencyMap = $classDependencyMaps['dependencies']; - $inheritanceDependencyMap = $classDependencyMaps['inheritanceDependencies']; - $classLayerMap = $classDependencyMaps['classLayerMap']; - $classPrimaryLayerMap = $classDependencyMaps['classPrimaryLayerMap']; + $globalSkipPathMatcher = SkipPathMatcher::compile($this->basePath, $globalSkipPaths); + $ruleSkipMatchers = $this->ruleSkipMatchers($nodeRules, $ruleSkipPaths); + $rulesetSkipPaths = $architecture->getRulesetSkipPaths(); + $rulesetSkipPathMatcher = SkipPathMatcher::compile($this->basePath, $rulesetSkipPaths); + $rulesetViolationCollection = new RuleViolationCollection(); + $hasRuleset = $rulesetAllowedLayerMaps !== []; + $scanScopeLayerMap = $hasRuleset ? $this->scanScopeLayerMap($architecture) : []; + $hasLayerAwareRules = $layerAwareRules !== []; + $classDependencyMaps = $this->classDependencyMaps($classNodes, $hasRuleset, $hasLayerAwareRules); + $dependencyMap = $classDependencyMaps['dependencies']; + $inheritanceDependencyMap = $classDependencyMaps['inheritanceDependencies']; + $classLayerMap = $classDependencyMaps['classLayerMap']; + $classPrimaryLayerMap = $classDependencyMaps['classPrimaryLayerMap']; $resolvedInheritedDependencies = []; @@ -308,7 +311,7 @@ private function doAnalyse( $ruleViolationCollection ); - // Append ruleset violations after node rules to preserve report ordering. + // Declarative ruleset dependency checks, per class node. foreach ($hasRuleset ? $classNodes : [] as $classNode) { if ($globalSkipPathMatcher->isSkipped($classNode->file)) { continue; @@ -344,7 +347,10 @@ private function doAnalyse( $primaryLayer = $classPrimaryLayerMap[$dependency] ?? null; - // Same primary layer is always allowed, including PSR-4 scan scopes. + // Same primary layer is always allowed. This check is not redundant + // with the loop below: for a dep whose primary layer is a PSR-4 + // catch-all, $depLayers is re-resolved without its file path and + // therefore need not contain the primary layer. if ($primaryLayer === $classNode->layer) { continue; } @@ -378,7 +384,7 @@ private function doAnalyse( $violatingLayer = $primaryLayer ?? $depLayers[0]; - $ruleViolationCollection->add(new RuleViolation( + $rulesetViolationCollection->add(new RuleViolation( message: sprintf( '%s [%s] in layer [%s] must not depend on [%s] which belongs to layer [%s]', $classNode->getType(), @@ -396,6 +402,8 @@ className: $classNode->className, } } + $ruleViolationCollection->merge($rulesetViolationCollection); + return $ruleViolationCollection; } @@ -481,8 +489,7 @@ private function addViolations( * instead of in_array()/array_intersect() scans. * * `+LayerName` means: include `LayerName` itself and all layers that `LayerName` is allowed to depend on. - * Unknown layers have no further dependencies. Each reference is expanded once per layer, - * so shared references and cycles do not repeat work. + * References to unknown layers expand to nothing. Circular references are skipped. * * @param array> $ruleset * @return array> @@ -500,9 +507,9 @@ private function rulesetAllowedLayerMaps(array $ruleset): array } /** - * @param list $allowedLayers + * @param list $allowedLayers * @param array> $ruleset - * @param array $expandedLayers Shared across branches to expand each reference only once. + * @param array $expandedLayers Shared across branches to expand each reference only once. * @return array */ private function expandAllowedLayers(array $allowedLayers, array $ruleset, array &$expandedLayers): array @@ -521,6 +528,7 @@ private function expandAllowedLayers(array $allowedLayers, array $ruleset, array continue; } + // Include the referenced layer itself, then recursively its allowed layers. $expandedLayers[$referencedLayer] = true; $allowedLayerMap[$referencedLayer] = true; From 0b6ff4af0b6a3e2e1a2093313233cd9eb429ef09 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 1 Oct 2026 19:16:20 +0700 Subject: [PATCH 10/20] 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 0a0c00d3..a21f4d1c 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.18.8 — Architecture Enforcement + StructArmed 0.18.9 — Architecture Enforcement =============================================== diff --git a/docs/assets/structarmed-showoff.svg b/docs/assets/structarmed-showoff.svg index dfc04222..2962e873 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.18.8 — Architecture Enforcement + StructArmed 0.18.9 — Architecture Enforcement =============================================== From 5e565c734c47b7f913c853938dc1b3f116604364 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 2 Oct 2026 15:07:06 +0700 Subject: [PATCH 11/20] Bump fidry/cpu-core-counter to version ^1.4 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 87b1b9e3..e093d17f 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "php": "^8.2", "composer-runtime-api": "^2.0", "boundwize/jsonrecast": "^1.0", - "fidry/cpu-core-counter": "^1.3", + "fidry/cpu-core-counter": "^1.4", "nikic/php-parser": "^5.9" }, "require-dev": { From faa6728aaf45cde2bdb65369b64e09463aafcc0e Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 5 Oct 2026 09:35:12 +0700 Subject: [PATCH 12/20] Add MayNotUseConstantRule and forbid STDIN, STDOUT, and STDERR in DDD preset Domain layer --- docs/available-rules.md | 1 + src/Analyser/AnalysisNodeCollector.php | 12 +- src/Cache/AnalysisResultCache.php | 2 +- src/Preset/Presets/DddPreset.php | 8 ++ .../Rules/Usage/MayNotUseConstantRule.php | 45 +++++++ tests/Analyser/AnalysisNodeCollectorTest.php | 26 ++++ tests/Preset/PresetTest.php | 1 + .../Rule/Usage/MayNotUseConstantRuleTest.php | 112 ++++++++++++++++++ 8 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 src/Rule/Rules/Usage/MayNotUseConstantRule.php create mode 100644 tests/Rule/Usage/MayNotUseConstantRuleTest.php diff --git a/docs/available-rules.md b/docs/available-rules.md index cc7cfd4b..7d255439 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -169,6 +169,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Usage`. |---|---|---| | `MayNotCallFunctionRule` | `new MayNotCallFunctionRule(layer: 'Domain', function: 'header')` | Classes in a layer do not call a forbidden function. | | `MayNotUseClassRule` | `new MayNotUseClassRule(layer: 'Domain', forbiddenClass: DateTime::class)` | Classes in a layer do not depend on a forbidden class. | +| `MayNotUseConstantRule` | `new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL')` | Classes in a layer do not use a forbidden constant. | | `MayNotUseLanguageConstructRule` | `new MayNotUseLanguageConstructRule(layer: 'Domain', construct: 'echo')` | Classes in a layer do not use a forbidden language construct. | | `MayNotUseNamespaceRule` | `new MayNotUseNamespaceRule(layer: 'Domain', forbiddenNamespace: 'Doctrine\\ORM\\')` | Classes in a layer do not depend on a forbidden namespace. | | `MayNotUseSuperglobalsRule` | `new MayNotUseSuperglobalsRule(layer: 'Controller')` | Classes in a layer do not access superglobals directly. | diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index e58af247..f6cc52dd 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -1176,8 +1176,16 @@ private function collectNodeAnalysis(Node $node): void // Entered before its name, so the FullyQualified branch above sees // the mark. if ($node instanceof ConstFetch) { - $node->name->setAttribute(self::NON_CLASS_NAME_ATTRIBUTE, true); - $this->collectKeywordConstant($node->name); + $name = $node->name; + $name->setAttribute(self::NON_CLASS_NAME_ATTRIBUTE, true); + $this->collectKeywordConstant($name); + + // An unqualified fetch in a namespace is not a FullyQualified + // node: PHP falls back to the global constant, so the global name + // is recorded as the dependency. + if (! $name instanceof FullyQualified && ! isset(self::KEYWORD_CONSTANTS[$name->toLowerString()])) { + $this->addDependency($name->toString(), false); + } return; } diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 1776884e..32c9d78e 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -65,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 = 13; + public const FORMAT_VERSION = 14; private readonly string $cacheDirectory; diff --git a/src/Preset/Presets/DddPreset.php b/src/Preset/Presets/DddPreset.php index 2ba49ded..78d8411b 100644 --- a/src/Preset/Presets/DddPreset.php +++ b/src/Preset/Presets/DddPreset.php @@ -17,6 +17,7 @@ use Boundwize\StructArmed\Rule\Rules\Method\MustHaveReturnTypeRule; use Boundwize\StructArmed\Rule\Rules\Usage\MayNotCallFunctionRule; use Boundwize\StructArmed\Rule\Rules\Usage\MayNotUseClassRule; +use Boundwize\StructArmed\Rule\Rules\Usage\MayNotUseConstantRule; use Boundwize\StructArmed\Rule\Rules\Usage\MayNotUseLanguageConstructRule; use DateTime; use Exception; @@ -270,6 +271,13 @@ private function applySafetyRules(Architecture $architecture): self new MayNotImplementInterfaceRule(layer: 'Domain', interface: JsonSerializable::class) ); + foreach (['STDIN', 'STDOUT', 'STDERR'] as $constant) { + $architecture->rule( + sprintf('ddd.safety.domain_no_%s', strtolower($constant)), + new MayNotUseConstantRule(layer: 'Domain', constant: $constant) + ); + } + foreach (['Domain', 'Application'] as $layer) { $architecture->rule( sprintf('ddd.safety.%s_max_complexity', strtolower($layer)), diff --git a/src/Rule/Rules/Usage/MayNotUseConstantRule.php b/src/Rule/Rules/Usage/MayNotUseConstantRule.php new file mode 100644 index 00000000..9a4b4bbf --- /dev/null +++ b/src/Rule/Rules/Usage/MayNotUseConstantRule.php @@ -0,0 +1,45 @@ +isInLayer($this->layer); + } + + public function evaluate(ClassNode $classNode): ?RuleViolation + { + if (! $classNode->dependsOn($this->constant)) { + return null; + } + + return new RuleViolation( + message: sprintf( + '%s [%s] must not use constant [%s]', + $classNode->getType(), + $classNode->className, + $this->constant + ), + file: $classNode->file, + line: $classNode->line, + className: $classNode->className, + layer: $classNode->layer, + ); + } +} diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index f0e9a57e..e8f6c33f 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -2349,6 +2349,32 @@ public function isEnabled(): bool $this->assertContains('App\Infrastructure\Config\FEATURE_ENABLED', $classNode->dependencies); } + public function testCollectsUnqualifiedNamespacedConstantUsageAsGlobalDependency(): void + { + $classNode = $this->collect( + <<<'PHP_WRAP' + assertSame(['PHP_EOL', 'PHP_INT_MAX'], $classNode->dependencies); + $this->assertSame(['PHP_EOL', 'PHP_INT_MAX'], $classNode->nonClassDependencies); + } + public function testCollectsFullyQualifiedDependencies(): void { $classNode = $this->collect('assertArrayHasKey('ddd.safety.domain_no_dd', $rules); $this->assertArrayHasKey('ddd.safety.application_no_exit', $rules); + $this->assertArrayHasKey('ddd.safety.domain_no_stderr', $rules); } public function testDddPresetCanSkipOptionalFinalRules(): void diff --git a/tests/Rule/Usage/MayNotUseConstantRuleTest.php b/tests/Rule/Usage/MayNotUseConstantRuleTest.php new file mode 100644 index 00000000..d8178d01 --- /dev/null +++ b/tests/Rule/Usage/MayNotUseConstantRuleTest.php @@ -0,0 +1,112 @@ + $dependencies */ + private function makeNode( + array $dependencies, + string $layer = 'Domain', + bool $isTrait = false, + bool $isEnum = false, + ): ClassNode { + return new ClassNode( + className: 'App\\Domain\\OrderService', + file: '/fake.php', + line: 1, + layer: $layer, + extends: null, + isAbstract: false, + isFinal: true, + isInterface: false, + isReadonly: false, + isTrait: $isTrait, + dependencies: $dependencies, + isEnum: $isEnum, + ); + } + + public function testPassesWhenForbiddenConstantNotUsed(): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL'); + $classNode = $this->makeNode(['PHP_INT_MAX', 'App\\Domain\\Order']); + + $this->assertNotInstanceOf(RuleViolation::class, $mayNotUseConstantRule->evaluate($classNode)); + } + + public function testViolatesWhenForbiddenConstantIsUsed(): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL'); + $classNode = $this->makeNode(['PHP_EOL']); + + $violation = $mayNotUseConstantRule->evaluate($classNode); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame('Class [App\\Domain\\OrderService] must not use constant [PHP_EOL]', $violation->message); + } + + #[DataProvider('traitAndEnumKindProvider')] + public function testViolationMessageNamesTheClassLikeKind(string $expectedKind, bool $isTrait, bool $isEnum): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL'); + $classNode = $this->makeNode(['PHP_EOL'], isTrait: $isTrait, isEnum: $isEnum); + + $violation = $mayNotUseConstantRule->evaluate($classNode); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame( + $expectedKind . ' [App\\Domain\\OrderService] must not use constant [PHP_EOL]', + $violation->message + ); + } + + /** @return iterable */ + public static function traitAndEnumKindProvider(): iterable + { + yield 'trait' => ['Trait', true, false]; + yield 'enum' => ['Enum', false, true]; + } + + public function testConstantComparisonIsCaseSensitive(): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL'); + $classNode = $this->makeNode(['php_eol']); + + $this->assertNotInstanceOf(RuleViolation::class, $mayNotUseConstantRule->evaluate($classNode)); + } + + public function testViolatesForNamespacedConstant(): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'Vendor\\Config\\DEBUG'); + $classNode = $this->makeNode(['Vendor\\Config\\DEBUG']); + + $this->assertInstanceOf(RuleViolation::class, $mayNotUseConstantRule->evaluate($classNode)); + } + + public function testDoesNotApplyToWrongLayer(): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL'); + $classNode = $this->makeNode(['PHP_EOL'], layer: 'Infrastructure'); + + $this->assertFalse($mayNotUseConstantRule->appliesTo($classNode)); + } + + public function testAppliesToCorrectLayer(): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL'); + $classNode = $this->makeNode([]); + + $this->assertTrue($mayNotUseConstantRule->appliesTo($classNode)); + } +} From 7b6c987e889adddf9b5275a8b320e5f86629c38d Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 5 Oct 2026 09:53:29 +0700 Subject: [PATCH 13/20] fix global name --- docs/available-rules.md | 2 + src/Analyser/AnalysisNodeCollector.php | 18 ++++-- src/Analyser/ClassLikeAnalysis.php | 8 +++ src/Analyser/ClassNode.php | 34 ++++++++++ src/Cache/AnalysisResultCache.php | 4 ++ .../Rules/Usage/MayNotUseConstantRule.php | 2 +- tests/Analyser/AnalysisNodeCollectorTest.php | 21 +++++-- tests/Analyser/ClassNodeTest.php | 26 ++++++++ tests/Cache/AnalysisResultCacheTest.php | 1 + .../Rule/Usage/MayNotUseConstantRuleTest.php | 62 +++++++++++++------ 10 files changed, 148 insertions(+), 30 deletions(-) diff --git a/docs/available-rules.md b/docs/available-rules.md index 7d255439..778e414a 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -177,4 +177,6 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Usage`. `MayNotUseClassRule` and `MayNotUseNamespaceRule` also accept `classNamePattern` when only matching classes should be checked. +`MayNotUseConstantRule` takes a global or namespaced constant name, such as `'PHP_EOL'` or `'Vendor\\Config\\DEBUG'`. As in PHP, the namespace is matched case-insensitively and the constant name case-sensitively. An unqualified constant inside a namespace counts as the global constant of that name. + `MayNotUseLanguageConstructRule` accepts one of the following `construct` names: `echo`, `print`, `eval`, `isset`, `empty`, `unset`, `list`, `exit`, `die`, `include`, `include_once`, `require`, `require_once`. `die` is a pure alias of `exit`, so banning either spelling catches both. The `include` / `include_once` / `require` / `require_once` constructs are distinct and are matched exactly. diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index f6cc52dd..674de3a8 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -1180,11 +1180,16 @@ private function collectNodeAnalysis(Node $node): void $name->setAttribute(self::NON_CLASS_NAME_ATTRIBUTE, true); $this->collectKeywordConstant($name); - // An unqualified fetch in a namespace is not a FullyQualified - // node: PHP falls back to the global constant, so the global name - // is recorded as the dependency. - if (! $name instanceof FullyQualified && ! isset(self::KEYWORD_CONSTANTS[$name->toLowerString()])) { - $this->addDependency($name->toString(), false); + if ($this->activeClassLikeAnalyses !== [] && ! isset(self::KEYWORD_CONSTANTS[$name->toLowerString()])) { + // An unqualified fetch in a namespace is not a FullyQualified + // node and keeps its short name: it is recorded as the global + // constant PHP falls back to, as whether a namespaced constant + // of that name exists is not known here. + $constant = $name->toString(); + + foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { + $activeClassLikeAnalysis->constantFetches[$constant] = true; + } } return; @@ -1625,6 +1630,7 @@ enumBackingType: $classLike instanceof Enum_ && $classLike->scalarType instan ? $classLike->scalarType->toLowerString() : null, nonClassDependencies: $analysis['nonClassDependencies'], + constantFetches: $analysis['constantFetches'], ); } @@ -1768,6 +1774,7 @@ private function resolveClassName(ClassLike $classLike): string * @return array{ * dependencies: list, * nonClassDependencies: list, + * constantFetches: list, * functionCalls: string[], * superglobals: string[], * languageConstructs: string[], @@ -1795,6 +1802,7 @@ private function collectClassLikeAnalysis(ClassLikeAnalysis $classLikeAnalysis): strcasecmp(...) ) ), + 'constantFetches' => array_keys($classLikeAnalysis->constantFetches), 'functionCalls' => array_values(array_unique($functionCalls)), 'superglobals' => array_keys($classLikeAnalysis->superglobals), 'languageConstructs' => array_keys($classLikeAnalysis->languageConstructs), diff --git a/src/Analyser/ClassLikeAnalysis.php b/src/Analyser/ClassLikeAnalysis.php index 22f658a0..dc061afe 100644 --- a/src/Analyser/ClassLikeAnalysis.php +++ b/src/Analyser/ClassLikeAnalysis.php @@ -25,6 +25,14 @@ final class ClassLikeAnalysis */ public array $classDependencies = []; + /** + * The constants fetched by name, kept apart from the dependencies: a + * class-like or function of the same name is not a constant fetch. + * + * @var array + */ + public array $constantFetches = []; + /** @var list */ public array $functionCallNames = []; diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index a4f6b8d7..26e0851c 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -5,8 +5,10 @@ namespace Boundwize\StructArmed\Analyser; use function array_filter; +use function in_array; use function preg_match; use function strcasecmp; +use function strncasecmp; use function strrpos; use function substr; @@ -37,6 +39,7 @@ final class ClassNode * @param EnumCaseNode[] $enumCases Cases of this enum * @param string|null $enumBackingType Backing type for a backed enum, null otherwise * @param list $nonClassDependencies Dependencies only ever used as a function or constant name + * @param list $constantFetches Global and namespaced constants fetched within this class */ public function __construct( public readonly string $className, @@ -70,6 +73,7 @@ public function __construct( public readonly array $enumCases = [], public readonly ?string $enumBackingType = null, public readonly array $nonClassDependencies = [], + public readonly array $constantFetches = [], ) { $this->layers = $layers ?: array_filter([$this->layer]); } @@ -93,6 +97,36 @@ public function usesClass(string $class): bool return true; } + /** + * Whether the class fetches the constant $constant: a class-like or + * function of the same name does not count. + */ + public function usesConstant(string $constant): bool + { + $separatorPosition = strrpos($constant, '\\'); + + // a constant name is case-sensitive + if ($separatorPosition === false) { + return in_array($constant, $this->constantFetches, true); + } + + $namespaceLength = $separatorPosition + 1; + $name = substr($constant, $namespaceLength); + + // namespace names are case-insensitive; only the namespace is + // compared that way, the constant name after it stays case-sensitive + foreach ($this->constantFetches as $constantFetch) { + if ( + strncasecmp($constantFetch, $constant, $namespaceLength) === 0 + && substr($constantFetch, $namespaceLength) === $name + ) { + return true; + } + } + + return false; + } + public function isBackedEnum(): bool { return $this->isEnum && $this->enumBackingType !== null; diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 32c9d78e..238960da 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -970,6 +970,7 @@ private function classNodeToArray(ClassNode $classNode): array $lists = [ 'dependencies' => $classNode->dependencies, 'nonClassDependencies' => $classNode->nonClassDependencies, + 'constantFetches' => $classNode->constantFetches, 'implements' => array_values($classNode->implements), 'interfaceExtends' => array_values($classNode->interfaceExtends), 'parentClasses' => $classNode->parentClasses, @@ -1011,6 +1012,7 @@ private function classNodeFromArray(array $node, string $file): ?ClassNode $isReadonly = $node['isReadonly'] ?? null; $dependencies = $node['dependencies'] ?? []; $nonClassDependencies = $node['nonClassDependencies'] ?? []; + $constantFetches = $node['constantFetches'] ?? []; $implements = $node['implements'] ?? []; $interfaceExtends = $node['interfaceExtends'] ?? []; $parentClasses = $node['parentClasses'] ?? []; @@ -1035,6 +1037,7 @@ private function classNodeFromArray(array $node, string $file): ?ClassNode || ! is_bool($isReadonly) || ! $this->isStringArray($dependencies) || ! $this->isStringArray($nonClassDependencies) + || ! $this->isStringArray($constantFetches) || ! $this->isStringArray($implements) || ! $this->isStringArray($interfaceExtends) || ! $this->isStringArray($parentClasses) @@ -1091,6 +1094,7 @@ interfaceExtends: array_values($interfaceExtends), enumCases: $enumCases, enumBackingType: $enumBackingType, nonClassDependencies: array_values($nonClassDependencies), + constantFetches: array_values($constantFetches), ); } diff --git a/src/Rule/Rules/Usage/MayNotUseConstantRule.php b/src/Rule/Rules/Usage/MayNotUseConstantRule.php index 9a4b4bbf..ea6cba13 100644 --- a/src/Rule/Rules/Usage/MayNotUseConstantRule.php +++ b/src/Rule/Rules/Usage/MayNotUseConstantRule.php @@ -25,7 +25,7 @@ public function appliesTo(ClassNode $classNode): bool public function evaluate(ClassNode $classNode): ?RuleViolation { - if (! $classNode->dependsOn($this->constant)) { + if (! $classNode->usesConstant($this->constant)) { return null; } diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index e8f6c33f..08c142c1 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -2349,7 +2349,7 @@ public function isEnabled(): bool $this->assertContains('App\Infrastructure\Config\FEATURE_ENABLED', $classNode->dependencies); } - public function testCollectsUnqualifiedNamespacedConstantUsageAsGlobalDependency(): void + public function testCollectsConstantFetchesApartFromDependencies(): void { $classNode = $this->collect( <<<'PHP_WRAP' @@ -2357,22 +2357,31 @@ public function testCollectsUnqualifiedNamespacedConstantUsageAsGlobalDependency namespace App\Domain; + use const App\Infrastructure\Config\FEATURE_ENABLED; + class Foo { public const SEPARATOR = PHP_EOL; public function bar(?int $limit = null): bool { - return $limit === PHP_INT_MAX || true || false; + new \STDIN(); + \STDOUT(); + + return $limit === \PHP_INT_MAX || FEATURE_ENABLED || true || false; } } PHP_WRAP ); - // PHP falls back to the global constant, so the global name is the - // dependency; true, false, and null are keywords, not dependencies. - $this->assertSame(['PHP_EOL', 'PHP_INT_MAX'], $classNode->dependencies); - $this->assertSame(['PHP_EOL', 'PHP_INT_MAX'], $classNode->nonClassDependencies); + // An unqualified fetch in a namespace keeps its short name, the global + // constant PHP falls back to; true, false, and null are keywords, and + // a class-like or function named like a constant is not a fetch. + $this->assertSame( + ['PHP_EOL', 'PHP_INT_MAX', 'App\Infrastructure\Config\FEATURE_ENABLED'], + $classNode->constantFetches + ); + $this->assertNotContains('PHP_EOL', $classNode->dependencies); } public function testCollectsFullyQualifiedDependencies(): void diff --git a/tests/Analyser/ClassNodeTest.php b/tests/Analyser/ClassNodeTest.php index 2ef8aacc..5c70370c 100644 --- a/tests/Analyser/ClassNodeTest.php +++ b/tests/Analyser/ClassNodeTest.php @@ -522,6 +522,32 @@ className: 'App\\Domain\\OrderService', $this->assertTrue($classNode->dependsOn('Vendor\\helper')); } + public function testUsesConstantMatchesNamespaceCaseInsensitivelyAndNameCaseSensitively(): void + { + $classNode = new ClassNode( + className: 'App\\Domain\\OrderService', + file: '/src/OrderService.php', + line: 5, + layer: 'Domain', + extends: null, + isAbstract: false, + isFinal: false, + isInterface: false, + isReadonly: false, + dependencies: ['STDOUT'], + constantFetches: ['STDIN', 'Vendor\\Config\\DEBUG'], + ); + + $this->assertTrue($classNode->usesConstant('STDIN')); + $this->assertFalse($classNode->usesConstant('stdin')); + $this->assertTrue($classNode->usesConstant('vendor\\config\\DEBUG')); + $this->assertFalse($classNode->usesConstant('Vendor\\Config\\debug')); + $this->assertFalse($classNode->usesConstant('Vendor\\DEBUG')); + $this->assertFalse($classNode->usesConstant('DEBUG')); + // a dependency of that name is a class-like or function, not a fetch + $this->assertFalse($classNode->usesConstant('STDOUT')); + } + public function testDependsOnDoesNotMatchNamespacePrefix(): void { $classNode = new ClassNode( diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index 2c0dbca8..8b04594f 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -2945,6 +2945,7 @@ className: Foo::class, ], functionCalls: ['sprintf'], superglobals: ['_SERVER'], + constantFetches: ['PHP_EOL'], ); } diff --git a/tests/Rule/Usage/MayNotUseConstantRuleTest.php b/tests/Rule/Usage/MayNotUseConstantRuleTest.php index d8178d01..52b56164 100644 --- a/tests/Rule/Usage/MayNotUseConstantRuleTest.php +++ b/tests/Rule/Usage/MayNotUseConstantRuleTest.php @@ -14,33 +14,33 @@ #[CoversClass(MayNotUseConstantRule::class)] final class MayNotUseConstantRuleTest extends TestCase { - /** @param list $dependencies */ + /** @param list $constantFetches */ private function makeNode( - array $dependencies, + array $constantFetches, string $layer = 'Domain', bool $isTrait = false, bool $isEnum = false, ): ClassNode { return new ClassNode( - className: 'App\\Domain\\OrderService', - file: '/fake.php', - line: 1, - layer: $layer, - extends: null, - isAbstract: false, - isFinal: true, - isInterface: false, - isReadonly: false, - isTrait: $isTrait, - dependencies: $dependencies, - isEnum: $isEnum, + className: 'App\\Domain\\OrderService', + file: '/fake.php', + line: 1, + layer: $layer, + extends: null, + isAbstract: false, + isFinal: true, + isInterface: false, + isReadonly: false, + isTrait: $isTrait, + isEnum: $isEnum, + constantFetches: $constantFetches, ); } public function testPassesWhenForbiddenConstantNotUsed(): void { $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL'); - $classNode = $this->makeNode(['PHP_INT_MAX', 'App\\Domain\\Order']); + $classNode = $this->makeNode(['PHP_INT_MAX']); $this->assertNotInstanceOf(RuleViolation::class, $mayNotUseConstantRule->evaluate($classNode)); } @@ -86,12 +86,38 @@ public function testConstantComparisonIsCaseSensitive(): void $this->assertNotInstanceOf(RuleViolation::class, $mayNotUseConstantRule->evaluate($classNode)); } - public function testViolatesForNamespacedConstant(): void + public function testNamespaceComparisonIsCaseInsensitive(): void { $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'Vendor\\Config\\DEBUG'); - $classNode = $this->makeNode(['Vendor\\Config\\DEBUG']); - $this->assertInstanceOf(RuleViolation::class, $mayNotUseConstantRule->evaluate($classNode)); + $this->assertInstanceOf( + RuleViolation::class, + $mayNotUseConstantRule->evaluate($this->makeNode(['vendor\\config\\DEBUG'])) + ); + $this->assertNotInstanceOf( + RuleViolation::class, + $mayNotUseConstantRule->evaluate($this->makeNode(['Vendor\\Config\\debug'])) + ); + } + + public function testPassesWhenOnlyAClassOrFunctionOfThatNameIsUsed(): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'STDIN'); + $classNode = new ClassNode( + className: 'App\\Domain\\OrderService', + file: '/fake.php', + line: 1, + layer: 'Domain', + extends: null, + isAbstract: false, + isFinal: true, + isInterface: false, + isReadonly: false, + dependencies: ['STDIN'], + functionCalls: ['STDIN'], + ); + + $this->assertNotInstanceOf(RuleViolation::class, $mayNotUseConstantRule->evaluate($classNode)); } public function testDoesNotApplyToWrongLayer(): void From 0b0d14f5ea216fb8609984b52f6c7cbbde572508 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 5 Oct 2026 09:55:52 +0700 Subject: [PATCH 14/20] clean up doc --- docs/available-rules.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/available-rules.md b/docs/available-rules.md index 778e414a..eade2310 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -177,6 +177,6 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Usage`. `MayNotUseClassRule` and `MayNotUseNamespaceRule` also accept `classNamePattern` when only matching classes should be checked. -`MayNotUseConstantRule` takes a global or namespaced constant name, such as `'PHP_EOL'` or `'Vendor\\Config\\DEBUG'`. As in PHP, the namespace is matched case-insensitively and the constant name case-sensitively. An unqualified constant inside a namespace counts as the global constant of that name. +`MayNotUseConstantRule` takes a global or namespaced constant name, such as `'PHP_EOL'` or `'Vendor\\Config\\DEBUG'`. An unqualified constant inside a namespace counts as the global constant of that name. `MayNotUseLanguageConstructRule` accepts one of the following `construct` names: `echo`, `print`, `eval`, `isset`, `empty`, `unset`, `list`, `exit`, `die`, `include`, `include_once`, `require`, `require_once`. `die` is a pure alias of `exit`, so banning either spelling catches both. The `include` / `include_once` / `require` / `require_once` constructs are distinct and are matched exactly. From 7fce4856e43b59ddd99d9cccafc396cf2b398861 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 5 Oct 2026 10:01:49 +0700 Subject: [PATCH 15/20] more test --- tests/Rule/RuleViolationTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Rule/RuleViolationTest.php b/tests/Rule/RuleViolationTest.php index 279e959f..9aeb406f 100644 --- a/tests/Rule/RuleViolationTest.php +++ b/tests/Rule/RuleViolationTest.php @@ -162,6 +162,7 @@ public function testCollectionFiltersAndSerializesViolations(): void $this->assertTrue($collection->hasViolations()); $this->assertCount(2, $collection); $this->assertSame([$app], $collection->forRule('app.rule')); + $this->assertSame([$ruleViolation->toArray(), $app->toArray()], $collection->toArray()); $this->assertSame([$ruleViolation, $app], iterator_to_array($collection)); } From e31891cae8cba75694248a40581d03414fb0e882 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 5 Oct 2026 10:04:06 +0700 Subject: [PATCH 16/20] fix inside namespace --- docs/available-rules.md | 2 +- src/Analyser/AnalysisNodeCollector.php | 13 +++++--- tests/Analyser/AnalysisNodeCollectorTest.php | 33 +++++++++++++++++--- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/docs/available-rules.md b/docs/available-rules.md index eade2310..91dfcd9f 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -177,6 +177,6 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Usage`. `MayNotUseClassRule` and `MayNotUseNamespaceRule` also accept `classNamePattern` when only matching classes should be checked. -`MayNotUseConstantRule` takes a global or namespaced constant name, such as `'PHP_EOL'` or `'Vendor\\Config\\DEBUG'`. An unqualified constant inside a namespace counts as the global constant of that name. +`MayNotUseConstantRule` takes a global or namespaced constant name, such as `'PHP_EOL'` or `'Vendor\\Config\\DEBUG'`. An unqualified constant inside a namespace counts as both the constant of that namespace and the global constant of that name, as PHP only resolves it at runtime. `MayNotUseLanguageConstructRule` accepts one of the following `construct` names: `echo`, `print`, `eval`, `isset`, `empty`, `unset`, `list`, `exit`, `die`, `include`, `include_once`, `require`, `require_once`. `die` is a pure alias of `exit`, so banning either spelling catches both. The `include` / `include_once` / `require` / `require_once` constructs are distinct and are matched exactly. diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index 674de3a8..3bd78816 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -1182,12 +1182,17 @@ private function collectNodeAnalysis(Node $node): void if ($this->activeClassLikeAnalyses !== [] && ! isset(self::KEYWORD_CONSTANTS[$name->toLowerString()])) { // An unqualified fetch in a namespace is not a FullyQualified - // node and keeps its short name: it is recorded as the global - // constant PHP falls back to, as whether a namespaced constant - // of that name exists is not known here. - $constant = $name->toString(); + // node: PHP fetches the namespaced constant when it exists and + // the global one otherwise, which is not known here, so both + // candidates are recorded. + $namespacedName = $name->getAttribute('namespacedName'); + $constant = $name->toString(); foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { + if ($namespacedName instanceof Name) { + $activeClassLikeAnalysis->constantFetches[$namespacedName->toString()] = true; + } + $activeClassLikeAnalysis->constantFetches[$constant] = true; } } diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index 08c142c1..027d6da2 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -2374,16 +2374,41 @@ public function bar(?int $limit = null): bool PHP_WRAP ); - // An unqualified fetch in a namespace keeps its short name, the global - // constant PHP falls back to; true, false, and null are keywords, and - // a class-like or function named like a constant is not a fetch. + // An unqualified fetch in a namespace is the namespaced constant when + // it exists and the global one otherwise, so both are collected; true, + // false, and null are keywords, and a class-like or function named + // like a constant is not a fetch. $this->assertSame( - ['PHP_EOL', 'PHP_INT_MAX', 'App\Infrastructure\Config\FEATURE_ENABLED'], + ['App\Domain\PHP_EOL', 'PHP_EOL', 'PHP_INT_MAX', 'App\Infrastructure\Config\FEATURE_ENABLED'], $classNode->constantFetches ); $this->assertNotContains('PHP_EOL', $classNode->dependencies); } + public function testCollectsUnqualifiedConstantFetchOfSameNamespaceConstant(): void + { + $classNode = $this->collect( + <<<'PHP' + assertTrue($classNode->usesConstant('Vendor\Config\DEBUG')); + $this->assertTrue($classNode->usesConstant('DEBUG')); + } + public function testCollectsFullyQualifiedDependencies(): void { $classNode = $this->collect(' Date: Wed, 7 Oct 2026 19:50:18 +0700 Subject: [PATCH 17/20] Add ability to setup basepath via --basepath in CLI --- docs/cli.md | 28 ++++ src/Cli/AnalyseCommand.php | 16 +- src/Cli/ClearCacheCommand.php | 38 +++-- src/Cli/Usage.php | 6 +- src/Config/ConfigLoader.php | 24 +-- ...ructArmedApplicationCommandRoutingTest.php | 22 +++ tests/Cli/StructArmedApplicationTest.php | 142 ++++++++++++++++++ tests/Config/ConfigLoaderTest.php | 9 ++ 8 files changed, 260 insertions(+), 25 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 9663ef2e..c37aba8c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -46,6 +46,34 @@ vendor/bin/structarmed analyse --config=path/to/structarmed.php vendor/bin/structarmed analyze --config=path/to/structarmed.php ``` +## Base Path + +The project root defaults to the directory the command runs in. Pass `--basepath` when StructArmed is installed somewhere else, for example in a `tools/structarmed` directory with its own `composer.json`: + +```text +composer.json +src/ +tests/ +tools/ +└── structarmed/ + ├── composer.json + ├── structarmed.php + └── vendor/ +``` + +```bash +cd tools/structarmed +vendor/bin/structarmed analyse --basepath=../../ +``` + +Everything relative to the project root now resolves against the base path: layer paths such as `->layer('Config', 'src/ConfigProvider.php')`, scan paths given on the command line, the `composer.json` read by the composer rules and PSR-4 layers, the cache directory, and baseline paths. The config file is discovered in the current directory first, then in the base path; `--config` keeps pointing to a path relative to the current directory. + +`--clear-cache` accepts the same option, so the cache of a project analysed through `--basepath` is cleared with: + +```bash +vendor/bin/structarmed --clear-cache --basepath=../../ +``` + ## Auto-Fix Violations Use `--fix` to automatically apply fixes for violations produced by rules that implement `Boundwize\StructArmed\Rule\FixableInterface`. diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index 5ece1701..16df289b 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -42,6 +42,7 @@ * @phpstan-type CommandOptions array{ * report?: string, * config?: string, + * basepath?: string, * generate-baseline?: string, * no-progress?: true, * clear-cache?: true, @@ -54,6 +55,7 @@ private const VALUE_OPTIONS = [ '--report' => 'report', '--config' => 'config', + '--basepath' => 'basepath', '--generate-baseline' => 'generate-baseline', ]; @@ -98,6 +100,18 @@ public function run(array $arguments, string $basePath): int return 1; } + $workingDirectory = $basePath; + + if (isset($options['basepath'])) { + $basePath = Path::normalise(Path::resolve($options['basepath'], $workingDirectory), canonicalise: true); + + if (! is_dir($basePath)) { + echo sprintf("Error: base path [%s] not found.\n", $options['basepath']); + + return 1; + } + } + foreach ($scanPaths as $scanPath) { $fullScanPath = Path::resolve($scanPath, $basePath); @@ -123,7 +137,7 @@ public function run(array $arguments, string $basePath): int } try { - $configFile = $options['config'] ?? ConfigLoader::discover($basePath); + $configFile = $options['config'] ?? ConfigLoader::discover($workingDirectory, $basePath); $architecture = ConfigLoader::load($configFile); } catch (RuntimeException $runtimeException) { return $this->reportError($runtimeException); diff --git a/src/Cli/ClearCacheCommand.php b/src/Cli/ClearCacheCommand.php index 6585c726..90467500 100644 --- a/src/Cli/ClearCacheCommand.php +++ b/src/Cli/ClearCacheCommand.php @@ -7,18 +7,23 @@ use Boundwize\StructArmed\Cache\AnalysisResultCache; use Boundwize\StructArmed\Cache\FileHashProvider; use Boundwize\StructArmed\Config\ConfigLoader; +use Boundwize\StructArmed\Util\Path; use RuntimeException; use function count; +use function explode; +use function is_dir; use function sprintf; -use function str_starts_with; -use function strlen; -use function substr; use const PHP_EOL; final readonly class ClearCacheCommand { + private const VALUE_OPTIONS = [ + '--config' => 'config', + '--basepath' => 'basepath', + ]; + /** * @param list $arguments */ @@ -28,15 +33,12 @@ public function run(array $arguments, string $basePath): int $counter = count($arguments); for ($i = 0; $i < $counter; $i++) { - $argument = $arguments[$i]; - - if (str_starts_with($argument, '--config=')) { - $options['config'] = substr($argument, strlen('--config=')); - continue; - } + $argument = $arguments[$i]; + $optionAndValue = explode('=', $argument, 2); + $option = $optionAndValue[0]; - if ($argument === '--config') { - $options['config'] = $arguments[++$i] ?? ''; + if (isset(self::VALUE_OPTIONS[$option])) { + $options[self::VALUE_OPTIONS[$option]] = $optionAndValue[1] ?? $arguments[++$i] ?? ''; continue; } @@ -46,10 +48,22 @@ public function run(array $arguments, string $basePath): int return 1; } + $workingDirectory = $basePath; + + if (isset($options['basepath'])) { + $basePath = Path::normalise(Path::resolve($options['basepath'], $workingDirectory), canonicalise: true); + + if (! is_dir($basePath)) { + echo sprintf("Error: base path [%s] not found.\n", $options['basepath']); + + return 1; + } + } + $cacheDirectory = null; try { - $configFile = $options['config'] ?? ConfigLoader::discover($basePath); + $configFile = $options['config'] ?? ConfigLoader::discover($workingDirectory, $basePath); $cacheDirectory = ConfigLoader::load($configFile)->getCacheDirectory(); } catch (RuntimeException $runtimeException) { if (isset($options['config'])) { diff --git a/src/Cli/Usage.php b/src/Cli/Usage.php index 7c89fd0b..2055660b 100644 --- a/src/Cli/Usage.php +++ b/src/Cli/Usage.php @@ -13,9 +13,9 @@ public static function render(): string structarmed --version 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|github] [--no-progress] [--clear-cache] [--disable-parallel] - [--fix] [--generate-baseline=structarmed-baseline.php] - structarmed --clear-cache [--config=path/to/structarmed.php] + [--basepath=path/to/project] [--report=console|json|github] [--no-progress] + [--clear-cache] [--disable-parallel] [--fix] [--generate-baseline=structarmed-baseline.php] + structarmed --clear-cache [--config=path/to/structarmed.php] [--basepath=path/to/project] TXT; } diff --git a/src/Config/ConfigLoader.php b/src/Config/ConfigLoader.php index 35833363..2b669d8b 100644 --- a/src/Config/ConfigLoader.php +++ b/src/Config/ConfigLoader.php @@ -35,16 +35,22 @@ public static function load(string $configPath): Architecture return $architecture; } - public static function discover(string $basePath): string + /** + * Searches the base paths in order, so the CLI's working directory wins over + * a --basepath project root that also holds a config file. + */ + public static function discover(string ...$basePaths): string { - $candidates = [ - $basePath . '/structarmed.php', - $basePath . '/structarmed.dist.php', - ]; - - foreach ($candidates as $candidate) { - if (file_exists($candidate)) { - return $candidate; + foreach ($basePaths as $basePath) { + $candidates = [ + $basePath . '/structarmed.php', + $basePath . '/structarmed.dist.php', + ]; + + foreach ($candidates as $candidate) { + if (file_exists($candidate)) { + return $candidate; + } } } diff --git a/tests/Cli/StructArmedApplicationCommandRoutingTest.php b/tests/Cli/StructArmedApplicationCommandRoutingTest.php index fb8f6229..e80d33dc 100644 --- a/tests/Cli/StructArmedApplicationCommandRoutingTest.php +++ b/tests/Cli/StructArmedApplicationCommandRoutingTest.php @@ -79,6 +79,17 @@ public function testApplicationRejectsUnknownClearCacheOption(): void $this->assertStringContainsString('Unknown option: --bad-option', $output); } + public function testApplicationRejectsMissingClearCacheBasePath(): void + { + [$exitCode, $output] = $this->runApplication( + ['structarmed', '--clear-cache', '--basepath', 'missing'], + self::BASE_PATH + ); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Error: base path [missing] not found.', $output); + } + public function testInitCommandRejectsUnknownOption(): void { [$exitCode, $output] = $this->runApplication(['structarmed', 'init', '--bad-option'], self::BASE_PATH); @@ -119,6 +130,17 @@ public function testAnalyseCommandRejectsMissingScanPath(): void $this->assertStringContainsString('Error: path [missing] not found.', $output); } + public function testAnalyseCommandRejectsMissingBasePath(): void + { + [$exitCode, $output] = $this->runApplication( + ['structarmed', 'analyse', '--basepath=missing'], + self::BASE_PATH + ); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Error: base path [missing] not found.', $output); + } + public function testAnalyseCommandAcceptsAbsoluteScanPath(): void { [$exitCode, $output] = $this->runApplication( diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index ead2a5ab..e4617169 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -32,6 +32,7 @@ use function preg_replace; use function random_bytes; use function realpath; +use function rename; use function rmdir; use function serialize; use function str_replace; @@ -97,6 +98,41 @@ public function testApplicationClearsConfiguredCacheWithoutAnalyseCommand(): voi } } + public function testApplicationClearsConfiguredCacheFromBasePathOption(): void + { + $basePath = (string) realpath($this->createProjectDirectory()); + $toolsPath = $basePath . '/tools/structarmed'; + $cacheDirectory = $basePath . '/var/cache/structarmed'; + + try { + mkdir($toolsPath, 0777, true); + mkdir($cacheDirectory, 0777, true); + + file_put_contents($cacheDirectory . '/key.json', '{}'); + file_put_contents($toolsPath . '/structarmed.php', <<<'PHP' +cacheDirectory('var/cache/structarmed'); +PHP); + + [$exitCode, $output] = $this->runApplication( + ['structarmed', '--clear-cache', '--basepath=../../'], + $toolsPath + ); + + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString('StructArmed cache cleared.', $output); + $this->assertDirectoryDoesNotExist($cacheDirectory); + } finally { + $this->removeTempDirectory($basePath); + } + } + public function testApplicationClearsConfiguredCacheWithSeparateConfigOption(): void { $basePath = $this->createProjectDirectory(); @@ -426,6 +462,92 @@ class Foo } } + public function testAnalyseCommandResolvesProjectPathsAgainstBasePathOption(): void + { + $basePath = (string) realpath($this->createTempDirectory()); + $toolsPath = $basePath . '/tools/structarmed'; + + mkdir($toolsPath, 0777, true); + mkdir($basePath . '/src/Exception', 0777, true); + file_put_contents($basePath . '/src/ConfigProvider.php', <<<'PHP' +layer('Config', 'src/ConfigProvider.php') + ->layer('Exception', 'src/Exception') + ->rule('config.must_be_final', new MustBeFinalRule('Config')) + ->rule('exception.must_be_final', new MustBeFinalRule('Exception')) + ->rule('composer.psr4_directory_exists', new Psr4DirectoryExistsRule()); +PHP); + + try { + [$exitCode, $output] = $this->runApplication( + ['structarmed', 'analyse', '--basepath=../../', '--no-progress'], + $toolsPath + ); + + $this->assertSame(1, $exitCode, $output); + $this->assertStringContainsString('Class [App\ConfigProvider] must be declared final', $output); + $this->assertStringContainsString('Class [App\Exception\NotFound] must be declared final', $output); + $this->assertStringContainsString('declared in composer.json do not exist on disk', $output); + $this->assertStringContainsString( + $this->normalisePath($basePath . '/src/ConfigProvider.php'), + $this->normalisePath($output) + ); + $this->assertStringContainsString( + $this->normalisePath($basePath . '/src/Exception/NotFound.php'), + $this->normalisePath($output) + ); + + // Without a config next to the tool, discovery falls back to the base path. + rename($toolsPath . '/structarmed.php', $basePath . '/structarmed.php'); + + [$fallbackExitCode, $fallbackOutput] = $this->runApplication( + ['structarmed', 'analyse', '--basepath', '../../', '--no-progress'], + $toolsPath + ); + + $this->assertSame(1, $fallbackExitCode, $fallbackOutput); + $this->assertStringContainsString('Class [App\ConfigProvider] must be declared final', $fallbackOutput); + } finally { + $this->removeTempDirectory($basePath); + } + } + public function testAnalyseCommandOnlyParsesNewFilesAfterCacheWarmup(): void { $basePath = $this->createProjectDirectory(); @@ -1807,6 +1929,10 @@ private function removeTempDirectory(string $basePath): void unlink($basePath . '/structarmed-custom.php'); } + if (file_exists($basePath . '/tools/structarmed/structarmed.php')) { + unlink($basePath . '/tools/structarmed/structarmed.php'); + } + if (file_exists($basePath . '/structarmed-baseline.php')) { unlink($basePath . '/structarmed-baseline.php'); } @@ -1827,6 +1953,14 @@ private function removeTempDirectory(string $basePath): void unlink($sourceFile); } + foreach (glob($basePath . '/src/Exception/*.php') ?: [] as $sourceFile) { + unlink($sourceFile); + } + + if (is_dir($basePath . '/src/Exception')) { + rmdir($basePath . '/src/Exception'); + } + if (is_dir($basePath . '/src/Domain')) { rmdir($basePath . '/src/Domain'); } @@ -1851,6 +1985,14 @@ private function removeTempDirectory(string $basePath): void rmdir($basePath . '/nested'); } + if (is_dir($basePath . '/tools/structarmed')) { + rmdir($basePath . '/tools/structarmed'); + } + + if (is_dir($basePath . '/tools')) { + rmdir($basePath . '/tools'); + } + if (is_dir($basePath)) { rmdir($basePath); } diff --git a/tests/Config/ConfigLoaderTest.php b/tests/Config/ConfigLoaderTest.php index fbe57257..9d58a5d7 100644 --- a/tests/Config/ConfigLoaderTest.php +++ b/tests/Config/ConfigLoaderTest.php @@ -53,6 +53,15 @@ public function testDiscoverFallsBackToDistConfig(): void $this->assertSame($basePath . '/structarmed.dist.php', ConfigLoader::discover($basePath)); } + public function testDiscoverFallsBackToNextBasePath(): void + { + $workingDirectory = $this->makeTempDir(); + $basePath = $this->makeTempDir(); + touch($basePath . '/structarmed.php'); + + $this->assertSame($basePath . '/structarmed.php', ConfigLoader::discover($workingDirectory, $basePath)); + } + private function writeTempConfig(string $body): string { $path = $this->makeTemporaryFile('structarmed-config'); From 81696cb9fa0d83e8751c4c4b48a9eb58a34926cb Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 7 Oct 2026 20:04:51 +0700 Subject: [PATCH 18/20] allow clear cache per base path --- docs/cli.md | 4 +++- src/Cli/StructArmedApplication.php | 23 +++++++++++++++---- ...ructArmedApplicationCommandRoutingTest.php | 11 +++++++++ tests/Cli/StructArmedApplicationTest.php | 2 +- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index c37aba8c..b0db2c9b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -71,9 +71,11 @@ Everything relative to the project root now resolves against the base path: laye `--clear-cache` accepts the same option, so the cache of a project analysed through `--basepath` is cleared with: ```bash -vendor/bin/structarmed --clear-cache --basepath=../../ +vendor/bin/structarmed --basepath=../../ --clear-cache ``` +Options may be given before or after the command. + ## Auto-Fix Violations Use `--fix` to automatically apply fixes for violations produced by rules that implement `Boundwize\StructArmed\Rule\FixableInterface`. diff --git a/src/Cli/StructArmedApplication.php b/src/Cli/StructArmedApplication.php index 90660ea6..5f7244e8 100644 --- a/src/Cli/StructArmedApplication.php +++ b/src/Cli/StructArmedApplication.php @@ -8,24 +8,39 @@ use Boundwize\StructArmed\Version; use function array_slice; +use function array_values; use function getcwd; use function in_array; use function sprintf; final readonly class StructArmedApplication { + private const COMMANDS = ['init', 'analyse', 'analyze', '--clear-cache', '--version', '-V', '--help', '-h']; + /** * @param list $argv */ public function run(array $argv, ?string $basePath = null): int { $basePath ??= (string) getcwd(); - $command = $argv[1] ?? null; + $arguments = array_slice($argv, 1); + $command = $arguments[0] ?? null; if ($command === '--internal-worker') { return AnalysisNodeWorker::run($argv[2] ?? '', $argv[3] ?? ''); } + // Options may precede the command: `structarmed --basepath=../../ --clear-cache`. + foreach ($arguments as $index => $argument) { + if (in_array($argument, self::COMMANDS, true)) { + $command = $argument; + unset($arguments[$index]); + break; + } + } + + $arguments = array_values($arguments); + if (in_array($command, ['--version', '-V'], true)) { echo sprintf("StructArmed %s\n", Version::current()); @@ -39,15 +54,15 @@ public function run(array $argv, ?string $basePath = null): int } if ($command === 'init') { - return (new InitCommand())->run(array_slice($argv, 2), $basePath); + return (new InitCommand())->run($arguments, $basePath); } if ($command === '--clear-cache') { - return (new ClearCacheCommand())->run(array_slice($argv, 2), $basePath); + return (new ClearCacheCommand())->run($arguments, $basePath); } if (in_array($command, ['analyse', 'analyze'], true)) { - return (new AnalyseCommand())->run(array_slice($argv, 2), $basePath); + return (new AnalyseCommand())->run($arguments, $basePath); } echo sprintf("Unknown command: %s\n\n", $command); diff --git a/tests/Cli/StructArmedApplicationCommandRoutingTest.php b/tests/Cli/StructArmedApplicationCommandRoutingTest.php index e80d33dc..2dce6eca 100644 --- a/tests/Cli/StructArmedApplicationCommandRoutingTest.php +++ b/tests/Cli/StructArmedApplicationCommandRoutingTest.php @@ -90,6 +90,17 @@ public function testApplicationRejectsMissingClearCacheBasePath(): void $this->assertStringContainsString('Error: base path [missing] not found.', $output); } + public function testApplicationAcceptsOptionsBeforeClearCacheCommand(): void + { + [$exitCode, $output] = $this->runApplication( + ['structarmed', '--basepath=missing', '--clear-cache'], + self::BASE_PATH + ); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Error: base path [missing] not found.', $output); + } + public function testInitCommandRejectsUnknownOption(): void { [$exitCode, $output] = $this->runApplication(['structarmed', 'init', '--bad-option'], self::BASE_PATH); diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index e4617169..35df6162 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -121,7 +121,7 @@ public function testApplicationClearsConfiguredCacheFromBasePathOption(): void PHP); [$exitCode, $output] = $this->runApplication( - ['structarmed', '--clear-cache', '--basepath=../../'], + ['structarmed', '--basepath=../../', '--clear-cache'], $toolsPath ); From eb1c5f853ad08a28978fc5a314d4c70f68004ce3 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 7 Oct 2026 20:36:15 +0700 Subject: [PATCH 19/20] add alias -d for --basepath --- docs/cli.md | 6 +++++ src/Cli/AnalyseCommand.php | 1 + src/Cli/ClearCacheCommand.php | 1 + src/Cli/Usage.php | 4 ++-- ...ructArmedApplicationCommandRoutingTest.php | 22 +++++++++++++++++++ tests/Cli/StructArmedApplicationTest.php | 2 +- 6 files changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index b0db2c9b..e32fed27 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -66,6 +66,12 @@ cd tools/structarmed vendor/bin/structarmed analyse --basepath=../../ ``` +`-d` is a short alias for `--basepath`: + +```bash +vendor/bin/structarmed analyse -d ../../ +``` + Everything relative to the project root now resolves against the base path: layer paths such as `->layer('Config', 'src/ConfigProvider.php')`, scan paths given on the command line, the `composer.json` read by the composer rules and PSR-4 layers, the cache directory, and baseline paths. The config file is discovered in the current directory first, then in the base path; `--config` keeps pointing to a path relative to the current directory. `--clear-cache` accepts the same option, so the cache of a project analysed through `--basepath` is cleared with: diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index 16df289b..ebe1d571 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -56,6 +56,7 @@ '--report' => 'report', '--config' => 'config', '--basepath' => 'basepath', + '-d' => 'basepath', '--generate-baseline' => 'generate-baseline', ]; diff --git a/src/Cli/ClearCacheCommand.php b/src/Cli/ClearCacheCommand.php index 90467500..3da1eccb 100644 --- a/src/Cli/ClearCacheCommand.php +++ b/src/Cli/ClearCacheCommand.php @@ -22,6 +22,7 @@ private const VALUE_OPTIONS = [ '--config' => 'config', '--basepath' => 'basepath', + '-d' => 'basepath', ]; /** diff --git a/src/Cli/Usage.php b/src/Cli/Usage.php index 2055660b..8e80bdef 100644 --- a/src/Cli/Usage.php +++ b/src/Cli/Usage.php @@ -13,9 +13,9 @@ public static function render(): string structarmed --version structarmed init [--preset=ddd|mvc|psr4|psr1|psr12|per|psr15|yagni|codequality|all] structarmed analyse|analyze [path ...] [--config=path/to/structarmed.php] - [--basepath=path/to/project] [--report=console|json|github] [--no-progress] + [-d|--basepath=path/to/project] [--report=console|json|github] [--no-progress] [--clear-cache] [--disable-parallel] [--fix] [--generate-baseline=structarmed-baseline.php] - structarmed --clear-cache [--config=path/to/structarmed.php] [--basepath=path/to/project] + structarmed --clear-cache [--config=path/to/structarmed.php] [-d|--basepath=path/to/project] TXT; } diff --git a/tests/Cli/StructArmedApplicationCommandRoutingTest.php b/tests/Cli/StructArmedApplicationCommandRoutingTest.php index 2dce6eca..8b3ca7c8 100644 --- a/tests/Cli/StructArmedApplicationCommandRoutingTest.php +++ b/tests/Cli/StructArmedApplicationCommandRoutingTest.php @@ -101,6 +101,17 @@ public function testApplicationAcceptsOptionsBeforeClearCacheCommand(): void $this->assertStringContainsString('Error: base path [missing] not found.', $output); } + public function testApplicationAcceptsShortClearCacheBasePathOption(): void + { + [$exitCode, $output] = $this->runApplication( + ['structarmed', '--clear-cache', '-d=missing'], + self::BASE_PATH + ); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Error: base path [missing] not found.', $output); + } + public function testInitCommandRejectsUnknownOption(): void { [$exitCode, $output] = $this->runApplication(['structarmed', 'init', '--bad-option'], self::BASE_PATH); @@ -152,6 +163,17 @@ public function testAnalyseCommandRejectsMissingBasePath(): void $this->assertStringContainsString('Error: base path [missing] not found.', $output); } + public function testAnalyseCommandAcceptsShortBasePathOption(): void + { + [$exitCode, $output] = $this->runApplication( + ['structarmed', 'analyse', '-d', 'missing'], + self::BASE_PATH + ); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Error: base path [missing] not found.', $output); + } + public function testAnalyseCommandAcceptsAbsoluteScanPath(): void { [$exitCode, $output] = $this->runApplication( diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 35df6162..147a9901 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -537,7 +537,7 @@ class NotFound rename($toolsPath . '/structarmed.php', $basePath . '/structarmed.php'); [$fallbackExitCode, $fallbackOutput] = $this->runApplication( - ['structarmed', 'analyse', '--basepath', '../../', '--no-progress'], + ['structarmed', 'analyse', '-d', '../../', '--no-progress'], $toolsPath ); From 7b94e3476ee18f02eb0aa982584adafd9c9a0ba5 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 8 Oct 2026 12:45:18 +0700 Subject: [PATCH 20/20] temporary disable php 8.5 on macos on github CI due seems temporary down --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e8e7b9b..d61bb134 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,11 @@ jobs: - operating-system: ubuntu-latest php-versions: "8.2" coverage: pcov + exclude: + # Temporarily disabled: GitHub Actions macOS runners currently fail + # to set up PHP 8.5. Re-enable once the runner image is fixed. + - operating-system: macos-latest + php-versions: "8.5" steps: - name: Checkout