From 9839d953ddbd6aaca1a3ea240a3f74feaf3dda73 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 27 Jun 2026 07:34:46 +0700 Subject: [PATCH 1/2] chore: Extract Psr1Utf8WithoutBomRule into 2 rules: Psr1Utf8WithoutBomRule and Psr1ValidUtf8Rule and make Psr1Utf8WithoutBomRule implements FixableInterface --- docs/available-rules.md | 5 +- docs/presets.md | 2 +- src/Preset/Presets/Psr1Preset.php | 4 + .../Rules/File/Psr1Utf8WithoutBomRule.php | 33 +++-- src/Rule/Rules/File/Psr1ValidUtf8Rule.php | 70 +++++++++++ tests/Preset/PresetTest.php | 5 +- .../Rule/File/Psr1Utf8WithoutBomRuleTest.php | 69 ++++++++++- tests/Rule/File/Psr1ValidUtf8RuleTest.php | 114 ++++++++++++++++++ 8 files changed, 283 insertions(+), 19 deletions(-) create mode 100644 src/Rule/Rules/File/Psr1ValidUtf8Rule.php create mode 100644 tests/Rule/File/Psr1ValidUtf8RuleTest.php diff --git a/docs/available-rules.md b/docs/available-rules.md index daed7056..6b0bad45 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -61,7 +61,8 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\File`. |---|---|---| | `Psr1PhpTagsRule` | `new Psr1PhpTagsRule(sourcePaths: ['src/'])` | PHP files use only `layer($layerName, $this->sourcePaths ?? []); $architecture->rule(self::FILES_MUST_USE_VALID_TAGS, new Psr1PhpTagsRule($this->sourcePaths)); + $architecture->rule(self::FILES_MUST_USE_VALID_UTF8, new Psr1ValidUtf8Rule($this->sourcePaths)); $architecture->rule(self::FILES_MUST_USE_UTF8_WITHOUT_BOM, new Psr1Utf8WithoutBomRule($this->sourcePaths)); $architecture->rule( self::FILES_SHOULD_DECLARE_SYMBOLS_OR_SIDE_EFFECTS, diff --git a/src/Rule/Rules/File/Psr1Utf8WithoutBomRule.php b/src/Rule/Rules/File/Psr1Utf8WithoutBomRule.php index f37ae54e..9a2e0aba 100644 --- a/src/Rule/Rules/File/Psr1Utf8WithoutBomRule.php +++ b/src/Rule/Rules/File/Psr1Utf8WithoutBomRule.php @@ -7,11 +7,17 @@ use Boundwize\StructArmed\Analyser\FileAnalysisProvider; use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface; +use Boundwize\StructArmed\Rule\FixableInterface; use Boundwize\StructArmed\Rule\RuleViolation; +use function file_get_contents; +use function file_put_contents; +use function is_file; use function sprintf; +use function str_starts_with; +use function substr; -final readonly class Psr1Utf8WithoutBomRule implements FileAnalysisRuleInterface +final readonly class Psr1Utf8WithoutBomRule implements FileAnalysisRuleInterface, FixableInterface { /** * @param list|null $sourcePaths @@ -62,19 +68,24 @@ public function evaluateProjectAllWithProvider( line: 1, className: '', ); - continue; - } - - if (! $fileAnalysisProvider->hasValidUtf8($file)) { - $violations[] = new RuleViolation( - message: sprintf('File [%s] must use valid UTF-8 encoding', $file), - file: $file, - line: 1, - className: '', - ); } } return $violations; } + + public function fix(RuleViolation $ruleViolation): bool + { + if (! is_file($ruleViolation->file)) { + return false; + } + + $code = (string) file_get_contents($ruleViolation->file); + + if (! str_starts_with($code, "\xEF\xBB\xBF")) { + return false; + } + + return file_put_contents($ruleViolation->file, substr($code, 3)) !== false; + } } diff --git a/src/Rule/Rules/File/Psr1ValidUtf8Rule.php b/src/Rule/Rules/File/Psr1ValidUtf8Rule.php new file mode 100644 index 00000000..974b9826 --- /dev/null +++ b/src/Rule/Rules/File/Psr1ValidUtf8Rule.php @@ -0,0 +1,70 @@ +|null $sourcePaths + */ + public function __construct( + private ?array $sourcePaths = null, + private ?PhpFileFinder $phpFileFinder = null, + ) { + } + + public function evaluateProject(string $basePath, Architecture $architecture, array $skipPaths = []): ?RuleViolation + { + return $this->evaluateProjectAll($basePath, $architecture, $skipPaths)[0] ?? null; + } + + /** + * @param list $skipPaths + * @return RuleViolation[] + */ + public function evaluateProjectAll(string $basePath, Architecture $architecture, array $skipPaths = []): array + { + return $this->evaluateProjectAllWithProvider( + $basePath, + $architecture, + new FileAnalysisProvider(), + $skipPaths, + ); + } + + /** + * @param list $skipPaths + * @return RuleViolation[] + */ + public function evaluateProjectAllWithProvider( + string $basePath, + Architecture $architecture, + FileAnalysisProvider $fileAnalysisProvider, + array $skipPaths = [], + ): array { + $phpFileFinder = $this->phpFileFinder ?? new PhpFileFinder($this->sourcePaths); + $violations = []; + + foreach ($phpFileFinder->files($basePath, $skipPaths) as $file) { + if (! $fileAnalysisProvider->hasValidUtf8($file)) { + $violations[] = new RuleViolation( + message: sprintf('File [%s] must use valid UTF-8 encoding', $file), + file: $file, + line: 1, + className: '', + ); + } + } + + return $violations; + } +} diff --git a/tests/Preset/PresetTest.php b/tests/Preset/PresetTest.php index 901cdd42..82b041a2 100644 --- a/tests/Preset/PresetTest.php +++ b/tests/Preset/PresetTest.php @@ -38,6 +38,7 @@ public function testPsr1PresetRegistersSourceLayerAndRules(): void $rules = $architecture->getRules(); $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_TAGS, $rules); + $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_UTF8, $rules); $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_UTF8_WITHOUT_BOM, $rules); $this->assertArrayHasKey(Psr1Preset::FILES_SHOULD_DECLARE_SYMBOLS_OR_SIDE_EFFECTS, $rules); $this->assertArrayHasKey(Psr4Preset::CLASSES_MUST_MATCH_COMPOSER, $rules); @@ -60,6 +61,7 @@ public function testPsr12PresetAppliesPsr1RulesAndAddsVisibilityRules(): void $rules = $architecture->getRules(); $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_TAGS, $rules); + $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_UTF8, $rules); $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_UTF8_WITHOUT_BOM, $rules); $this->assertArrayHasKey(Psr1Preset::FILES_SHOULD_DECLARE_SYMBOLS_OR_SIDE_EFFECTS, $rules); $this->assertArrayHasKey(Psr4Preset::CLASSES_MUST_MATCH_COMPOSER, $rules); @@ -220,9 +222,10 @@ public function testPsr1AndPsr12BothEnabledDoNotDuplicatePsr1Rules(): void $rules = $architecture->getRules(); - $this->assertCount(14, $rules); + $this->assertCount(15, $rules); $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_TAGS, $rules); + $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_UTF8, $rules); $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_UTF8_WITHOUT_BOM, $rules); $this->assertArrayHasKey(Psr1Preset::FILES_SHOULD_DECLARE_SYMBOLS_OR_SIDE_EFFECTS, $rules); $this->assertArrayHasKey(Psr4Preset::CLASSES_MUST_MATCH_COMPOSER, $rules); diff --git a/tests/Rule/File/Psr1Utf8WithoutBomRuleTest.php b/tests/Rule/File/Psr1Utf8WithoutBomRuleTest.php index bb94acfc..a8c9a2f2 100644 --- a/tests/Rule/File/Psr1Utf8WithoutBomRuleTest.php +++ b/tests/Rule/File/Psr1Utf8WithoutBomRuleTest.php @@ -5,6 +5,7 @@ namespace Boundwize\StructArmed\Tests\Rule\File; use Boundwize\StructArmed\Architecture; +use Boundwize\StructArmed\Rule\FixableInterface; use Boundwize\StructArmed\Rule\Rules\File\PhpFileFinder; use Boundwize\StructArmed\Rule\Rules\File\Psr1Utf8WithoutBomRule; use Boundwize\StructArmed\Rule\RuleViolation; @@ -12,6 +13,7 @@ use PHPUnit\Framework\TestCase; use function bin2hex; +use function file_get_contents; use function file_put_contents; use function mkdir; use function random_bytes; @@ -41,7 +43,7 @@ public function testViolatesUtf8Bom(): void } } - public function testViolatesInvalidUtf8(): void + public function testPassesInvalidUtf8WithoutBom(): void { $basePath = $this->makeTempDir(); @@ -51,8 +53,7 @@ public function testViolatesInvalidUtf8(): void $violations = (new Psr1Utf8WithoutBomRule(['src/']))->evaluateProjectAll($basePath, Architecture::define()); - $this->assertCount(1, $violations); - $this->assertStringContainsString('valid UTF-8', $violations[0]->message); + $this->assertSame([], $violations); } finally { unlink($basePath . '/src/Foo.php'); rmdir($basePath . '/src'); @@ -60,7 +61,7 @@ public function testViolatesInvalidUtf8(): void } } - public function testPassesValidUtf8AndMissingPaths(): void + public function testPassesFilesWithoutBomAndMissingPaths(): void { $basePath = $this->makeTempDir(); @@ -102,6 +103,66 @@ public function testEvaluateProjectReturnsFirstViolation(): void } } + public function testIsFixable(): void + { + $this->assertInstanceOf(FixableInterface::class, new Psr1Utf8WithoutBomRule(['src/'])); + } + + public function testFixesUtf8Bom(): void + { + $basePath = $this->makeTempDir(); + + try { + mkdir($basePath . '/src'); + file_put_contents($basePath . '/src/Foo.php', "\xEF\xBB\xBFevaluateProject($basePath, Architecture::define()); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertTrue($psr1Utf8WithoutBomRule->fix($violation)); + $this->assertSame('makeTempDir(); + + try { + mkdir($basePath . '/src'); + file_put_contents($basePath . '/src/Foo.php', 'assertFalse($psr1Utf8WithoutBomRule->fix(new RuleViolation( + message: 'File must use UTF-8 without BOM', + file: $basePath . '/src/Foo.php', + line: 1, + className: '', + ))); + $this->assertSame('assertFalse((new Psr1Utf8WithoutBomRule(['src/']))->fix(new RuleViolation( + message: 'File must use UTF-8 without BOM', + file: sys_get_temp_dir() . '/structarmed-missing-file-' . bin2hex(random_bytes(6)) . '.php', + line: 1, + className: '', + ))); + } + public function testReturnsAllViolationsWhenMultipleFilesViolate(): void { $basePath = $this->makeTempDir(); diff --git a/tests/Rule/File/Psr1ValidUtf8RuleTest.php b/tests/Rule/File/Psr1ValidUtf8RuleTest.php new file mode 100644 index 00000000..80e59a8e --- /dev/null +++ b/tests/Rule/File/Psr1ValidUtf8RuleTest.php @@ -0,0 +1,114 @@ +makeTempDir(); + + try { + mkdir($basePath . '/src'); + file_put_contents($basePath . '/src/Foo.php', "evaluateProjectAll($basePath, Architecture::define()); + + $this->assertCount(1, $violations); + $this->assertStringContainsString('valid UTF-8', $violations[0]->message); + } finally { + unlink($basePath . '/src/Foo.php'); + rmdir($basePath . '/src'); + rmdir($basePath); + } + } + + public function testPassesValidUtf8WithBomAndMissingPaths(): void + { + $basePath = $this->makeTempDir(); + + try { + mkdir($basePath . '/src'); + file_put_contents($basePath . '/src/Foo.php', "\xEF\xBB\xBFassertSame( + [], + (new Psr1ValidUtf8Rule(['src/']))->evaluateProjectAll($basePath, Architecture::define()) + ); + $this->assertSame( + [], + (new Psr1ValidUtf8Rule(['missing/']))->evaluateProjectAll($basePath, Architecture::define()) + ); + } finally { + unlink($basePath . '/src/Foo.php'); + rmdir($basePath . '/src'); + rmdir($basePath); + } + } + + public function testEvaluateProjectReturnsFirstViolation(): void + { + $basePath = $this->makeTempDir(); + + try { + mkdir($basePath . '/src'); + file_put_contents($basePath . '/src/Foo.php', "evaluateProject($basePath, Architecture::define()); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertStringContainsString('Foo.php', $violation->message); + } finally { + unlink($basePath . '/src/Foo.php'); + rmdir($basePath . '/src'); + rmdir($basePath); + } + } + + public function testReturnsAllViolationsWhenMultipleFilesViolate(): void + { + $basePath = $this->makeTempDir(); + + try { + mkdir($basePath . '/src'); + file_put_contents($basePath . '/src/Foo.php', "evaluateProjectAll($basePath, Architecture::define()); + + $this->assertCount(2, $violations); + } finally { + unlink($basePath . '/src/Foo.php'); + unlink($basePath . '/src/Bar.php'); + rmdir($basePath . '/src'); + rmdir($basePath); + } + } + + private function makeTempDir(): string + { + $path = sys_get_temp_dir() . '/structarmed-psr1-' . bin2hex(random_bytes(6)); + mkdir($path); + + return $path; + } +} From 0e3f96229d691ada38ecebc85214e791ae65a5f0 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 27 Jun 2026 07:36:05 +0700 Subject: [PATCH 2/2] update screnshoot --- 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 48f9759b..d439da80 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.1 — Architecture Enforcement + StructArmed 0.14.2 — Architecture Enforcement =============================================== diff --git a/docs/assets/structarmed-showoff.svg b/docs/assets/structarmed-showoff.svg index 22c4198f..0011c093 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.1 — Architecture Enforcement + StructArmed 0.14.2 — Architecture Enforcement ===============================================