From b4b24949fa0956f0331107798f7a28678f33f090 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 11 Jul 2026 15:01:42 +0700 Subject: [PATCH 1/5] Fix XML declarations misidentified as PHP short tags --- src/Analyser/FileAnalysisProvider.php | 6 +-- src/Rule/Rules/File/Psr1PhpTagsRule.php | 7 ++-- src/Util/InlineHtmlOpeningTagMatcher.php | 41 +++++++++++++++++++++ tests/Analyser/FileAnalysisProviderTest.php | 5 +++ tests/Rule/File/Psr1PhpTagsRuleTest.php | 38 +++++++++++++++++++ 5 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 src/Util/InlineHtmlOpeningTagMatcher.php diff --git a/src/Analyser/FileAnalysisProvider.php b/src/Analyser/FileAnalysisProvider.php index d249c13..1173c4c 100644 --- a/src/Analyser/FileAnalysisProvider.php +++ b/src/Analyser/FileAnalysisProvider.php @@ -5,6 +5,7 @@ namespace Boundwize\StructArmed\Analyser; use Boundwize\StructArmed\Util\Path; +use Boundwize\StructArmed\Util\InlineHtmlOpeningTagMatcher; use PhpParser\Error; use PhpParser\Node; use PhpParser\Node\Stmt; @@ -33,7 +34,6 @@ use function token_get_all; use function trim; -use const PREG_OFFSET_CAPTURE; use const T_INLINE_HTML; use const T_OPEN_TAG; use const T_OPEN_TAG_WITH_ECHO; @@ -221,12 +221,12 @@ private function invalidPhpTagLineForToken(int $id, string $text, int $tokenLine if ( $id !== T_INLINE_HTML - || preg_match('/<\?(?!php(?:\s|$)|=)/', $text, $matches, PREG_OFFSET_CAPTURE) !== 1 + || ($tagOffset = InlineHtmlOpeningTagMatcher::invalidInlineHtmlTagOffset($text)) === null ) { return null; } - return $tokenLine + substr_count(substr($text, 0, $matches[0][1]), "\n"); + return $tokenLine + substr_count(substr($text, 0, $tagOffset), "\n"); } private function contents(string $file): string diff --git a/src/Rule/Rules/File/Psr1PhpTagsRule.php b/src/Rule/Rules/File/Psr1PhpTagsRule.php index 8f2e379..be1919e 100644 --- a/src/Rule/Rules/File/Psr1PhpTagsRule.php +++ b/src/Rule/Rules/File/Psr1PhpTagsRule.php @@ -9,6 +9,7 @@ use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface; use Boundwize\StructArmed\Rule\FixableInterface; use Boundwize\StructArmed\Rule\RuleViolation; +use Boundwize\StructArmed\Util\InlineHtmlOpeningTagMatcher; use function file_get_contents; use function file_put_contents; @@ -21,7 +22,6 @@ use function substr_count; use function token_get_all; -use const PREG_OFFSET_CAPTURE; use const T_INLINE_HTML; use const T_OPEN_TAG; @@ -150,9 +150,8 @@ private function replacementForInvalidTag(int $id, string $text, int $tokenLine, $searchOffset = 0; - while (preg_match('/<\?(?!php(?:\s|$)|=)/', $text, $matches, PREG_OFFSET_CAPTURE, $searchOffset) === 1) { - $tagOffset = $matches[0][1]; - $tagLine = $tokenLine + substr_count(substr($text, 0, $tagOffset), "\n"); + while (($tagOffset = InlineHtmlOpeningTagMatcher::invalidInlineHtmlTagOffset($text, $searchOffset)) !== null) { + $tagLine = $tokenLine + substr_count(substr($text, 0, $tagOffset), "\n"); if ($tagLine === $targetLine) { return [ diff --git a/src/Util/InlineHtmlOpeningTagMatcher.php b/src/Util/InlineHtmlOpeningTagMatcher.php new file mode 100644 index 0000000..53b140c --- /dev/null +++ b/src/Util/InlineHtmlOpeningTagMatcher.php @@ -0,0 +1,41 @@ + */ + private const ALLOWED_TAGS = [ + 'php', + 'xml', + 'xml-stylesheet', + ]; + + public static function invalidInlineHtmlTagOffset(string $text, int $offset = 0): ?int + { + $allowedTags = implode('|', array_map( + static fn (string $target): string => preg_quote($target, '/'), + self::ALLOWED_TAGS, + )); + $pattern = sprintf( + '/<\?(?!(?:%s)(?:\s|$|\?>)|=)/', + $allowedTags, + ); + + if (preg_match($pattern, $text, $matches, PREG_OFFSET_CAPTURE, $offset) !== 1) { + return null; + } + + return $matches[0][1]; + } +} diff --git a/tests/Analyser/FileAnalysisProviderTest.php b/tests/Analyser/FileAnalysisProviderTest.php index db89585..1870a9c 100644 --- a/tests/Analyser/FileAnalysisProviderTest.php +++ b/tests/Analyser/FileAnalysisProviderTest.php @@ -6,6 +6,7 @@ use Boundwize\StructArmed\Analyser\FileAnalysis; use Boundwize\StructArmed\Analyser\FileAnalysisProvider; +use Boundwize\StructArmed\Util\InlineHtmlOpeningTagMatcher; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -14,6 +15,7 @@ #[CoversClass(FileAnalysis::class)] #[CoversClass(FileAnalysisProvider::class)] +#[CoversClass(InlineHtmlOpeningTagMatcher::class)] final class FileAnalysisProviderTest extends TestCase { public function testAnalysesPsr1FactsAndCachesThemByFile(): void @@ -94,6 +96,9 @@ public static function lightweightAnalysisProvider(): iterable yield 'uppercase tag' => [' [' [' ['', false, true, null]; + yield 'XML stylesheet' => ['', false, true, null]; + yield 'arbitrary XML processing instruction' => ['', false, true, 1]; yield 'plain text' => ['plain text', false, true, null]; } diff --git a/tests/Rule/File/Psr1PhpTagsRuleTest.php b/tests/Rule/File/Psr1PhpTagsRuleTest.php index 76382c0..85a1ffe 100644 --- a/tests/Rule/File/Psr1PhpTagsRuleTest.php +++ b/tests/Rule/File/Psr1PhpTagsRuleTest.php @@ -11,6 +11,7 @@ use Boundwize\StructArmed\Rule\Rules\File\PhpFileFinder; use Boundwize\StructArmed\Rule\Rules\File\Psr1PhpTagsRule; use Boundwize\StructArmed\Rule\RuleViolation; +use Boundwize\StructArmed\Util\InlineHtmlOpeningTagMatcher; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -31,6 +32,7 @@ #[CoversClass(PhpFileFinder::class)] #[CoversClass(Psr1PhpTagsRule::class)] +#[CoversClass(InlineHtmlOpeningTagMatcher::class)] final class Psr1PhpTagsRuleTest extends TestCase { public function testViolatesShortOpenTag(): void @@ -69,6 +71,42 @@ public function testPassesLongAndEchoTags(): void } } + public function testPassesXmlProcessingInstructionsInInlineHtml(): void + { + $basePath = $this->makeTempDir(); + + try { + mkdir($basePath . '/src'); + $contents = <<<'PHP' + + + + + PHP; + file_put_contents($basePath . '/src/sitemap.php', $contents); + + $psr1PhpTagsRule = new Psr1PhpTagsRule(['src/']); + $violations = $psr1PhpTagsRule->evaluateProjectAll($basePath, Architecture::define()); + + $this->assertSame([], $violations); + $this->assertFalse($psr1PhpTagsRule->fix(new RuleViolation( + message: 'File must use only valid PHP tags', + file: $basePath . '/src/sitemap.php', + line: 6, + className: '', + ))); + $this->assertSame($contents, file_get_contents($basePath . '/src/sitemap.php')); + } finally { + unlink($basePath . '/src/sitemap.php'); + rmdir($basePath . '/src'); + rmdir($basePath); + } + } + public function testIgnoresPhpTagsInsideStrings(): void { $basePath = $this->makeTempDir(); From ee034f4cff897f156f7f88bc20c41d28a6f4a19e Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 11 Jul 2026 15:07:06 +0700 Subject: [PATCH 2/5] add more tests --- tests/Rule/File/Psr1PhpTagsRuleTest.php | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/Rule/File/Psr1PhpTagsRuleTest.php b/tests/Rule/File/Psr1PhpTagsRuleTest.php index 85a1ffe..611729d 100644 --- a/tests/Rule/File/Psr1PhpTagsRuleTest.php +++ b/tests/Rule/File/Psr1PhpTagsRuleTest.php @@ -53,6 +53,44 @@ public function testViolatesShortOpenTag(): void } } + public function testViolatesAssignmentUsingShortOpenTag(): void + { + $basePath = $this->makeTempDir(); + + try { + mkdir($basePath . '/src'); + file_put_contents($basePath . '/src/Foo.php', ''); + + $violations = (new Psr1PhpTagsRule(['src/']))->evaluateProjectAll($basePath, Architecture::define()); + + $this->assertCount(1, $violations); + $this->assertSame(1, $violations[0]->line); + } finally { + unlink($basePath . '/src/Foo.php'); + rmdir($basePath . '/src'); + rmdir($basePath); + } + } + + public function testViolatesCommentPrefixedShortOpenTag(): void + { + $basePath = $this->makeTempDir(); + + try { + mkdir($basePath . '/src'); + file_put_contents($basePath . '/src/Foo.php', ""); + + $violations = (new Psr1PhpTagsRule(['src/']))->evaluateProjectAll($basePath, Architecture::define()); + + $this->assertCount(1, $violations); + $this->assertSame(1, $violations[0]->line); + } finally { + unlink($basePath . '/src/Foo.php'); + rmdir($basePath . '/src'); + rmdir($basePath); + } + } + public function testPassesLongAndEchoTags(): void { $basePath = $this->makeTempDir(); From c9e6f1b6be1db99c23d835d37a511f706babf865 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 11 Jul 2026 15:08:55 +0700 Subject: [PATCH 3/5] cs fix --- src/Analyser/FileAnalysisProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyser/FileAnalysisProvider.php b/src/Analyser/FileAnalysisProvider.php index 1173c4c..2e18bbb 100644 --- a/src/Analyser/FileAnalysisProvider.php +++ b/src/Analyser/FileAnalysisProvider.php @@ -4,8 +4,8 @@ namespace Boundwize\StructArmed\Analyser; -use Boundwize\StructArmed\Util\Path; use Boundwize\StructArmed\Util\InlineHtmlOpeningTagMatcher; +use Boundwize\StructArmed\Util\Path; use PhpParser\Error; use PhpParser\Node; use PhpParser\Node\Stmt; From 4e746c71cd3d7dacbe085d92c8e5723934f907e8 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 11 Jul 2026 15:13:00 +0700 Subject: [PATCH 4/5] php tag --- src/Util/InlineHtmlOpeningTagMatcher.php | 1 - tests/Analyser/FileAnalysisProviderTest.php | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Util/InlineHtmlOpeningTagMatcher.php b/src/Util/InlineHtmlOpeningTagMatcher.php index 53b140c..37e9af0 100644 --- a/src/Util/InlineHtmlOpeningTagMatcher.php +++ b/src/Util/InlineHtmlOpeningTagMatcher.php @@ -16,7 +16,6 @@ final class InlineHtmlOpeningTagMatcher { /** @var list */ private const ALLOWED_TAGS = [ - 'php', 'xml', 'xml-stylesheet', ]; diff --git a/tests/Analyser/FileAnalysisProviderTest.php b/tests/Analyser/FileAnalysisProviderTest.php index 1870a9c..9c89338 100644 --- a/tests/Analyser/FileAnalysisProviderTest.php +++ b/tests/Analyser/FileAnalysisProviderTest.php @@ -94,6 +94,7 @@ public static function lightweightAnalysisProvider(): iterable yield 'invalid UTF-8' => [" [' [' ['', false, true, 1]; yield 'valid tag' => [' [' ['', false, true, null]; From 6d5eb5df7410ba383a20d9c87ef26c9625566a2b Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 11 Jul 2026 15:16:00 +0700 Subject: [PATCH 5/5] treat as valid inline html --- src/Util/InlineHtmlOpeningTagMatcher.php | 1 + tests/Analyser/FileAnalysisProviderTest.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Util/InlineHtmlOpeningTagMatcher.php b/src/Util/InlineHtmlOpeningTagMatcher.php index 37e9af0..53b140c 100644 --- a/src/Util/InlineHtmlOpeningTagMatcher.php +++ b/src/Util/InlineHtmlOpeningTagMatcher.php @@ -16,6 +16,7 @@ final class InlineHtmlOpeningTagMatcher { /** @var list */ private const ALLOWED_TAGS = [ + 'php', 'xml', 'xml-stylesheet', ]; diff --git a/tests/Analyser/FileAnalysisProviderTest.php b/tests/Analyser/FileAnalysisProviderTest.php index 9c89338..b69d0f4 100644 --- a/tests/Analyser/FileAnalysisProviderTest.php +++ b/tests/Analyser/FileAnalysisProviderTest.php @@ -94,7 +94,7 @@ public static function lightweightAnalysisProvider(): iterable yield 'invalid UTF-8' => [" [' [' ['', false, true, 1]; + yield 'PHP-like inline HTML' => ['', false, true, null]; yield 'valid tag' => [' [' ['', false, true, null];