diff --git a/.ddev/commands/web/mago b/.ddev/commands/web/mago index 5db35919..52781f34 100755 --- a/.ddev/commands/web/mago +++ b/.ddev/commands/web/mago @@ -2,11 +2,11 @@ set -euo pipefail -## Description: Run Mago (PHP linter and formatter) +## Description: Run Mago (PHP linter, formatter and static analyzer) ## Usage: mago [options] ## Example: ddev mago lint -## Example: ddev mago fmt ## Example: ddev mago fmt --dry-run +## Example: ddev mago analyze cd /var/www/html @@ -16,4 +16,19 @@ if [[ ! -x vendor/bin/mago ]]; then composer install --no-interaction fi +# `analyze` needs the full Magento class graph to resolve framework/module +# classes. Run it against the installed Magento (workspace = magento/), with the +# module source as the target, so it matches what CI analyzes. lint/fmt operate +# on the module source directly and run from the repo root. +if [[ ${1-} == "analyze" ]]; then + shift + target="vendor/openforgeproject/mageforge/src" + # Optional explicit path override (must be relative to magento/). + if [[ $# -gt 0 && ! ${1} =~ ^- ]]; then + target="${1}" + shift + fi + exec vendor/bin/mago --workspace magento --config mago.toml analyze "${target}" "$@" +fi + vendor/bin/mago "$@" diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml deleted file mode 100644 index c64c63f7..00000000 --- a/.github/workflows/phpstan.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: PHPStan - -on: - pull_request: - branches: [main] - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -jobs: - phpstan: - name: PHPStan Analysis - runs-on: ubuntu-latest - - services: - mariadb: - image: mariadb:11.4 - env: - MYSQL_ROOT_PASSWORD: magento - MYSQL_DATABASE: magento - ports: - - 3306:3306 - options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=10s --health-timeout=5s --health-retries=3 - - opensearch: - image: opensearchproject/opensearch:3 - ports: - - 9200:9200 - env: - discovery.type: single-node - DISABLE_SECURITY_PLUGIN: true - OPENSEARCH_JAVA_OPTS: -Xms512m -Xmx512m - options: --health-cmd="curl http://localhost:9200/_cluster/health" --health-interval=10s --health-timeout=5s --health-retries=10 - - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - path: mageforge - - - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 - with: - php-version: "8.4" - extensions: mbstring, intl, gd, xml, soap, zip, bcmath, pdo_mysql, curl, sockets - tools: composer:v2 - - - name: Cache Composer packages - id: composer-cache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: ~/.composer/cache/files - key: ${{ runner.os }}-composer-2.4.8-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer-2.4.8 - - - name: Download Magento - run: | - composer create-project \ - --repository-url=https://mirror.mage-os.org/ \ - magento/project-community-edition \ - magento2 - - - name: Install Magento - working-directory: magento2 - env: - COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} - run: | - composer config minimum-stability stable - composer config prefer-stable true - composer install --no-interaction --no-progress - bin/magento setup:install \ - --base-url=http://localhost \ - --db-host=127.0.0.1 \ - --db-name=magento \ - --db-user=root \ - --db-password=magento \ - --admin-firstname=Admin \ - --admin-lastname=User \ - --admin-email=admin@example.com \ - --admin-user=admin \ - --admin-password=admin12345 \ - --language=en_US \ - --currency=USD \ - --timezone=Europe/Berlin \ - --use-rewrites=1 \ - --backend-frontname=admin \ - --search-engine=opensearch \ - --opensearch-host=localhost \ - --opensearch-port=9200 \ - --opensearch-index-prefix=magento \ - --cleanup-database - - - name: Install MageForge Module and PHPStan - working-directory: magento2 - run: | - # Add local repository - composer config repositories.mageforge-local path ../mageforge - - # Install module - composer require --no-update openforgeproject/mageforge:@dev - - # Allow PHPStan extension installer - composer config --no-plugins allow-plugins.phpstan/extension-installer true - - # Install PHPStan and Magento extension - composer require --dev --no-update bitexpert/phpstan-magento "phpstan/phpstan:^2.0" phpstan/extension-installer - - # Update - composer update --with-dependencies - - # Enable module - bin/magento setup:upgrade - - - name: Run PHPStan - working-directory: magento2 - run: | - vendor/bin/phpstan analyse -c vendor/openforgeproject/mageforge/phpstan.neon vendor/openforgeproject/mageforge/src diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 00000000..1c17adfb --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,217 @@ +name: Static Analysis + +# Builds a full Magento install ONCE (build-magento) and shares it via an +# artifact, so multiple static-analysis tools (PHPStan, Mago analyze) run +# against the same Magento codebase instead of each rebuilding it. The analysis +# jobs need no live database — they only read code — so they run in parallel +# without service containers. + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-magento: + name: Build Magento (shared) + runs-on: ubuntu-latest + + services: + mariadb: + image: mariadb:11.4 + env: + MYSQL_ROOT_PASSWORD: magento + MYSQL_DATABASE: magento + ports: + - 3306:3306 + options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=10s --health-timeout=5s --health-retries=3 + + opensearch: + image: opensearchproject/opensearch:3 + ports: + - 9200:9200 + env: + discovery.type: single-node + DISABLE_SECURITY_PLUGIN: true + OPENSEARCH_JAVA_OPTS: -Xms512m -Xmx512m + options: --health-cmd="curl http://localhost:9200/_cluster/health" --health-interval=10s --health-timeout=5s --health-retries=10 + + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + path: mageforge + + - name: Setup PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 + with: + php-version: "8.4" + extensions: mbstring, intl, gd, xml, soap, zip, bcmath, pdo_mysql, curl, sockets + tools: composer:v2 + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.composer/cache/files + key: ${{ runner.os }}-composer-2.4.8-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer-2.4.8 + + - name: Download Magento + run: | + composer create-project \ + --repository-url=https://mirror.mage-os.org/ \ + magento/project-community-edition \ + magento2 + + - name: Install Magento + working-directory: magento2 + env: + COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} + run: | + composer config minimum-stability stable + composer config prefer-stable true + composer install --no-interaction --no-progress + bin/magento setup:install \ + --base-url=http://localhost \ + --db-host=127.0.0.1 \ + --db-name=magento \ + --db-user=root \ + --db-password=magento \ + --admin-firstname=Admin \ + --admin-lastname=User \ + --admin-email=admin@example.com \ + --admin-user=admin \ + --admin-password=admin12345 \ + --language=en_US \ + --currency=USD \ + --timezone=Europe/Berlin \ + --use-rewrites=1 \ + --backend-frontname=admin \ + --search-engine=opensearch \ + --opensearch-host=localhost \ + --opensearch-port=9200 \ + --opensearch-index-prefix=magento \ + --cleanup-database + + - name: Install MageForge module and PHPStan tooling + working-directory: magento2 + run: | + # Add the module from the current checkout as a copied (non-symlinked) + # path repository so its source ends up inside the shared artifact. + composer config repositories.mageforge-local '{"type": "path", "url": "../mageforge", "options": {"symlink": false}}' + composer require --no-update openforgeproject/mageforge:@dev + + # Allow the PHPStan extension installer plugin + composer config --no-plugins allow-plugins.phpstan/extension-installer true + + # PHPStan + Magento extension (consumed by the phpstan job) + composer require --dev --no-update bitexpert/phpstan-magento "phpstan/phpstan:^2.0" phpstan/extension-installer + + composer update --with-dependencies + bin/magento setup:upgrade + + # phpstan.neon is export-ignored, so the copied path repository omits + # it; place it next to the analysed source for the phpstan job. + cp ../mageforge/phpstan.neon vendor/openforgeproject/mageforge/phpstan.neon + + - name: Pack Magento install + run: | + # Exclude runtime-only/disposable dirs to keep the artifact small; + # static analysis only needs vendor/, app/, generated/ and app/etc/. + tar czf magento.tar.gz \ + --exclude='magento2/var' \ + --exclude='magento2/pub/static' \ + --exclude='magento2/pub/media' \ + --exclude='magento2/.git' \ + --exclude='magento2/dev/tests' \ + magento2 + + - name: Upload Magento artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: magento-build + path: magento.tar.gz + retention-days: 1 + compression-level: 0 # already gzipped + + phpstan: + name: PHPStan Analysis + runs-on: ubuntu-latest + needs: build-magento + + steps: + - name: Setup PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 + with: + php-version: "8.4" + extensions: mbstring, intl, gd, xml, soap, zip, bcmath, pdo_mysql, curl, sockets + tools: composer:v2 + + - name: Download Magento artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: magento-build + + - name: Unpack Magento install + run: tar xzf magento.tar.gz + + - name: Run PHPStan + working-directory: magento2 + run: | + vendor/bin/phpstan analyse -c vendor/openforgeproject/mageforge/phpstan.neon vendor/openforgeproject/mageforge/src + + mago-analyze: + name: Mago Analyze + runs-on: ubuntu-latest + needs: build-magento + + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + path: mageforge + + - name: Setup PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 + with: + php-version: "8.4" + tools: composer:v2 + + - name: Cache Composer packages + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.composer/cache/files + key: ${{ runner.os }}-composer-mago-${{ hashFiles('mageforge/composer.json') }} + restore-keys: ${{ runner.os }}-composer-mago + + - name: Install module dev dependencies (Mago binary) + working-directory: mageforge + run: composer install --no-interaction --no-progress + + - name: Download Magento artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: magento-build + + - name: Unpack Magento install + run: tar xzf magento.tar.gz + + # Run Mago from inside the built Magento (workspace = magento2), so the + # `includes = ["vendor"]` from mago.toml resolves the full Magento class + # graph. Only the module source is analyzed; phtml templates and the + # Magento-idiomatic `mixed-*` codes are filtered via mago.toml. The Mago + # binary and config come from the separate module checkout (its + # require-dev isn't part of the artifact). + - name: Mago analyze + working-directory: magento2 + run: | + ../mageforge/vendor/bin/mago \ + --config ../mageforge/mago.toml \ + analyze vendor/openforgeproject/mageforge/src \ + --reporting-format=github diff --git a/mago.toml b/mago.toml index 53453352..d4826339 100644 --- a/mago.toml +++ b/mago.toml @@ -15,6 +15,20 @@ enable-short-tags = false inline-empty-constructor-braces = false inline-empty-classlike-braces = false +[analyzer] +# phtml templates rely on variables ($block, $escaper, $secureRenderer, …) that +# Magento's template engine injects into the render scope at runtime. A static +# analyzer cannot know them, so every template would report "undefined variable" +# plus a cascade of mixed-* follow-ups. Exclude templates from `mago analyze` +# only — `mago lint`/`mago fmt` still cover them (they don't type-check scope). +excludes = ["**/*.phtml"] +# Magento framework APIs (ObjectManager, InputInterface::getOption(), collection +# items, …) are pervasively typed as `mixed`, so `mixed-assignment`/`mixed-operand` +# fire throughout idiomatic Magento code without pointing at real bugs. PHPStan +# level 9 (with the Magento extension, see phpstan.neon) is the project's type +# gate for these; `mago analyze` gates on the higher-confidence type errors. +ignore = ["mixed-assignment", "mixed-operand"] + [linter.rules] # Code-size metrics: Magento CLI commands and services are naturally verbose; # refactoring purely to satisfy thresholds is not a goal of this codebase. diff --git a/src/Console/Command/Hyva/CompatibilityCheckCommand.php b/src/Console/Command/Hyva/CompatibilityCheckCommand.php index fa6436f4..07b5c1df 100644 --- a/src/Console/Command/Hyva/CompatibilityCheckCommand.php +++ b/src/Console/Command/Hyva/CompatibilityCheckCommand.php @@ -95,10 +95,10 @@ protected function executeCommand(InputInterface $input, OutputInterface $output { // Check if we're in interactive mode (no options provided) $hasOptions = - $input->getOption(self::OPTION_SHOW_ALL) - || $input->getOption(self::OPTION_THIRD_PARTY_ONLY) - || $input->getOption(self::OPTION_INCLUDE_VENDOR) - || $input->getOption(self::OPTION_DETAILED); + (bool) $input->getOption(self::OPTION_SHOW_ALL) + || (bool) $input->getOption(self::OPTION_THIRD_PARTY_ONLY) + || (bool) $input->getOption(self::OPTION_INCLUDE_VENDOR) + || (bool) $input->getOption(self::OPTION_DETAILED); if (!$hasOptions && $this->isInteractiveTerminal($output)) { return $this->runInteractiveMode($input, $output); diff --git a/src/Console/Command/System/CheckCommand.php b/src/Console/Command/System/CheckCommand.php index 864610ed..f58e9ded 100644 --- a/src/Console/Command/System/CheckCommand.php +++ b/src/Console/Command/System/CheckCommand.php @@ -62,13 +62,12 @@ protected function configure(): void */ protected function executeCommand(InputInterface $input, OutputInterface $output): int { - $phpVersion = phpversion(); + $phpVersion = PHP_VERSION; $nodeVersion = $this->getNodeVersion(); $mysqlVersion = $this->getShortMysqlVersion(); $dbType = $this->getDatabaseType(); $osInfo = $this->getShortOsInfo(); $magentoVersion = $this->productMetadata->getVersion(); - /** @var string $latestLtsNodeVersion */ $latestLtsNodeVersion = $this->escaper->escapeHtml($this->getLatestLtsNodeVersion()); $composerVersion = $this->getComposerVersion(); $npmVersion = $this->getNpmVersion(); @@ -212,8 +211,7 @@ private function getMysqlVersionViaMagento(): ?string { try { $connection = $this->resourceConnection->getConnection(); - $select = $connection->select()->from(null, new \Zend_Db_Expr('VERSION()')); - $version = $connection->fetchOne($select); + $version = $connection->fetchOne('SELECT VERSION()'); return !empty($version) ? $version : null; } catch (\Exception $e) { @@ -661,7 +659,7 @@ private function getImportantPhpExtensions(): array */ private function getPhpMemoryLimit(): string { - return ini_get('memory_limit'); + return (string) ini_get('memory_limit'); } /** diff --git a/src/Console/Command/Theme/BuildCommand.php b/src/Console/Command/Theme/BuildCommand.php index 8fa58249..b97122ed 100644 --- a/src/Console/Command/Theme/BuildCommand.php +++ b/src/Console/Command/Theme/BuildCommand.php @@ -98,10 +98,7 @@ protected function executeCommand(InputInterface $input, OutputInterface $output label: 'Select themes to build', options: static fn(string $value) => empty($value) ? $options - : array_values(array_filter( - $options, - static fn($option) => stripos((string) $option, $value) !== false, - )), + : array_values(array_filter($options, static fn($option) => stripos($option, $value) !== false)), placeholder: 'Type to search theme...', hint: 'Type to search, arrow keys to navigate, Space to toggle, Enter to confirm', required: false, @@ -185,7 +182,7 @@ private function processBuildThemes( } else { // Use the existing spinner with a customized message foreach ($themeCodes as $index => $themeCode) { - $currentTheme = $index + 1; + $currentTheme = (int) $index + 1; // Validate theme and handle suggestions BEFORE showing spinner $validatedTheme = $this->validateAndCorrectTheme($themeCode, $io, $output); diff --git a/src/Console/Command/Theme/CleanCommand.php b/src/Console/Command/Theme/CleanCommand.php index 256d8e5e..293d12a4 100644 --- a/src/Console/Command/Theme/CleanCommand.php +++ b/src/Console/Command/Theme/CleanCommand.php @@ -201,10 +201,7 @@ private function promptForThemes(array $options, array $themes): ?array label: 'Select themes to clean', options: static fn(string $value) => empty($value) ? $options - : array_values(array_filter( - $options, - static fn($option) => stripos((string) $option, $value) !== false, - )), + : array_values(array_filter($options, static fn($option) => stripos($option, $value) !== false)), placeholder: 'Type to search theme...', hint: 'Type to search, arrow keys to navigate, Space to toggle, Enter to confirm', required: false, @@ -245,7 +242,7 @@ private function processThemes(array $themeCodes, bool $dryRun, OutputInterface $failedThemes = []; foreach ($themeCodes as $index => $themeName) { - $currentTheme = $index + 1; + $currentTheme = (int) $index + 1; // Validate and potentially correct theme name $validatedTheme = $this->validateTheme($themeName, $failedThemes, $output); diff --git a/src/Console/Command/Theme/TokensCommand.php b/src/Console/Command/Theme/TokensCommand.php index 53485ea0..a00bdd36 100644 --- a/src/Console/Command/Theme/TokensCommand.php +++ b/src/Console/Command/Theme/TokensCommand.php @@ -113,10 +113,7 @@ private function selectTheme(?string $themeCode): ?string label: 'Select theme to generate tokens for', options: static fn(string $value) => empty($value) ? $options - : array_values(array_filter( - $options, - static fn($option) => stripos((string) $option, $value) !== false, - )), + : array_values(array_filter($options, static fn($option) => stripos($option, $value) !== false)), placeholder: 'Type to search theme...', scroll: 10, hint: 'Type to search, arrow keys to navigate, Enter to confirm', diff --git a/src/Console/Command/Theme/WatchCommand.php b/src/Console/Command/Theme/WatchCommand.php index a40301f4..8a35356a 100644 --- a/src/Console/Command/Theme/WatchCommand.php +++ b/src/Console/Command/Theme/WatchCommand.php @@ -77,10 +77,7 @@ protected function executeCommand(InputInterface $input, OutputInterface $output label: 'Select theme to watch', options: static fn(string $value) => empty($value) ? $options - : array_values(array_filter( - $options, - static fn($option) => stripos((string) $option, $value) !== false, - )), + : array_values(array_filter($options, static fn($option) => stripos($option, $value) !== false)), placeholder: 'Type to search theme...', scroll: 10, hint: 'Type to search, arrow keys to navigate, Enter to confirm', diff --git a/src/Model/TemplateEngine/Decorator/InspectorHints.php b/src/Model/TemplateEngine/Decorator/InspectorHints.php index 19fca200..e6798e25 100644 --- a/src/Model/TemplateEngine/Decorator/InspectorHints.php +++ b/src/Model/TemplateEngine/Decorator/InspectorHints.php @@ -53,7 +53,7 @@ public function __construct( private function resolveMagentoRoot(): string { if (defined('BP')) { - return BP; + return (string) BP; } // vendor/openforgeproject/mageforge/src/Model/TemplateEngine/Decorator/InspectorHints.php @@ -106,14 +106,14 @@ private function isExcludedTemplate(string $templateFile): bool * @param BlockInterface $block * @param string $templateFile * @param array $dictionary - * @phpstan-param array $dictionary + * @phpstan-param array $dictionary * @return string */ public function render(BlockInterface $block, $templateFile, array $dictionary = []): string { // Measure render time $startTime = hrtime(true); - $result = (string) $this->subject->render($block, $templateFile, $dictionary); + $result = $this->subject->render($block, $templateFile, $dictionary); $endTime = hrtime(true); if (!$this->showBlockHints) { @@ -141,8 +141,8 @@ public function render(BlockInterface $block, $templateFile, array $dictionary = $renderMetrics = [ 'renderTimeMs' => round($renderTimeMs, 2), - 'startTime' => $startTime, - 'endTime' => $endTime, + 'startTime' => (int) $startTime, + 'endTime' => (int) $endTime, ]; return $this->injectInspectorAttributes($result, $block, $templateFile, $renderMetrics); diff --git a/src/Model/ThemePath.php b/src/Model/ThemePath.php index bc844150..c358da09 100644 --- a/src/Model/ThemePath.php +++ b/src/Model/ThemePath.php @@ -25,6 +25,7 @@ public function __construct( */ public function getPath(string $themeCode): ?string { + /** @var array $registeredThemes */ $registeredThemes = $this->componentRegistrar->getPaths(ComponentRegistrar::THEME); return $registeredThemes['frontend/' . $themeCode] ?? $registeredThemes['adminhtml/' . $themeCode] ?? null; diff --git a/src/Service/Hyva/CompatibilityChecker.php b/src/Service/Hyva/CompatibilityChecker.php index 55f979f3..f908e1a7 100644 --- a/src/Service/Hyva/CompatibilityChecker.php +++ b/src/Service/Hyva/CompatibilityChecker.php @@ -69,6 +69,7 @@ public function check( bool $thirdPartyOnly = false, bool $excludeVendor = true, ): array { + /** @var array $modules */ $modules = $this->componentRegistrar->getPaths(ComponentRegistrar::MODULE); $results = [ 'modules' => [], diff --git a/src/Service/Hyva/IncompatibilityDetector.php b/src/Service/Hyva/IncompatibilityDetector.php index c3be3133..52471298 100644 --- a/src/Service/Hyva/IncompatibilityDetector.php +++ b/src/Service/Hyva/IncompatibilityDetector.php @@ -128,7 +128,10 @@ public function detectInFile(string $filePath): array $content = $this->fileDriver->fileGetContents($filePath); $lines = explode("\n", $content); - return $this->scanContentForPatterns($lines, self::INCOMPATIBLE_PATTERNS[$fileType]); + /** @var array $patterns */ + $patterns = self::INCOMPATIBLE_PATTERNS[$fileType]; + + return $this->scanContentForPatterns($lines, $patterns); } catch (\Exception $e) { return []; } diff --git a/src/Service/Inspector/Cache/BlockCacheCollector.php b/src/Service/Inspector/Cache/BlockCacheCollector.php index 61c58d75..aabfd0c5 100644 --- a/src/Service/Inspector/Cache/BlockCacheCollector.php +++ b/src/Service/Inspector/Cache/BlockCacheCollector.php @@ -205,6 +205,7 @@ private function isPageCacheable(): bool { try { // Get all blocks from layout + /** @var BlockInterface[] $allBlocks */ $allBlocks = $this->layout->getAllBlocks(); foreach ($allBlocks as $block) { diff --git a/src/Service/SymlinkCleaner.php b/src/Service/SymlinkCleaner.php index 506ab403..18dfbfb0 100644 --- a/src/Service/SymlinkCleaner.php +++ b/src/Service/SymlinkCleaner.php @@ -94,7 +94,7 @@ private function isSymlink(string $path): bool return false; } - return (($stat['mode'] ?? 0) & 0o12_0000) === 0o12_0000; + return ((int) ($stat['mode'] ?? 0) & 0o12_0000) === 0o12_0000; } /**