From 0992b1039aa82a2278f3db6481b810878a797959 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 28 Jun 2026 15:51:37 +0700 Subject: [PATCH 1/7] Make use of JsonRecast to make Psr4DirectoryExistsRule fixable --- composer.json | 1 + docs/available-rules.md | 4 +- .../AbstractJsonRecastFixableRule.php | 31 ++++ .../Composer/RemoveMissingPsr4PathVisitor.php | 138 ++++++++++++++++++ .../JsonRecast/JsonRecastFixerProcessor.php | 38 +++++ .../Composer/Psr4DirectoryExistsRule.php | 10 +- .../Composer/Psr4DirectoryExistsRuleTest.php | 109 ++++++++++++++ 7 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 src/Rule/Fixer/JsonRecast/AbstractJsonRecastFixableRule.php create mode 100644 src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php create mode 100644 src/Rule/Fixer/JsonRecast/JsonRecastFixerProcessor.php diff --git a/composer.json b/composer.json index 8569b61d..844688fb 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "require": { "php": "^8.2", "composer-runtime-api": "^2.0", + "boundwize/jsonrecast": "^0.0.2", "fidry/cpu-core-counter": "^1.3", "nikic/php-parser": "^5.7" }, diff --git a/docs/available-rules.md b/docs/available-rules.md index 6b0bad45..d5f57472 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -46,7 +46,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Composer`. | Rule | Constructor | Checks | |---|---|---| -| `Psr4DirectoryExistsRule` | `new Psr4DirectoryExistsRule()` | `composer.json` exists, is valid JSON, and every PSR-4 source path exists on disk. | +| `Psr4DirectoryExistsRule` | `new Psr4DirectoryExistsRule()` | `composer.json` exists, is valid JSON, and every PSR-4 source path exists on disk. Supports `--fix` by removing mappings for missing directories. | | `Psr4EmptyNamespacePrefixRule` | `new Psr4EmptyNamespacePrefixRule()` | `autoload` and `autoload-dev` PSR-4 mappings do not use an empty namespace prefix. | | `Psr4NamespaceRule` | `new Psr4NamespaceRule(layer: 'Source')` | A class name matches the namespace expected from its PSR-4 path. | | `Psr4RootPathRule` | `new Psr4RootPathRule()` | PSR-4 mappings do not point directly to the project root. | @@ -91,7 +91,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`. `classNamePattern` and `excludePattern` are regular expressions matched against the fully-qualified class name. -`Psr1PhpTagsRule`, `Psr1Utf8WithoutBomRule`, `MustBeFinalRule`, `MustDeclareConstantVisibilityRule`, `MustDeclareMethodVisibilityRule`, and `MustDeclarePropertyVisibilityRule` implement `Boundwize\StructArmed\Rule\FixableInterface`, so StructArmed can automatically normalize invalid PHP opening tags, remove UTF-8 byte order marks, add the `final` class modifier, and add missing constant, method, or property visibility modifiers when you run `vendor/bin/structarmed analyse --fix`. +`Psr4DirectoryExistsRule`, `Psr1PhpTagsRule`, `Psr1Utf8WithoutBomRule`, `MustBeFinalRule`, `MustDeclareConstantVisibilityRule`, `MustDeclareMethodVisibilityRule`, and `MustDeclarePropertyVisibilityRule` implement `Boundwize\StructArmed\Rule\FixableInterface`, so StructArmed can automatically remove PSR-4 mappings for missing directories, normalize invalid PHP opening tags, remove UTF-8 byte order marks, add the `final` class modifier, and add missing constant, method, or property visibility modifiers when you run `vendor/bin/structarmed analyse --fix`. ## Layer Rules diff --git a/src/Rule/Fixer/JsonRecast/AbstractJsonRecastFixableRule.php b/src/Rule/Fixer/JsonRecast/AbstractJsonRecastFixableRule.php new file mode 100644 index 00000000..9b06fb7a --- /dev/null +++ b/src/Rule/Fixer/JsonRecast/AbstractJsonRecastFixableRule.php @@ -0,0 +1,31 @@ +fixerProcessor()->process( + $ruleViolation->file, + $this->createFixerVisitor($ruleViolation), + ); + } + + abstract protected function createFixerVisitor(RuleViolation $ruleViolation): NodeJsonVisitor; + + private function fixerProcessor(): JsonRecastFixerProcessor + { + static $processor; + + return $processor instanceof JsonRecastFixerProcessor + ? $processor + : ($processor = new JsonRecastFixerProcessor()); + } +} diff --git a/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php b/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php new file mode 100644 index 00000000..0a31b28e --- /dev/null +++ b/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php @@ -0,0 +1,138 @@ +isPsr4Mapping($nodeJsonPath)) { + if ( + $nodeJson->value instanceof StringNode + && ! $this->directoryExists($nodeJson->value->value) + ) { + return NodeJsonVisitor::REMOVE_NODE; + } + + return null; + } + + if (! $nodeJson instanceof ArrayItemNode || ! $this->isPsr4PathListItem($nodeJsonPath)) { + return null; + } + + if (! $nodeJson->value instanceof StringNode || $this->directoryExists($nodeJson->value->value)) { + return null; + } + + return NodeJsonVisitor::REMOVE_NODE; + } + + public function leaveNode(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): ?int + { + if (! $nodeJson instanceof ObjectItemNode) { + return null; + } + + if ($this->isPsr4Section($nodeJson, $nodeJsonPath)) { + if (! $nodeJson->value instanceof ObjectNode || $nodeJson->value->items !== []) { + return null; + } + + return NodeJsonVisitor::REMOVE_NODE; + } + + if (! $this->isPsr4Mapping($nodeJsonPath)) { + return null; + } + + if (! $nodeJson->value instanceof ArrayNode || $nodeJson->value->items !== []) { + return null; + } + + return NodeJsonVisitor::REMOVE_NODE; + } + + private function isPsr4Section(ObjectItemNode $objectItemNode, NodeJsonPath $nodeJsonPath): bool + { + $segments = $nodeJsonPath->segments(); + + if (count($segments) !== 1) { + return false; + } + + return $this->isComposerAutoloadSection($segments[0]) + && $objectItemNode->key->value === 'psr-4'; + } + + private function isPsr4Mapping(NodeJsonPath $nodeJsonPath): bool + { + $segments = $nodeJsonPath->segments(); + + if (count($segments) !== 2) { + return false; + } + + return $this->isComposerAutoloadSection($segments[0]) + && $this->isObjectKey($segments[1], 'psr-4'); + } + + private function isPsr4PathListItem(NodeJsonPath $nodeJsonPath): bool + { + $segments = $nodeJsonPath->segments(); + + if (count($segments) !== 4) { + return false; + } + + if (! $this->isComposerAutoloadSection($segments[0])) { + return false; + } + + if (! $this->isObjectKey($segments[1], 'psr-4')) { + return false; + } + + return $segments[2]->isObjectKey() && $segments[3]->isArrayIndex(); + } + + private function isComposerAutoloadSection(NodeJsonPathSegment $segment): bool + { + return $segment->isObjectKey() + && in_array($segment->value, ['autoload', 'autoload-dev'], true); + } + + private function isObjectKey(NodeJsonPathSegment $segment, string $key): bool + { + return $segment->isObjectKey() && $segment->value === $key; + } + + private function directoryExists(string $path): bool + { + return is_dir(Path::resolve(Path::normalise(trim($path)), $this->basePath)); + } +} diff --git a/src/Rule/Fixer/JsonRecast/JsonRecastFixerProcessor.php b/src/Rule/Fixer/JsonRecast/JsonRecastFixerProcessor.php new file mode 100644 index 00000000..8d881713 --- /dev/null +++ b/src/Rule/Fixer/JsonRecast/JsonRecastFixerProcessor.php @@ -0,0 +1,38 @@ +file)); + } } diff --git a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php index cfbe4f33..5c9dcad1 100644 --- a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php +++ b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php @@ -5,20 +5,33 @@ namespace Boundwize\StructArmed\Tests\Rule\Composer; use Boundwize\StructArmed\Architecture; +use Boundwize\StructArmed\Rule\FixableInterface; +use Boundwize\StructArmed\Rule\Fixer\JsonRecast\AbstractJsonRecastFixableRule; +use Boundwize\StructArmed\Rule\Fixer\JsonRecast\Composer\RemoveMissingPsr4PathVisitor; +use Boundwize\StructArmed\Rule\Fixer\JsonRecast\JsonRecastFixerProcessor; use Boundwize\StructArmed\Rule\Rules\Composer\Psr4DirectoryExistsRule; use Boundwize\StructArmed\Rule\RuleViolation; use Boundwize\StructArmed\Tests\Support\TemporaryDirectoryCleanupTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use function file_get_contents; use function file_put_contents; use function mkdir; #[CoversClass(Psr4DirectoryExistsRule::class)] +#[CoversClass(AbstractJsonRecastFixableRule::class)] +#[CoversClass(JsonRecastFixerProcessor::class)] +#[CoversClass(RemoveMissingPsr4PathVisitor::class)] final class Psr4DirectoryExistsRuleTest extends TestCase { use TemporaryDirectoryCleanupTrait; + public function testIsFixable(): void + { + $this->assertInstanceOf(FixableInterface::class, new Psr4DirectoryExistsRule()); + } + public function testPassesWhenAllPsr4DirectoriesExistOnDisk(): void { $basePath = $this->makeTempProject(<<<'JSON' @@ -90,6 +103,102 @@ public function testPassesWhenNoPsr4PathsAreDeclared(): void ); } + public function testFixRemovesPsr4MappingsForMissingDirectories(): void + { + $basePath = $this->makeTempProject(<<<'JSON' +{ + "autoload": { + "psr-4": { + "App\\": "src/", + "Missing\\": "missing/", + "Mixed\\": ["src/", "missing-tests/"], + "Gone\\": ["missing-one/", "missing-two/"] + } + }, + "autoload-dev": { + "psr-4": { + "ExistingTests\\": "tests/", + "MissingTests\\": "missing-tests/" + } + } +} +JSON, ['src', 'tests']); + $rule = new Psr4DirectoryExistsRule(); + $violation = $rule->evaluateProject($basePath, Architecture::define()); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertTrue($rule->fix($violation)); + + $this->assertSame(<<<'JSON' +{ + "autoload": { + "psr-4": { + "App\\": "src/", + "Mixed\\": ["src/"] + } + }, + "autoload-dev": { + "psr-4": { + "ExistingTests\\": "tests/" + } + } +} +JSON, file_get_contents($basePath . '/composer.json')); + $this->assertNotInstanceOf( + RuleViolation::class, + $rule->evaluateProject($basePath, Architecture::define()) + ); + } + + public function testFixRemovesPsr4BlockWhenEveryMappingDirectoryIsMissing(): void + { + $basePath = $this->makeTempProject(<<<'JSON' +{ + "autoload": { + "psr-4": { + "View\\": "directory/not/exists" + } + } +} +JSON); + $rule = new Psr4DirectoryExistsRule(); + $violation = $rule->evaluateProject($basePath, Architecture::define()); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertTrue($rule->fix($violation)); + + $this->assertSame(<<<'JSON' +{ + "autoload": { + } +} +JSON, file_get_contents($basePath . '/composer.json')); + $this->assertNotInstanceOf( + RuleViolation::class, + $rule->evaluateProject($basePath, Architecture::define()) + ); + } + + public function testFixReturnsFalseWhenAllPsr4DirectoriesExist(): void + { + $basePath = $this->makeTempProject(<<<'JSON' +{ + "autoload": { + "psr-4": { + "App\\": "src/" + } + } +} +JSON, ['src']); + + $this->assertFalse((new Psr4DirectoryExistsRule())->fix(new RuleViolation( + message: 'PSR-4 source path(s) [src] declared in composer.json do not exist on disk', + file: $basePath . '/composer.json', + line: 1, + className: '', + ))); + } + /** * @param list $dirs */ From 5ef940063ad31b60328fbf757e21cd46e2feba80 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 28 Jun 2026 16:05:33 +0700 Subject: [PATCH 2/7] clean up autoload/autoload-dev section when there is no data --- .../Composer/RemoveMissingPsr4PathVisitor.php | 34 +++++++++++++++---- .../Composer/Psr4DirectoryExistsRuleTest.php | 25 ++++++-------- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php b/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php index 0a31b28e..aee6f771 100644 --- a/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php +++ b/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php @@ -52,8 +52,15 @@ public function enterNode(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): ?int return NodeJsonVisitor::REMOVE_NODE; } - public function leaveNode(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): ?int + public function leaveNode(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): null|NodeJson|int { + if ($nodeJson instanceof ObjectNode && $nodeJsonPath->isRoot() && $nodeJson->items === []) { + $nodeJson->afterOpenBrace = ''; + $nodeJson->beforeCloseBrace = ''; + + return $nodeJson; + } + if (! $nodeJson instanceof ObjectItemNode) { return null; } @@ -66,6 +73,10 @@ public function leaveNode(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): ?int return NodeJsonVisitor::REMOVE_NODE; } + if ($this->isEmptyComposerAutoloadItem($nodeJson, $nodeJsonPath)) { + return NodeJsonVisitor::REMOVE_NODE; + } + if (! $this->isPsr4Mapping($nodeJsonPath)) { return null; } @@ -89,6 +100,17 @@ private function isPsr4Section(ObjectItemNode $objectItemNode, NodeJsonPath $nod && $objectItemNode->key->value === 'psr-4'; } + private function isEmptyComposerAutoloadItem(ObjectItemNode $objectItemNode, NodeJsonPath $nodeJsonPath): bool + { + if ($nodeJsonPath->segments() !== []) { + return false; + } + + return in_array($objectItemNode->key->value, ['autoload', 'autoload-dev'], true) + && $objectItemNode->value instanceof ObjectNode + && $objectItemNode->value->items === []; + } + private function isPsr4Mapping(NodeJsonPath $nodeJsonPath): bool { $segments = $nodeJsonPath->segments(); @@ -120,15 +142,15 @@ private function isPsr4PathListItem(NodeJsonPath $nodeJsonPath): bool return $segments[2]->isObjectKey() && $segments[3]->isArrayIndex(); } - private function isComposerAutoloadSection(NodeJsonPathSegment $segment): bool + private function isComposerAutoloadSection(NodeJsonPathSegment $nodeJsonPathSegment): bool { - return $segment->isObjectKey() - && in_array($segment->value, ['autoload', 'autoload-dev'], true); + return $nodeJsonPathSegment->isObjectKey() + && in_array($nodeJsonPathSegment->value, ['autoload', 'autoload-dev'], true); } - private function isObjectKey(NodeJsonPathSegment $segment, string $key): bool + private function isObjectKey(NodeJsonPathSegment $nodeJsonPathSegment, string $key): bool { - return $segment->isObjectKey() && $segment->value === $key; + return $nodeJsonPathSegment->isObjectKey() && $nodeJsonPathSegment->value === $key; } private function directoryExists(string $path): bool diff --git a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php index 5c9dcad1..2626f502 100644 --- a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php +++ b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php @@ -105,7 +105,7 @@ public function testPassesWhenNoPsr4PathsAreDeclared(): void public function testFixRemovesPsr4MappingsForMissingDirectories(): void { - $basePath = $this->makeTempProject(<<<'JSON' + $basePath = $this->makeTempProject(<<<'JSON' { "autoload": { "psr-4": { @@ -123,11 +123,11 @@ public function testFixRemovesPsr4MappingsForMissingDirectories(): void } } JSON, ['src', 'tests']); - $rule = new Psr4DirectoryExistsRule(); - $violation = $rule->evaluateProject($basePath, Architecture::define()); + $psr4DirectoryExistsRule = new Psr4DirectoryExistsRule(); + $violation = $psr4DirectoryExistsRule->evaluateProject($basePath, Architecture::define()); $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertTrue($rule->fix($violation)); + $this->assertTrue($psr4DirectoryExistsRule->fix($violation)); $this->assertSame(<<<'JSON' { @@ -146,13 +146,13 @@ public function testFixRemovesPsr4MappingsForMissingDirectories(): void JSON, file_get_contents($basePath . '/composer.json')); $this->assertNotInstanceOf( RuleViolation::class, - $rule->evaluateProject($basePath, Architecture::define()) + $psr4DirectoryExistsRule->evaluateProject($basePath, Architecture::define()) ); } public function testFixRemovesPsr4BlockWhenEveryMappingDirectoryIsMissing(): void { - $basePath = $this->makeTempProject(<<<'JSON' + $basePath = $this->makeTempProject(<<<'JSON' { "autoload": { "psr-4": { @@ -161,21 +161,18 @@ public function testFixRemovesPsr4BlockWhenEveryMappingDirectoryIsMissing(): voi } } JSON); - $rule = new Psr4DirectoryExistsRule(); - $violation = $rule->evaluateProject($basePath, Architecture::define()); + $psr4DirectoryExistsRule = new Psr4DirectoryExistsRule(); + $violation = $psr4DirectoryExistsRule->evaluateProject($basePath, Architecture::define()); $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertTrue($rule->fix($violation)); + $this->assertTrue($psr4DirectoryExistsRule->fix($violation)); $this->assertSame(<<<'JSON' -{ - "autoload": { - } -} +{} JSON, file_get_contents($basePath . '/composer.json')); $this->assertNotInstanceOf( RuleViolation::class, - $rule->evaluateProject($basePath, Architecture::define()) + $psr4DirectoryExistsRule->evaluateProject($basePath, Architecture::define()) ); } From b35c69d2cf1ed0992b6011c6d1f8ffe1eb09a373 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 28 Jun 2026 16:12:20 +0700 Subject: [PATCH 3/7] fix root empty --- .../JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php | 7 ------- tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php | 3 ++- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php b/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php index aee6f771..32265039 100644 --- a/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php +++ b/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php @@ -54,13 +54,6 @@ public function enterNode(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): ?int public function leaveNode(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): null|NodeJson|int { - if ($nodeJson instanceof ObjectNode && $nodeJsonPath->isRoot() && $nodeJson->items === []) { - $nodeJson->afterOpenBrace = ''; - $nodeJson->beforeCloseBrace = ''; - - return $nodeJson; - } - if (! $nodeJson instanceof ObjectItemNode) { return null; } diff --git a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php index 2626f502..853faaf3 100644 --- a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php +++ b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php @@ -168,7 +168,8 @@ public function testFixRemovesPsr4BlockWhenEveryMappingDirectoryIsMissing(): voi $this->assertTrue($psr4DirectoryExistsRule->fix($violation)); $this->assertSame(<<<'JSON' -{} +{ +} JSON, file_get_contents($basePath . '/composer.json')); $this->assertNotInstanceOf( RuleViolation::class, From e495da174a414cd4d66594d47fca1f2b747587a2 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 28 Jun 2026 16:20:29 +0700 Subject: [PATCH 4/7] ensure has changed --- .../Composer/RemoveMissingPsr4PathVisitor.php | 75 ++++++++++++++++++- .../Composer/Psr4DirectoryExistsRuleTest.php | 35 +++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php b/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php index 32265039..1aad3e4a 100644 --- a/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php +++ b/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php @@ -16,13 +16,21 @@ use Boundwize\JsonRecast\NodeVisitor\NodeJsonVisitorAbstract; use Boundwize\StructArmed\Util\Path; +use function array_key_exists; +use function array_slice; use function count; use function in_array; use function is_dir; +use function strlen; use function trim; final class RemoveMissingPsr4PathVisitor extends NodeJsonVisitorAbstract { + private const CHILD_CHANGED = 'child_changed'; + + /** @var array */ + private array $changedContainerPathKeys = []; + public function __construct( private readonly string $basePath, ) { @@ -35,6 +43,8 @@ public function enterNode(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): ?int $nodeJson->value instanceof StringNode && ! $this->directoryExists($nodeJson->value->value) ) { + $this->markContainerChanged($nodeJsonPath); + return NodeJsonVisitor::REMOVE_NODE; } @@ -49,20 +59,34 @@ public function enterNode(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): ?int return null; } + $this->markContainerChanged($this->parentPath($nodeJsonPath)); + return NodeJsonVisitor::REMOVE_NODE; } - public function leaveNode(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): null|NodeJson|int + public function leaveNode(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): null|int { + if ($nodeJson instanceof ObjectNode || $nodeJson instanceof ArrayNode) { + $this->flagChangedContainer($nodeJson, $nodeJsonPath); + + return null; + } + if (! $nodeJson instanceof ObjectItemNode) { return null; } if ($this->isPsr4Section($nodeJson, $nodeJsonPath)) { - if (! $nodeJson->value instanceof ObjectNode || $nodeJson->value->items !== []) { + if ( + ! $nodeJson->value instanceof ObjectNode + || $nodeJson->value->items !== [] + || ! $this->hasChangedChild($nodeJson->value) + ) { return null; } + $this->markContainerChanged($nodeJsonPath); + return NodeJsonVisitor::REMOVE_NODE; } @@ -74,10 +98,16 @@ public function leaveNode(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): null| return null; } - if (! $nodeJson->value instanceof ArrayNode || $nodeJson->value->items !== []) { + if ( + ! $nodeJson->value instanceof ArrayNode + || $nodeJson->value->items !== [] + || ! $this->hasChangedChild($nodeJson->value) + ) { return null; } + $this->markContainerChanged($nodeJsonPath); + return NodeJsonVisitor::REMOVE_NODE; } @@ -101,7 +131,8 @@ private function isEmptyComposerAutoloadItem(ObjectItemNode $objectItemNode, Nod return in_array($objectItemNode->key->value, ['autoload', 'autoload-dev'], true) && $objectItemNode->value instanceof ObjectNode - && $objectItemNode->value->items === []; + && $objectItemNode->value->items === [] + && $this->hasChangedChild($objectItemNode->value); } private function isPsr4Mapping(NodeJsonPath $nodeJsonPath): bool @@ -150,4 +181,40 @@ private function directoryExists(string $path): bool { return is_dir(Path::resolve(Path::normalise(trim($path)), $this->basePath)); } + + private function markContainerChanged(NodeJsonPath $nodeJsonPath): void + { + $this->changedContainerPathKeys[$this->pathKey($nodeJsonPath)] = true; + } + + private function flagChangedContainer(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): void + { + if (! array_key_exists($this->pathKey($nodeJsonPath), $this->changedContainerPathKeys)) { + return; + } + + $nodeJson->setAttribute(self::CHILD_CHANGED, true); + } + + private function hasChangedChild(NodeJson $nodeJson): bool + { + return $nodeJson->getAttribute(self::CHILD_CHANGED) === true; + } + + private function parentPath(NodeJsonPath $nodeJsonPath): NodeJsonPath + { + return new NodeJsonPath(array_slice($nodeJsonPath->segments(), 0, -1)); + } + + private function pathKey(NodeJsonPath $nodeJsonPath): string + { + $key = ''; + + foreach ($nodeJsonPath->segments() as $nodeJsonPathSegment) { + $value = (string) $nodeJsonPathSegment->value; + $key .= ($nodeJsonPathSegment->isObjectKey() ? 'o' : 'a') . strlen($value) . ':' . $value; + } + + return $key; + } } diff --git a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php index 853faaf3..73a59bb4 100644 --- a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php +++ b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php @@ -177,6 +177,41 @@ public function testFixRemovesPsr4BlockWhenEveryMappingDirectoryIsMissing(): voi ); } + public function testFixKeepsUnchangedEmptyPsr4Block(): void + { + $basePath = $this->makeTempProject(<<<'JSON' +{ + "autoload": { + "psr-4": { + } + }, + "autoload-dev": { + "psr-4": { + "View\\Tests\\": "directory/not/exists" + } + } +} +JSON); + $psr4DirectoryExistsRule = new Psr4DirectoryExistsRule(); + $violation = $psr4DirectoryExistsRule->evaluateProject($basePath, Architecture::define()); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertTrue($psr4DirectoryExistsRule->fix($violation)); + + $this->assertSame(<<<'JSON' +{ + "autoload": { + "psr-4": { + } + } +} +JSON, file_get_contents($basePath . '/composer.json')); + $this->assertNotInstanceOf( + RuleViolation::class, + $psr4DirectoryExistsRule->evaluateProject($basePath, Architecture::define()) + ); + } + public function testFixReturnsFalseWhenAllPsr4DirectoriesExist(): void { $basePath = $this->makeTempProject(<<<'JSON' From 6c185caca07ff08a44d0ddade681106deba2e98c Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 28 Jun 2026 16:28:57 +0700 Subject: [PATCH 5/7] more test --- .../RemoveMissingPsr4PathVisitorTest.php | 41 +++++++++++++ .../JsonRecastFixerProcessorTest.php | 57 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 tests/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitorTest.php create mode 100644 tests/Rule/Fixer/JsonRecast/JsonRecastFixerProcessorTest.php diff --git a/tests/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitorTest.php b/tests/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitorTest.php new file mode 100644 index 00000000..6a973acd --- /dev/null +++ b/tests/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitorTest.php @@ -0,0 +1,41 @@ +assertNull($removeMissingPsr4PathVisitor->enterNode($arrayItemNode, new NodeJsonPath([ + NodeJsonPathSegment::objectKey('autoload'), + NodeJsonPathSegment::objectKey('psr-4'), + NodeJsonPathSegment::objectKey('App\\'), + ]))); + $this->assertNull($removeMissingPsr4PathVisitor->enterNode($arrayItemNode, new NodeJsonPath([ + NodeJsonPathSegment::objectKey('scripts'), + NodeJsonPathSegment::objectKey('psr-4'), + NodeJsonPathSegment::objectKey('App\\'), + NodeJsonPathSegment::arrayIndex(0), + ]))); + $this->assertNull($removeMissingPsr4PathVisitor->enterNode($arrayItemNode, new NodeJsonPath([ + NodeJsonPathSegment::objectKey('autoload'), + NodeJsonPathSegment::objectKey('classmap'), + NodeJsonPathSegment::objectKey('App\\'), + NodeJsonPathSegment::arrayIndex(0), + ]))); + } +} diff --git a/tests/Rule/Fixer/JsonRecast/JsonRecastFixerProcessorTest.php b/tests/Rule/Fixer/JsonRecast/JsonRecastFixerProcessorTest.php new file mode 100644 index 00000000..ed85f322 --- /dev/null +++ b/tests/Rule/Fixer/JsonRecast/JsonRecastFixerProcessorTest.php @@ -0,0 +1,57 @@ +temporaryJsonFile('{}'); + unlink($file); + + $this->assertFalse($this->process($file)); + } + + public function testProcessReturnsFalseWhenJsonCannotBeParsed(): void + { + $file = $this->temporaryJsonFile('{not json'); + + try { + $this->assertFalse($this->process($file)); + } finally { + unlink($file); + } + } + + private function process(string $file): bool + { + return (new JsonRecastFixerProcessor())->process( + $file, + new class extends NodeJsonVisitorAbstract { + }, + ); + } + + private function temporaryJsonFile(string $contents): string + { + $file = tempnam(sys_get_temp_dir(), 'structarmed-jsonrecast-fixer-'); + $this->assertIsString($file); + + file_put_contents($file, $contents); + + return $file; + } +} From 34828b57aa151d2ae7598ed0fc8a401aa81ca424 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 28 Jun 2026 18:06:36 +0700 Subject: [PATCH 6/7] simplify --- .../Composer/RemoveMissingPsr4PathVisitor.php | 63 +++++++------------ 1 file changed, 23 insertions(+), 40 deletions(-) diff --git a/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php b/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php index 1aad3e4a..44da6282 100644 --- a/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php +++ b/src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php @@ -11,14 +11,12 @@ use Boundwize\JsonRecast\Node\ObjectNode; use Boundwize\JsonRecast\Node\StringNode; use Boundwize\JsonRecast\NodePath\NodeJsonPath; -use Boundwize\JsonRecast\NodePath\NodeJsonPathSegment; use Boundwize\JsonRecast\NodeVisitor\NodeJsonVisitor; use Boundwize\JsonRecast\NodeVisitor\NodeJsonVisitorAbstract; use Boundwize\StructArmed\Util\Path; use function array_key_exists; use function array_slice; -use function count; use function in_array; use function is_dir; use function strlen; @@ -28,6 +26,9 @@ final class RemoveMissingPsr4PathVisitor extends NodeJsonVisitorAbstract { private const CHILD_CHANGED = 'child_changed'; + /** @var list */ + private const AUTOLOAD_SECTIONS = ['autoload', 'autoload-dev']; + /** @var array */ private array $changedContainerPathKeys = []; @@ -113,23 +114,14 @@ public function leaveNode(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): null| private function isPsr4Section(ObjectItemNode $objectItemNode, NodeJsonPath $nodeJsonPath): bool { - $segments = $nodeJsonPath->segments(); - - if (count($segments) !== 1) { - return false; - } - - return $this->isComposerAutoloadSection($segments[0]) - && $objectItemNode->key->value === 'psr-4'; + return $objectItemNode->key->value === 'psr-4' + && $this->isComposerAutoloadPath($nodeJsonPath); } private function isEmptyComposerAutoloadItem(ObjectItemNode $objectItemNode, NodeJsonPath $nodeJsonPath): bool { - if ($nodeJsonPath->segments() !== []) { - return false; - } - - return in_array($objectItemNode->key->value, ['autoload', 'autoload-dev'], true) + return $nodeJsonPath->isRoot() + && $this->isComposerAutoloadKey($objectItemNode->key->value) && $objectItemNode->value instanceof ObjectNode && $objectItemNode->value->items === [] && $this->hasChangedChild($objectItemNode->value); @@ -137,44 +129,35 @@ private function isEmptyComposerAutoloadItem(ObjectItemNode $objectItemNode, Nod private function isPsr4Mapping(NodeJsonPath $nodeJsonPath): bool { - $segments = $nodeJsonPath->segments(); - - if (count($segments) !== 2) { - return false; - } - - return $this->isComposerAutoloadSection($segments[0]) - && $this->isObjectKey($segments[1], 'psr-4'); + return $nodeJsonPath->matches(['autoload', 'psr-4']) + || $nodeJsonPath->matches(['autoload-dev', 'psr-4']); } private function isPsr4PathListItem(NodeJsonPath $nodeJsonPath): bool { - $segments = $nodeJsonPath->segments(); - - if (count($segments) !== 4) { - return false; - } + $last = $nodeJsonPath->last(); - if (! $this->isComposerAutoloadSection($segments[0])) { - return false; - } + return $last?->isArrayIndex() === true + && $this->isPsr4MappingValuePath($this->parentPath($nodeJsonPath)); + } - if (! $this->isObjectKey($segments[1], 'psr-4')) { - return false; - } + private function isPsr4MappingValuePath(NodeJsonPath $nodeJsonPath): bool + { + $last = $nodeJsonPath->last(); - return $segments[2]->isObjectKey() && $segments[3]->isArrayIndex(); + return $last?->isObjectKey() === true + && $this->isPsr4Mapping($this->parentPath($nodeJsonPath)); } - private function isComposerAutoloadSection(NodeJsonPathSegment $nodeJsonPathSegment): bool + private function isComposerAutoloadPath(NodeJsonPath $nodeJsonPath): bool { - return $nodeJsonPathSegment->isObjectKey() - && in_array($nodeJsonPathSegment->value, ['autoload', 'autoload-dev'], true); + return $nodeJsonPath->matches(['autoload']) + || $nodeJsonPath->matches(['autoload-dev']); } - private function isObjectKey(NodeJsonPathSegment $nodeJsonPathSegment, string $key): bool + private function isComposerAutoloadKey(string $key): bool { - return $nodeJsonPathSegment->isObjectKey() && $nodeJsonPathSegment->value === $key; + return in_array($key, self::AUTOLOAD_SECTIONS, true); } private function directoryExists(string $path): bool From 44a7a298e0361eac4d6392b2ba9a86d42fe20d7a Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 28 Jun 2026 18:56:30 +0700 Subject: [PATCH 7/7] update 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 80814e77..2cc26b35 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.14.3 — Architecture Enforcement + StructArmed 0.14.4 — Architecture Enforcement =============================================== diff --git a/docs/assets/structarmed-showoff.svg b/docs/assets/structarmed-showoff.svg index d61c269c..7d2da845 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.14.3 — Architecture Enforcement + StructArmed 0.14.4 — Architecture Enforcement ===============================================