├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── composer.json ├── phpcs.xml.dist ├── phpstan.neon.dist ├── phpunit.xml.dist ├── src └── Report │ └── Gitlab.php └── tests ├── Report └── GitlabTest.php └── _files ├── Mixed.json ├── Mixed.php ├── Multiple.json ├── Multiple.php ├── SameLine.json ├── SameLine.php ├── Single.json └── Single.php /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | 13 | - package-ecosystem: "composer" 14 | directory: "/" 15 | schedule: 16 | interval: "monthly" 17 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main workflow 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | phpunit: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | php-versions: ['7.4', '8.4'] 11 | dependencies: 12 | - lowest 13 | - highest 14 | 15 | name: phpunit (${{ matrix.php-versions }}-${{ matrix.dependencies }}) 16 | 17 | steps: 18 | - uses: actions/checkout@v4.2.2 19 | 20 | - uses: shivammathur/setup-php@2.31.1 21 | with: 22 | php-version: ${{ matrix.php-versions }} 23 | coverage: none 24 | if: matrix.php-versions != '8.4' || matrix.dependencies != 'highest' 25 | 26 | - uses: shivammathur/setup-php@2.31.1 27 | with: 28 | php-version: ${{ matrix.php-versions }} 29 | coverage: pcov 30 | if: matrix.php-versions == '8.4' && matrix.dependencies == 'highest' 31 | 32 | - name: Setup problem matcher for PHPUnit 33 | run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 34 | 35 | - name: Install dependencies 36 | if: matrix.dependencies == 'lowest' 37 | run: composer update --prefer-lowest 38 | 39 | - name: Install dependencies 40 | if: matrix.dependencies == 'highest' 41 | run: composer install 42 | 43 | - name: Run PHPUnit 44 | run: vendor/bin/phpunit 45 | if: matrix.php-versions != '8.4' || matrix.dependencies != 'highest' 46 | 47 | - name: Run PHPUnit with coverage 48 | run: vendor/bin/phpunit --coverage-clover=coverage.xml 49 | if: matrix.php-versions == '8.4' && matrix.dependencies == 'highest' 50 | 51 | - name: Upload coverage to Codecov 52 | uses: codecov/codecov-action@v5.1.2 53 | with: 54 | token: ${{ secrets.CODECOV_TOKEN }} 55 | if: matrix.php-versions == '8.4' && matrix.dependencies == 'highest' 56 | 57 | phpcs: 58 | runs-on: ubuntu-latest 59 | 60 | steps: 61 | - uses: actions/checkout@v4.2.2 62 | - uses: shivammathur/setup-php@2.31.1 63 | with: 64 | php-version: 7.4 65 | coverage: none 66 | tools: cs2pr 67 | 68 | - name: Install dependencies 69 | run: composer install 70 | 71 | - name: Run PHP Codesniffer 72 | run: vendor/bin/phpcs --report=checkstyle -q | cs2pr --graceful-warnings 73 | 74 | phpstan: 75 | runs-on: ubuntu-latest 76 | 77 | steps: 78 | - uses: actions/checkout@v4.2.2 79 | - uses: shivammathur/setup-php@2.31.1 80 | with: 81 | php-version: 7.4 82 | coverage: none 83 | 84 | - name: Install dependencies 85 | run: composer install 86 | 87 | - name: Run PHPStan 88 | run: vendor/bin/phpstan 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .phpcs-cache 2 | composer.lock 3 | vendor/ 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Michel Hunziker . 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in 13 | the documentation and/or other materials provided with the 14 | distribution. 15 | 16 | * Neither the name of Michel Hunziker nor the names of his 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 23 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 24 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 25 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 26 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 29 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 30 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 31 | POSSIBILITY OF SUCH DAMAGE. 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | GitLab Report for PHP_CodeSniffer 3 | --------------------------------- 4 | ![Main workflow](https://github.com/micheh/phpcs-gitlab/actions/workflows/main.yml/badge.svg) 5 | [![codecov](https://codecov.io/github/micheh/phpcs-gitlab/graph/badge.svg?token=02FSF3TT0T)](https://codecov.io/github/micheh/phpcs-gitlab) 6 | 7 | 8 | This library adds a custom report to [PHP_CodeSniffer](https://github.com/PHPCSStandards/PHP_CodeSniffer/) (phpcs) to generate a codequality artifact that can be used by GitLab CI/CD. 9 | The custom report is generated in Code Climate format and allows GitLab CI/CD to display the violations in the Code Quality report. 10 | 11 | ## Installation 12 | 13 | Install this library using [Composer](https://getcomposer.org): 14 | 15 | ```shell script 16 | composer require --dev micheh/phpcs-gitlab 17 | ``` 18 | 19 | Then adjust your `.gitlab-ci.yml` to run PHP_CodeSniffer with the custom reporter and to collect the codequality artifacts: 20 | 21 | ```yaml 22 | phpcs: 23 | script: vendor/bin/phpcs --report=full --report-\\Micheh\\PhpCodeSniffer\\Report\\Gitlab=phpcs-quality-report.json 24 | artifacts: 25 | reports: 26 | codequality: phpcs-quality-report.json 27 | ``` 28 | 29 | The example above uses two reports, one to display in the build log (full) and one to generate the codequality artifact file in Code Climate format. 30 | 31 | > **Note:** GitLab did not support multiple codequality artifacts before version 15.7. 32 | > If you are using an earlier version of GitLab, you will not be able to see the violations from multiple tools (e.g. PHP Code Sniffer & PHPStan) in the Code Quality report. 33 | 34 | Inside the codequality artifact, GitLab expects relative paths to the files with violations. 35 | To generate relative paths with PHP Code Sniffer, set the `basepath` argument in your `phpcs.xml.dist` configuration file with `` or run phpcs with `--basepath=.` (adjust the base path as needed). 36 | 37 | It is also possible to specify the reports to be used in the `phpcs.xml.dist` file: 38 | 39 | ```xml 40 | 41 | 42 | ``` 43 | 44 | ## Upgrade from version 1 to 2 45 | 46 | The usage of this package remains the same. 47 | However, the calculation of the fingerprint has been updated and is now based on the content instead of the line number. 48 | This has the advantage that lines with violations can move up or down and GitLab will not report them as new violations. 49 | When upgrading to version 2, it is likely that all violations will show up as changed once, since all fingerprints are new. 50 | 51 | ## References 52 | 53 | - [PHP_CodeSniffer](https://github.com/PHPCSStandards/PHP_CodeSniffer/) 54 | - [GitLab CI/CD Code Quality](https://docs.gitlab.com/ee/ci/testing/code_quality.html) 55 | - [Code Climate Specification](https://github.com/codeclimate/platform/blob/master/spec/analyzers/SPEC.md#data-types) 56 | 57 | 58 | ## License 59 | 60 | The files in this archive are licensed under the BSD-3-Clause license. 61 | You can find a copy of this license in [LICENSE.md](LICENSE.md). 62 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "micheh/phpcs-gitlab", 3 | "description": "GitLab Report for PHP_CodeSniffer (display the violations in the GitLab CI/CD Code Quality Report)", 4 | "type": "library", 5 | "license": "BSD-3-Clause", 6 | "authors": [ 7 | { 8 | "name": "Michel Hunziker", 9 | "email": "info@michelhunziker.com" 10 | } 11 | ], 12 | "keywords": [ 13 | "phpcs", 14 | "php_codesniffer", 15 | "gitlab", 16 | "code quality", 17 | "code climate", 18 | "report" 19 | ], 20 | "minimum-stability": "stable", 21 | "require": { 22 | "ext-json": "*" 23 | }, 24 | "require-dev": { 25 | "phpstan/phpstan": "^2.0", 26 | "phpunit/phpunit": "^9.3 || ^10.0", 27 | "squizlabs/php_codesniffer": "^3.5.0" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "Micheh\\PhpCodeSniffer\\": "src/" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "MichehTest\\PhpCodeSniffer\\": "tests/" 37 | } 38 | }, 39 | "config": { 40 | "sort-packages": true 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | src 17 | tests 18 | tests/_files/* 19 | 20 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | 2 | parameters: 3 | level: 8 4 | paths: 5 | - src 6 | - tests 7 | excludePaths: 8 | - tests/_files/ 9 | scanDirectories: 10 | - vendor/squizlabs/php_codesniffer 11 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | tests 17 | 18 | 19 | 20 | 21 | 22 | src 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/Report/Gitlab.php: -------------------------------------------------------------------------------- 1 | 5 | * @license http://www.opensource.org/licenses/BSD-3-Clause The BSD-3-Clause License 6 | */ 7 | 8 | declare(strict_types=1); 9 | 10 | namespace Micheh\PhpCodeSniffer\Report; 11 | 12 | use PHP_CodeSniffer\Files\File; 13 | use PHP_CodeSniffer\Reports\Report; 14 | use SplFileObject; 15 | 16 | use function count; 17 | use function is_string; 18 | use function md5; 19 | use function preg_replace; 20 | use function rtrim; 21 | use function str_replace; 22 | 23 | use const PHP_EOL; 24 | 25 | class Gitlab implements Report 26 | { 27 | /** 28 | * @param array{filename: string, errors: int, warnings: int, fixable: int, messages: array} $report 29 | */ 30 | public function generateFileReport($report, File $phpcsFile, $showSources = false, $width = 80) 31 | { 32 | $hasOutput = false; 33 | $violations = []; 34 | 35 | foreach ($report['messages'] as $line => $lineErrors) { 36 | $lineContent = $this->getContentOfLine($phpcsFile->getFilename(), $line); 37 | 38 | foreach ($lineErrors as $col => $colErrors) { 39 | foreach ($colErrors as $error) { 40 | $fingerprint = md5($report['filename'] . $lineContent . $error['source']); 41 | 42 | $violations[$fingerprint][$line . $col] = [ 43 | 'type' => 'issue', 44 | 'categories' => ['Style'], 45 | 'check_name' => $error['source'], 46 | 'fingerprint' => $fingerprint, 47 | 'severity' => $error['type'] === 'ERROR' ? 'major' : 'minor', 48 | 'description' => str_replace(["\n", "\r", "\t"], ['\n', '\r', '\t'], $error['message']), 49 | 'location' => [ 50 | 'path' => $report['filename'], 51 | 'lines' => [ 52 | 'begin' => $line, 53 | 'end' => $line, 54 | ] 55 | ], 56 | ]; 57 | } 58 | } 59 | } 60 | 61 | foreach ($violations as $fingerprints) { 62 | $hasMultiple = count($fingerprints) > 1; 63 | foreach ($fingerprints as $lineColumn => $issue) { 64 | if ($hasMultiple) { 65 | $issue['fingerprint'] = md5($issue['fingerprint'] . $lineColumn); 66 | } 67 | 68 | echo json_encode($issue, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ','; 69 | $hasOutput = true; 70 | } 71 | } 72 | 73 | return $hasOutput; 74 | } 75 | 76 | public function generate( 77 | $cachedData, 78 | $totalFiles, 79 | $totalErrors, 80 | $totalWarnings, 81 | $totalFixable, 82 | $showSources = false, 83 | $width = 80, 84 | $interactive = false, 85 | $toScreen = true 86 | ) { 87 | echo '[' . rtrim($cachedData, ',') . ']' . PHP_EOL; 88 | } 89 | 90 | /** 91 | * @param string $filename 92 | * @param int $line 93 | * @return string 94 | */ 95 | private function getContentOfLine($filename, $line) 96 | { 97 | $file = new SplFileObject($filename); 98 | 99 | if (!$file->eof()) { 100 | $file->seek($line - 1); 101 | $contents = $file->current(); 102 | 103 | if (is_string($contents)) { 104 | return (string) preg_replace('/\s+/', '', $contents); 105 | } 106 | } 107 | 108 | return ''; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/Report/GitlabTest.php: -------------------------------------------------------------------------------- 1 | 5 | * @license http://www.opensource.org/licenses/BSD-3-Clause The BSD-3-Clause License 6 | */ 7 | 8 | declare(strict_types=1); 9 | 10 | namespace MichehTest\PhpCodeSniffer\Report; 11 | 12 | use Micheh\PhpCodeSniffer\Report\Gitlab; 13 | use PHP_CodeSniffer\Config; 14 | use PHP_CodeSniffer\Files\File; 15 | use PHP_CodeSniffer\Files\LocalFile; 16 | use PHP_CodeSniffer\Reporter; 17 | use PHP_CodeSniffer\Ruleset; 18 | use PHP_CodeSniffer\Runner; 19 | use PHPUnit\Framework\TestCase; 20 | 21 | use function file_get_contents; 22 | 23 | class GitlabTest extends TestCase 24 | { 25 | /** 26 | * @var Gitlab 27 | */ 28 | private $report; 29 | 30 | /** 31 | * @var Config 32 | */ 33 | private static $config; 34 | 35 | 36 | public static function setUpBeforeClass(): void 37 | { 38 | self::$config = new Config(); 39 | self::$config->basepath = __DIR__ . '/../'; 40 | self::$config->standards = ['PSR12']; 41 | 42 | $runner = new Runner(); 43 | $runner->config = self::$config; 44 | $runner->init(); 45 | } 46 | 47 | protected function setUp(): void 48 | { 49 | $this->report = new Gitlab(); 50 | } 51 | 52 | public function testGenerate(): void 53 | { 54 | $this->expectOutputString("[{\"phpunit\":\"test\"}]\n"); 55 | $this->report->generate('{"phpunit":"test"},', 5, 1, 2, 1); 56 | } 57 | 58 | public function testGenerateWithEmpty(): void 59 | { 60 | $this->expectOutputString("[]\n"); 61 | $this->report->generate('', 5, 0, 0, 0); 62 | } 63 | 64 | /** 65 | * @return array> 66 | */ 67 | public static function violations(): array 68 | { 69 | return [ 70 | 'single' => ['Single'], 71 | 'mixed' => ['Mixed'], 72 | 'multiple' => ['Multiple'], 73 | 'same-line' => ['SameLine'], 74 | ]; 75 | } 76 | 77 | /** 78 | * @dataProvider violations 79 | */ 80 | public function testGenerateFileReport(string $fileName): void 81 | { 82 | $phpPath = __DIR__ . '/../_files/' . $fileName . '.php'; 83 | self::assertFileExists($phpPath); 84 | 85 | $outputPath = __DIR__ . '/../_files/' . $fileName . '.json'; 86 | self::assertFileExists($outputPath); 87 | 88 | $file = $this->createFile($phpPath); 89 | 90 | $this->expectOutputString((string) file_get_contents($outputPath)); 91 | $this->report->generateFileReport($this->getReportData($file), $file); 92 | } 93 | 94 | private function createFile(string $path): File 95 | { 96 | $file = new LocalFile($path, new Ruleset(self::$config), self::$config); 97 | $file->process(); 98 | 99 | return $file; 100 | } 101 | 102 | /** 103 | * @return array{filename: string, errors: int, warnings: int, fixable: int, messages: array} 104 | */ 105 | private function getReportData(File $file): array 106 | { 107 | $reporter = new Reporter(self::$config); 108 | return $reporter->prepareFileReport($file); // @phpstan-ignore return.type 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/_files/Mixed.json: -------------------------------------------------------------------------------- 1 | {"type":"issue","categories":["Style"],"check_name":"Squiz.Functions.MultiLineFunctionDeclaration.BraceOnSameLine","fingerprint":"0da49d5a2f6b30f151e4abef2fccd4e2","severity":"major","description":"Opening brace should be on a new line","location":{"path":"_files/Mixed.php","lines":{"begin":7,"end":7}}},{"type":"issue","categories":["Style"],"check_name":"Generic.Files.LineLength.TooLong","fingerprint":"0be4b9620fcab36dcef7ba50bd3ac097","severity":"minor","description":"Line exceeds 120 characters; contains 143 characters","location":{"path":"_files/Mixed.php","lines":{"begin":8,"end":8}}}, -------------------------------------------------------------------------------- /tests/_files/Mixed.php: -------------------------------------------------------------------------------- 1 |