}>
*/
- public function getDetailedIssues(string $moduleName, array $moduleData): array
+ public function getDetailedIssues(array $moduleData): array
{
$scanResult = $moduleData['scanResult'];
$files = $scanResult['files'];
diff --git a/src/Service/Hyva/IncompatibilityDetector.php b/src/Service/Hyva/IncompatibilityDetector.php
index c3be3133..6c3e6922 100644
--- a/src/Service/Hyva/IncompatibilityDetector.php
+++ b/src/Service/Hyva/IncompatibilityDetector.php
@@ -35,7 +35,12 @@ class IncompatibilityDetector
'severity' => self::SEVERITY_CRITICAL,
],
[
- 'pattern' => '/ko\.observable|ko\.observableArray|ko\.computed/',
+ 'pattern' => '/\bko\.(observable|observableArray|computed|pureComputed)\b/',
+ 'description' => 'Knockout.js usage',
+ 'severity' => self::SEVERITY_CRITICAL,
+ ],
+ [
+ 'pattern' => '/\bko\.(applyBindings|components|bindingHandlers)\b/',
'description' => 'Knockout.js usage',
'severity' => self::SEVERITY_CRITICAL,
],
@@ -78,6 +83,11 @@ class IncompatibilityDetector
'description' => 'data-mage-init JavaScript initialization',
'severity' => self::SEVERITY_CRITICAL,
],
+ [
+ 'pattern' => '/data-bind\b\s*=/',
+ 'description' => 'Knockout.js data-bind attribute',
+ 'severity' => self::SEVERITY_CRITICAL,
+ ],
[
'pattern' => '/x-magento-init/',
'description' => 'x-magento-init JavaScript initialization',
@@ -93,6 +103,11 @@ class IncompatibilityDetector
'description' => 'RequireJS in template',
'severity' => self::SEVERITY_CRITICAL,
],
+ [
+ 'pattern' => '/",
+ 'Knockout.js virtual element binding',
+ 'critical',
+ ],
+ 'ko virtual element no space' => [
+ '',
+ 'Knockout.js virtual element binding',
+ 'critical',
+ ],
+ ];
+ }
+
+ public function testDoesNotFlagCleanHyvaPhtml(): void
+ {
+ $cleanPhtml = <<<'PHTML'
+
+
+
+
+
+
+ PHTML;
+
+ $this->fileMock->method('fileGetContents')->willReturn($cleanPhtml);
+ $issues = $this->detector->detectInFile('product-view.phtml');
+ $this->assertEmpty($issues, 'Clean Hyvä/Alpine.js template must not trigger any issues');
+ }
+
+ // -------------------------------------------------------------------------
+ // HTML template patterns (Knockout component templates)
+ // -------------------------------------------------------------------------
+
+ #[DataProvider('incompatibleHtmlProvider')]
+ public function testDetectsIncompatibleHtmlPattern(
+ string $content,
+ string $expectedDescription,
+ ): void {
+ $this->fileMock->method('fileGetContents')->willReturn($content);
+ $issues = $this->detector->detectInFile('view/frontend/web/template/listing.html');
+ $this->assertIssueFound($issues, $expectedDescription, 'critical');
+ }
+
+ /**
+ * @return array
+ */
+ public static function incompatibleHtmlProvider(): array
+ {
+ return [
+ 'data-bind in html template' => [
+ '
',
+ 'Knockout.js data-bind attribute',
+ ],
+ 'ko virtual element in html' => [
+ 'Hello
',
+ 'Knockout.js virtual element binding',
+ ],
+ 'x-magento-init in html' => [
+ '',
+ 'x-magento-init JavaScript initialization',
+ ],
+ ];
+ }
+
+ // -------------------------------------------------------------------------
+ // Edge cases
+ // -------------------------------------------------------------------------
+
+ public function testReturnsEmptyArrayWhenFileNotExists(): void
+ {
+ $this->fileMock->method('isExists')->willReturn(false);
+ $issues = $this->detector->detectInFile('nonexistent.js');
+ $this->assertEmpty($issues);
+ }
+
+ public function testReturnsEmptyArrayForUnknownExtension(): void
+ {
+ $this->fileMock->method('fileGetContents')->willReturn("define(['jquery'], function() {});");
+ $issues = $this->detector->detectInFile('script.coffee');
+ $this->assertEmpty($issues, 'Unknown file extensions must be ignored');
+ }
+
+ public function testReturnsEmptyArrayOnFileReadError(): void
+ {
+ $this->fileMock->method('fileGetContents')->willThrowException(new \RuntimeException('Read error'));
+ $issues = $this->detector->detectInFile('test.js');
+ $this->assertEmpty($issues, 'File read errors must be handled gracefully');
+ }
+
+ public function testReportsCorrectLineNumbers(): void
+ {
+ $content = "const x = 1;\nconst y = 2;\nko.applyBindings(viewModel);\nconst z = 3;";
+ $this->fileMock->method('fileGetContents')->willReturn($content);
+
+ $issues = $this->detector->detectInFile('test.js');
+
+ $this->assertNotEmpty($issues);
+ $this->assertSame(3, $issues[0]['line'], 'Line number must be 1-based');
+ }
+
+ // -------------------------------------------------------------------------
+ // Helper
+ // -------------------------------------------------------------------------
+
+ /**
+ * @param array> $issues
+ */
+ private function assertIssueFound(array $issues, string $description, string $severity): void
+ {
+ $this->assertNotEmpty(
+ $issues,
+ sprintf('Expected issue "%s" but no issues were detected at all', $description),
+ );
+
+ $found = array_filter(
+ $issues,
+ static fn(array $issue): bool => $issue['description'] === $description,
+ );
+
+ $this->assertNotEmpty(
+ $found,
+ sprintf(
+ 'Expected issue "%s" not found. Detected: %s',
+ $description,
+ implode(', ', array_map('strval', array_column($issues, 'description'))),
+ ),
+ );
+
+ $issue = array_values($found)[0];
+ $this->assertSame(
+ $severity,
+ (string) $issue['severity'],
+ sprintf('Issue "%s" expected severity "%s" but got "%s"', $description, $severity, $issue['severity']),
+ );
+ }
+}