diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e15678..687d2a03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -488,7 +488,7 @@ All notable changes to this project will be documented in this file. - Scans Magento modules for Hyvä theme compatibility issues - Detects RequireJS, Knockout.js, jQuery, and UI Components usage - Interactive menu with Laravel Prompts for scan options - - Options: `--show-all`, `--third-party-only`, `--include-vendor`, `--detailed` + - Options: `--show-all`, `--third-party-only`, `--include-core`, `--include-vendor`, `--detailed` - Color-coded output (✓ Compatible, ⚠ Warnings, ✗ Incompatible) - Detailed file-level issues with line numbers - Exit code 1 for critical issues, 0 for success diff --git a/docs/commands_reference.md b/docs/commands_reference.md index 443633f6..889638ab 100644 --- a/docs/commands_reference.md +++ b/docs/commands_reference.md @@ -245,7 +245,8 @@ bin/magento hyva:check - `-a, --show-all` — Show all modules including compatible ones. - `-t, --third-party-only` — Check only third-party modules (exclude Magento\_\*). -- `--include-vendor` — Include Magento core modules in the check. +- `--include-core` — Include Magento core modules in the check. +- `--include-vendor` — Include modules installed in the vendor directory (default: excluded). - `--detailed` — Show detailed compatibility information. **Output:** Displays a table with compatibility status per module. diff --git a/src/Console/Command/Hyva/CompatibilityCheckCommand.php b/src/Console/Command/Hyva/CompatibilityCheckCommand.php index 498738c2..911cfe70 100644 --- a/src/Console/Command/Hyva/CompatibilityCheckCommand.php +++ b/src/Console/Command/Hyva/CompatibilityCheckCommand.php @@ -28,6 +28,7 @@ class CompatibilityCheckCommand extends AbstractCommand { private const OPTION_SHOW_ALL = 'show-all'; private const OPTION_THIRD_PARTY_ONLY = 'third-party-only'; + private const OPTION_INCLUDE_CORE = 'include-core'; private const OPTION_INCLUDE_VENDOR = 'include-vendor'; private const OPTION_DETAILED = 'detailed'; @@ -71,11 +72,17 @@ protected function configure(): void 'Check only third-party modules (exclude Magento_* modules)', ) ->addOption( - self::OPTION_INCLUDE_VENDOR, + self::OPTION_INCLUDE_CORE, null, InputOption::VALUE_NONE, 'Include Magento core modules (default: third-party modules only)', ) + ->addOption( + self::OPTION_INCLUDE_VENDOR, + null, + InputOption::VALUE_NONE, + 'Include modules installed in the vendor directory (default: excluded)', + ) ->addOption( self::OPTION_DETAILED, 'd', @@ -93,10 +100,18 @@ protected function configure(): void */ protected function executeCommand(InputInterface $input, OutputInterface $output): int { + // Validate conflicting options early + if ($input->getOption(self::OPTION_THIRD_PARTY_ONLY) && $input->getOption(self::OPTION_INCLUDE_CORE)) { + $this->io->error('The options --third-party-only and --include-core cannot be used together.'); + + return Cli::RETURN_FAILURE; + } + // Check if we're in interactive mode (no options provided) $hasOptions = (bool) $input->getOption(self::OPTION_SHOW_ALL) || (bool) $input->getOption(self::OPTION_THIRD_PARTY_ONLY) + || (bool) $input->getOption(self::OPTION_INCLUDE_CORE) || (bool) $input->getOption(self::OPTION_INCLUDE_VENDOR) || (bool) $input->getOption(self::OPTION_DETAILED); @@ -118,6 +133,10 @@ private function runInteractiveMode(InputInterface $input, OutputInterface $outp { $this->io->title('Hyvä Theme Compatibility Check'); + if ($this->isVerbose($output)) { + $this->io->info('Running in interactive mode'); + } + // Set environment variables for Laravel Prompts $this->setPromptEnvironment(); @@ -155,8 +174,9 @@ private function runInteractiveMode(InputInterface $input, OutputInterface $outp // Map selected options to flags $showAll = $displayMode === self::DISPLAY_MODE_SHOW_ALL; $incompatibleOnly = $displayMode === self::DISPLAY_MODE_INCOMPATIBLE_ONLY; - $includeVendor = $scope === self::SCOPE_ALL; + $includeCore = $scope === self::SCOPE_ALL; $thirdPartyOnly = false; // Not needed in interactive mode + $includeVendor = false; // Vendor modules excluded by default in interactive mode // Show selected configuration $this->io->newLine(); @@ -168,7 +188,7 @@ private function runInteractiveMode(InputInterface $input, OutputInterface $outp } else { $config[] = 'Show modules with issues'; } - $config[] = $includeVendor ? 'Include Magento core' : 'Third-party modules only'; + $config[] = $includeCore ? 'Include Magento core' : 'Third-party modules only'; if ($detailed) { $config[] = 'Detailed issues'; } @@ -176,7 +196,14 @@ private function runInteractiveMode(InputInterface $input, OutputInterface $outp $this->io->newLine(); // Run scan with selected options - return $this->runScan($showAll, $thirdPartyOnly, $includeVendor, $detailed, $incompatibleOnly); + return $this->runScan( + $showAll, + $thirdPartyOnly, + $includeCore, + $includeVendor, + $detailed, + $incompatibleOnly, + ); } catch (\Throwable $e) { $this->io->error('Interactive mode failed: ' . $e->getMessage()); $this->io->info('Falling back to default scan (third-party modules only)...'); @@ -199,12 +226,24 @@ private function runDirectMode(InputInterface $input, OutputInterface $output): { $showAll = (bool) $input->getOption(self::OPTION_SHOW_ALL); $thirdPartyOnly = (bool) $input->getOption(self::OPTION_THIRD_PARTY_ONLY); + $includeCore = (bool) $input->getOption(self::OPTION_INCLUDE_CORE); $includeVendor = (bool) $input->getOption(self::OPTION_INCLUDE_VENDOR); $detailed = (bool) $input->getOption(self::OPTION_DETAILED); $this->io->title('Hyvä Theme Compatibility Check'); - return $this->runScan($showAll, $thirdPartyOnly, $includeVendor, $detailed, false); + if ($this->isVerbose($output)) { + $this->io->info(sprintf( + 'Direct mode: showAll=%s, thirdPartyOnly=%s, includeCore=%s, includeVendor=%s, detailed=%s', + $showAll ? 'true' : 'false', + $thirdPartyOnly ? 'true' : 'false', + $includeCore ? 'true' : 'false', + $includeVendor ? 'true' : 'false', + $detailed ? 'true' : 'false', + )); + } + + return $this->runScan($showAll, $thirdPartyOnly, $includeCore, $includeVendor, $detailed, false); } /** @@ -212,6 +251,7 @@ private function runDirectMode(InputInterface $input, OutputInterface $output): * * @param bool $showAll * @param bool $thirdPartyOnly + * @param bool $includeCore * @param bool $includeVendor * @param bool $detailed * @param bool $incompatibleOnly @@ -220,19 +260,19 @@ private function runDirectMode(InputInterface $input, OutputInterface $output): private function runScan( bool $showAll, bool $thirdPartyOnly, + bool $includeCore, bool $includeVendor, bool $detailed, bool $incompatibleOnly, ): int { // Determine filter logic: - // - thirdPartyOnly: Only scan non-Magento_* modules (default behavior) - // - includeVendor: Also scan Magento_* core modules - // - excludeVendor: Whether to exclude vendor/ directory (always false for now) - $scanThirdPartyOnly = !$includeVendor; - $excludeVendor = false; + // - thirdPartyOnly: Only scan non-Magento_* modules + // - includeCore: Also scan Magento_* core modules + // - includeVendor: Whether to include modules installed in vendor/ + $scanThirdPartyOnly = $thirdPartyOnly || !$includeCore; // Run the compatibility check - $results = $this->compatibilityChecker->check($this->io, $showAll, $scanThirdPartyOnly, $excludeVendor); + $results = $this->compatibilityChecker->check($this->io, $showAll, $scanThirdPartyOnly, !$includeVendor); // Determine display mode: // showAll = show all modules including compatible ones @@ -244,7 +284,7 @@ private function runScan( $this->displayResults($results, $displayShowAll); // Display detailed issues if requested - if ($detailed && $results['hasIncompatibilities']) { + if ($detailed && $results['hasIssues']) { $this->displayDetailedIssues($results); } @@ -252,7 +292,7 @@ private function runScan( $this->displaySummary($results['summary']); // Display recommendations if there are issues - if ($results['hasIncompatibilities']) { + if ($results['hasIssues']) { $this->displayRecommendations(); } diff --git a/src/Service/Hyva/CompatibilityChecker.php b/src/Service/Hyva/CompatibilityChecker.php index c06f3364..9590c0a6 100644 --- a/src/Service/Hyva/CompatibilityChecker.php +++ b/src/Service/Hyva/CompatibilityChecker.php @@ -34,7 +34,7 @@ * @phpstan-type CheckResults array{ * modules: array, * summary: CheckSummary, - * hasIncompatibilities: bool + * hasIssues: bool * } */ class CompatibilityChecker @@ -57,7 +57,7 @@ public function __construct( * @param bool $thirdPartyOnly Whether to scan only third-party modules (excludes Magento_* modules) * @param bool $excludeVendor Whether to exclude modules from the vendor/ directory * @return array Results with structure: ['modules' => [], 'summary' => [], - * 'hasIncompatibilities' => bool] + * 'hasIssues' => bool] * @phpstan-return CheckResults */ public function check( @@ -78,7 +78,7 @@ public function check( 'criticalIssues' => 0, 'warningIssues' => 0, ], - 'hasIncompatibilities' => false, + 'hasIssues' => false, ]; $io->text(sprintf('Scanning %d modules for Hyvä compatibility...', count($modules))); @@ -118,12 +118,12 @@ public function check( $results['summary']['compatible']++; } else { $results['summary']['incompatible']++; - $results['hasIncompatibilities'] = true; + $results['hasIssues'] = true; } // Warnings alone still trigger the detail/recommendation display if ($hasWarnings) { - $results['hasIncompatibilities'] = true; + $results['hasIssues'] = true; } if ($moduleInfo['isHyvaAware']) { diff --git a/src/Service/Hyva/ModuleScanner.php b/src/Service/Hyva/ModuleScanner.php index 80cc771d..7e0af4a7 100644 --- a/src/Service/Hyva/ModuleScanner.php +++ b/src/Service/Hyva/ModuleScanner.php @@ -174,13 +174,33 @@ public function getModuleInfo(string $modulePath): array return [ 'name' => is_string($composerData['name'] ?? null) ? $composerData['name'] : 'Unknown', 'version' => is_string($composerData['version'] ?? null) ? $composerData['version'] : 'Unknown', - 'isHyvaAware' => $this->isHyvaCompatibilityPackage($composerData), + 'isHyvaAware' => $this->isHyvaAware($modulePath, $composerData), ]; } catch (\Throwable $e) { return ['name' => 'Unknown', 'version' => 'Unknown', 'isHyvaAware' => false]; } } + /** + * Determine whether a module is Hyvä-aware. + * + * A module is considered Hyvä-aware when it either declares a Hyvä dependency, + * is a Hyvä compatibility package, or ships a hyva-themes.json config. + * + * @param string $modulePath + * @param array $composerData + * @phpstan-param array $composerData + * @return bool + */ + private function isHyvaAware(string $modulePath, array $composerData): bool + { + if ($this->isHyvaCompatibilityPackage($composerData)) { + return true; + } + + return $this->fileDriver->isExists($modulePath . '/hyva-themes.json'); + } + /** * Get basename without using basename(). * diff --git a/tests/Unit/Console/Command/Hyva/CompatibilityCheckCommandTest.php b/tests/Unit/Console/Command/Hyva/CompatibilityCheckCommandTest.php index 164c7cc8..7a5fdee2 100644 --- a/tests/Unit/Console/Command/Hyva/CompatibilityCheckCommandTest.php +++ b/tests/Unit/Console/Command/Hyva/CompatibilityCheckCommandTest.php @@ -44,7 +44,7 @@ private function makeResults(array $overrides = []): array 'criticalIssues' => 0, 'warningIssues' => 0, ], - 'hasIncompatibilities' => false, + 'hasIssues' => false, ], $overrides); } @@ -77,7 +77,7 @@ public function testReturnsFailureWhenCriticalIssuesFound(): void 'criticalIssues' => 2, 'warningIssues' => 1, ], - 'hasIncompatibilities' => true, + 'hasIssues' => true, ]); $this->compatibilityChecker->method('check')->willReturn($results); $this->compatibilityChecker->method('formatResultsForDisplay') @@ -113,7 +113,7 @@ public function testReturnsSuccessWithWarningsOnly(): void 'criticalIssues' => 0, 'warningIssues' => 2, ], - 'hasIncompatibilities' => true, + 'hasIssues' => true, ]); $this->compatibilityChecker->method('check')->willReturn($results); $this->compatibilityChecker->method('formatResultsForDisplay') @@ -140,7 +140,7 @@ public function testDisplaySummaryRendersEachDistinctFigure(): void 'criticalIssues' => 2, 'warningIssues' => 5, ], - 'hasIncompatibilities' => true, + 'hasIssues' => true, ]); $this->compatibilityChecker->method('check')->willReturn($results); $this->compatibilityChecker->method('formatResultsForDisplay')->willReturn([]); @@ -175,7 +175,7 @@ public function testExactlyZeroCriticalIssuesWithWarningsShowsWarningMessageNotC 'criticalIssues' => 0, 'warningIssues' => 3, ], - 'hasIncompatibilities' => true, + 'hasIssues' => true, ]); $this->compatibilityChecker->method('check')->willReturn($results); $this->compatibilityChecker->method('formatResultsForDisplay')->willReturn([]); @@ -202,7 +202,7 @@ public function testOneCriticalIssueShowsCriticalMessageWithExactCounts(): void 'criticalIssues' => 1, 'warningIssues' => 0, ], - 'hasIncompatibilities' => true, + 'hasIssues' => true, ]); $this->compatibilityChecker->method('check')->willReturn($results); $this->compatibilityChecker->method('formatResultsForDisplay')->willReturn([]); @@ -242,7 +242,7 @@ public function testWithoutShowAllOptionFormatResultsForDisplayReceivesFalse(): public function testDetailedFlagWithoutIncompatibilitiesSkipsDetailedIssues(): void { - $this->compatibilityChecker->method('check')->willReturn($this->makeResults(['hasIncompatibilities' => false])); + $this->compatibilityChecker->method('check')->willReturn($this->makeResults(['hasIssues' => false])); $this->compatibilityChecker->method('formatResultsForDisplay')->willReturn([]); $this->compatibilityChecker->expects($this->never())->method('getDetailedIssues'); @@ -263,7 +263,7 @@ public function testIncompatibilitiesWithoutDetailedFlagSkipsDetailedIssues(): v 'criticalIssues' => 1, 'warningIssues' => 0, ], - 'hasIncompatibilities' => true, + 'hasIssues' => true, ]); $this->compatibilityChecker->method('check')->willReturn($results); $this->compatibilityChecker->method('formatResultsForDisplay')->willReturn([]); @@ -301,7 +301,7 @@ public function testDetailedOptionDisplaysFileLevelIssues(): void 'criticalIssues' => 1, 'warningIssues' => 0, ], - 'hasIncompatibilities' => true, + 'hasIssues' => true, ]); $this->compatibilityChecker->method('check')->willReturn($results); $this->compatibilityChecker->method('formatResultsForDisplay') @@ -350,7 +350,7 @@ public function testDetailedIssuesIncludesCompatibleModulesThatHaveWarnings(): v 'criticalIssues' => 0, 'warningIssues' => 1, ], - 'hasIncompatibilities' => true, + 'hasIssues' => true, ]); $this->compatibilityChecker->method('check')->willReturn($results); $this->compatibilityChecker->method('formatResultsForDisplay')->willReturn([]); @@ -365,11 +365,35 @@ public function testDetailedIssuesIncludesCompatibleModulesThatHaveWarnings(): v $this->assertStringContainsString('Vendor_Warned', $tester->getDisplay()); } + public function testDefaultExcludesVendorModules(): void + { + $this->compatibilityChecker->expects($this->once()) + ->method('check') + ->with($this->anything(), false, true, true) + ->willReturn($this->makeResults()); + $this->compatibilityChecker->method('formatResultsForDisplay')->willReturn([]); + + $tester = new CommandTester($this->command); + $tester->execute([]); + } + + public function testIncludeCoreOptionIsPassedToChecker(): void + { + $this->compatibilityChecker->expects($this->once()) + ->method('check') + ->with($this->anything(), false, false, true) + ->willReturn($this->makeResults()); + $this->compatibilityChecker->method('formatResultsForDisplay')->willReturn([]); + + $tester = new CommandTester($this->command); + $tester->execute(['--include-core' => true]); + } + public function testIncludeVendorOptionIsPassedToChecker(): void { $this->compatibilityChecker->expects($this->once()) ->method('check') - ->with($this->anything(), false, false, false) + ->with($this->anything(), false, true, false) ->willReturn($this->makeResults()); $this->compatibilityChecker->method('formatResultsForDisplay')->willReturn([]); @@ -377,6 +401,23 @@ public function testIncludeVendorOptionIsPassedToChecker(): void $tester->execute(['--include-vendor' => true]); } + public function testConflictingThirdPartyOnlyAndIncludeCoreOptionsReturnError(): void + { + $this->compatibilityChecker->expects($this->never())->method('check'); + + $tester = new CommandTester($this->command); + $exitCode = $tester->execute([ + '--third-party-only' => true, + '--include-core' => true, + ]); + + $this->assertSame(Cli::RETURN_FAILURE, $exitCode); + $this->assertStringContainsString( + 'cannot be used together', + $tester->getDisplay(), + ); + } + public function testCommandNameAndAliases(): void { $this->assertSame('mageforge:hyva:compatibility:check', $this->command->getName()); diff --git a/tests/Unit/Service/Hyva/CompatibilityCheckerTest.php b/tests/Unit/Service/Hyva/CompatibilityCheckerTest.php index 0a9f50af..15053dfe 100644 --- a/tests/Unit/Service/Hyva/CompatibilityCheckerTest.php +++ b/tests/Unit/Service/Hyva/CompatibilityCheckerTest.php @@ -80,7 +80,7 @@ public function testAggregatesSummaryAcrossModules(): void ], $results['summary'], ); - $this->assertTrue($results['hasIncompatibilities']); + $this->assertTrue($results['hasIssues']); $this->assertTrue($results['modules']['Vendor_Clean']['compatible']); $this->assertFalse($results['modules']['Vendor_Broken']['compatible']); } @@ -93,7 +93,7 @@ public function testCriticalIssuesAloneMarkResultsAsIncompatible(): void $results = $this->checker->check($this->io); - $this->assertTrue($results['hasIncompatibilities']); + $this->assertTrue($results['hasIssues']); $this->assertFalse($results['modules']['Vendor_Broken']['hasWarnings']); } @@ -105,7 +105,7 @@ public function testFullyCompatibleModulesProduceNoIncompatibilities(): void $results = $this->checker->check($this->io); - $this->assertFalse($results['hasIncompatibilities']); + $this->assertFalse($results['hasIssues']); $this->assertSame(0, $results['summary']['incompatible']); } @@ -117,7 +117,7 @@ public function testWarningsAloneMarkResultsAsIncompatible(): void $results = $this->checker->check($this->io); - $this->assertTrue($results['hasIncompatibilities']); + $this->assertTrue($results['hasIssues']); $this->assertTrue($results['modules']['Vendor_Warned']['compatible']); $this->assertTrue($results['modules']['Vendor_Warned']['hasWarnings']); $this->assertSame(2, $results['summary']['warningIssues']); @@ -201,7 +201,7 @@ public function testDisplaysOnlyProblematicModulesByDefault(): void 'Vendor_Clean' => $this->moduleEntry(compatible: true, hasWarnings: false, critical: 0, total: 0), 'Vendor_Broken' => $this->moduleEntry(compatible: false, hasWarnings: false, critical: 2, total: 2), 'Vendor_Warned' => $this->moduleEntry(compatible: true, hasWarnings: true, critical: 0, total: 1), - ], hasIncompatibilities: true); + ], hasIssues: true); $tableData = $this->checker->formatResultsForDisplay($results); @@ -215,7 +215,7 @@ public function testDisplaysAllModulesWhenRequested(): void $results = $this->checkResults([ 'Vendor_Clean' => $this->moduleEntry(compatible: true, hasWarnings: false, critical: 0, total: 0), 'Vendor_Broken' => $this->moduleEntry(compatible: false, hasWarnings: false, critical: 2, total: 2), - ], hasIncompatibilities: true); + ], hasIssues: true); $tableData = $this->checker->formatResultsForDisplay($results, true); @@ -236,7 +236,7 @@ public function testFormatsMixedIssuesAndHyvaAwareStatus(): void total: 3, hyvaAware: true, ), - ], hasIncompatibilities: true); + ], hasIssues: true); $tableData = $this->checker->formatResultsForDisplay($results); @@ -254,7 +254,7 @@ public function testHyvaAwareCompatibleModuleGetsDedicatedStatus(): void total: 0, hyvaAware: true, ), - ], hasIncompatibilities: false); + ], hasIssues: false); $tableData = $this->checker->formatResultsForDisplay($results, true); @@ -333,7 +333,7 @@ private function givenModuleInfo(bool $hyvaAware): void * @param array $modules * @return CheckResults */ - private function checkResults(array $modules, bool $hasIncompatibilities): array + private function checkResults(array $modules, bool $hasIssues): array { return [ 'modules' => $modules, @@ -345,7 +345,7 @@ private function checkResults(array $modules, bool $hasIncompatibilities): array 'criticalIssues' => 0, 'warningIssues' => 0, ], - 'hasIncompatibilities' => $hasIncompatibilities, + 'hasIssues' => $hasIssues, ]; } diff --git a/tests/Unit/Service/Hyva/ModuleScannerTest.php b/tests/Unit/Service/Hyva/ModuleScannerTest.php index c891e0cd..816d9bdc 100644 --- a/tests/Unit/Service/Hyva/ModuleScannerTest.php +++ b/tests/Unit/Service/Hyva/ModuleScannerTest.php @@ -226,7 +226,9 @@ private function givenDirectories(array $directories): void */ private function givenComposerJson(array $composerData): void { - $this->fileDriver->method('isExists')->with('/module/composer.json')->willReturn(true); + $this->fileDriver->method('isExists')->willReturnCallback( + static fn(string $path): bool => $path === '/module/composer.json', + ); $this->fileDriver->method('fileGetContents')->willReturn(json_encode($composerData)); } @@ -239,4 +241,27 @@ public function testNonHyvaRequirementsAreNotHyvaAware(): void $this->assertFalse($this->scanner->getModuleInfo('/module')['isHyvaAware']); } + + public function testHyvaThemesJsonMakesModuleHyvaAware(): void + { + $this->fileDriver->method('isExists')->willReturnMap([ + ['/module/composer.json', true], + ['/module/hyva-themes.json', true], + ]); + $this->fileDriver->method('fileGetContents')->with('/module/composer.json')->willReturn( + json_encode(['name' => 'vendor/module']), + ); + + $this->assertTrue($this->scanner->getModuleInfo('/module')['isHyvaAware']); + } + + public function testComposerHyvaDependencyTakesPrecedenceOverMissingHyvaThemesJson(): void + { + $this->givenComposerJson([ + 'name' => 'vendor/module', + 'require' => ['hyva-themes/magento2-default-theme' => '*'], + ]); + + $this->assertTrue($this->scanner->getModuleInfo('/module')['isHyvaAware']); + } }