From a23f4f1ed9f44e148a1da89b9cb0efdc996e4ac8 Mon Sep 17 00:00:00 2001 From: NopeCharon <115794254+NopeCharon@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:10:59 +0530 Subject: [PATCH] Add the number of PHPStan errors to the extension list generate.php already clones every extension of repositories.json into ./third-party/, which is what phpstan-third-party.neon analyses. When PHPStan (Composer dependencies) and ../FreshRSS/ are available, run that analysis once with the JSON error format after cloning, and attribute each error to the extension whose directory contains the file (the most specific one, for repositories with several extensions). Errors that are not tied to a file, such as internal errors, are attributed through the path they mention. The result is added to extensions.json as an integer `phpstan_errors` key, to give an automatic hint of the compatibility, maintenance, and code quality of third-party extensions. If PHPStan or FreshRSS are not available (e.g. `make generate` in Docker), or if the analysis fails, the key is omitted and the list is generated as before. FreshRSS keeps only the keys it knows when reading the list, so the new key is ignored by current versions. The generate workflow now checks out FreshRSS next to the extensions and installs the Composer dependencies, like the tests workflow, and also runs when phpstan-third-party.neon changes. Closes #525 --- .github/workflows/generate.yml | 16 +++++++++ README.md | 2 ++ generate.php | 60 ++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 663979db..f97a0693 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -12,6 +12,7 @@ on: - 'repositories.json' # For new third-party extensions # - 'extensions.json' # Must not be included to avoid causing a commit loop - 'generate.php' # To regenerate extensions.json with the latest approach + - 'phpstan-third-party.neon' # To count the PHPStan errors of extensions with the latest rules permissions: contents: write @@ -21,10 +22,25 @@ jobs: runs-on: ubuntu-latest env: FILE: extensions.json + defaults: + run: + working-directory: ./Extensions steps: - name: Checkout source code # zizmor: ignore[artipacked] Credentials are needed later uses: actions/checkout@v7 + with: + path: Extensions + + - name: Checkout FreshRSS source code # Needed by PHPStan + uses: actions/checkout@v7 + with: + repository: FreshRSS/FreshRSS + path: FreshRSS + persist-credentials: false + + - name: Run Composer install # Needed by PHPStan + run: composer install --prefer-dist --no-progress - name: Build JSON file run: php ./generate.php diff --git a/README.md b/README.md index ec8b91a9..43674570 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ make test-all composer run-script phpstan-third-party ``` +When `../FreshRSS/` and the Composer dependencies are available, `./generate.php` also adds the number of PHPStan errors of each extension to `extensions.json`, as `phpstan_errors`. + ## Core extensions *Custom CSS* and *Custom JS* are now a part of [core extensions shipped with FreshRSS](https://github.com/FreshRSS/FreshRSS/tree/edge/lib/core-extensions). diff --git a/generate.php b/generate.php index b1a4e6f6..5e8d2153 100755 --- a/generate.php +++ b/generate.php @@ -9,6 +9,8 @@ $tempFolder = './third-party/'; $extensions = []; +/** @var array $extensionDirectories Absolute path of the directory of each extension, with the same keys as $extensions */ +$extensionDirectories = []; $gitRepositories = []; if (file_exists($tempFolder)) { // TODO: Improve by keeping git copy if possible (e.g. fetch + reset) @@ -85,6 +87,7 @@ } $metadata['description'] = strip_tags(is_string($metadata['description'] ?? null) ? $metadata['description'] : '', allowed_tags: ['a']); + $extensionDirectories[count($extensions)] = realpath(dirname($metadataFile)) ?: ''; $extensions[] = $metadata; } catch (Exception $exception) { continue; @@ -92,6 +95,63 @@ } } +// ------------------------------------------------------------------------------- // +// Count the PHPStan errors of each extension, when PHPStan and FreshRSS are found // +// ------------------------------------------------------------------------------- // +if (is_file('vendor/bin/phpstan') && is_dir('../FreshRSS/')) { + echo PHP_EOL, 'Running PHPStan on the extensions', PHP_EOL; + $phpstanOutput = []; + $phpstanExitCode = 0; + $phpstanCommand = escapeshellarg(PHP_BINARY) . ' vendor/bin/phpstan analyse -c phpstan-third-party.neon --error-format=json --no-progress .'; + exec($phpstanCommand, $phpstanOutput, $phpstanExitCode); + try { + // PHPStan exits with 1 when errors are found, and with another non-zero code when it cannot analyse + if ($phpstanExitCode > 1) { + throw new RuntimeException('PHPStan failed with exit code ' . $phpstanExitCode); + } + $phpstanResult = json_decode(implode("\n", $phpstanOutput), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($phpstanResult) || !is_array($phpstanResult['files'] ?? null) || !is_array($phpstanResult['errors'] ?? null)) { + throw new RuntimeException('Not a PHPStan JSON output!'); + } + + $phpstanErrors = array_fill_keys(array_keys($extensions), 0); + // Attribute each error to the extension with the most specific directory found in the path of the file + $findExtension = static function (string $path) use ($extensionDirectories): ?int { + $found = null; + foreach ($extensionDirectories as $key => $directory) { + if ($directory !== '' && str_contains($path, $directory . '/') && + ($found === null || strlen($directory) > strlen($extensionDirectories[$found]))) { + $found = $key; + } + } + return $found; + }; + foreach ($phpstanResult['files'] as $file => $fileResult) { + $key = $findExtension((string)$file); + if ($key !== null && is_array($fileResult) && is_int($fileResult['errors'] ?? null)) { + $phpstanErrors[$key] += $fileResult['errors']; + } + } + // Errors not tied to a file, such as internal errors, mention the path of the file being analysed + foreach ($phpstanResult['errors'] as $error) { + if (!is_string($error)) { + continue; + } + echo 'PHPStan: ', $error, PHP_EOL; + $key = $findExtension($error); + if ($key !== null) { + $phpstanErrors[$key]++; + } + } + + foreach ($phpstanErrors as $key => $count) { + $extensions[$key]['phpstan_errors'] = $count; + } + } catch (Exception $exception) { + echo 'The PHPStan errors can not be counted: ', $exception->getMessage(), PHP_EOL; + } +} + // --------------- // // Generate output // // --------------- //