├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json └── src ├── Baseline ├── BaselineSet.php ├── BaselineSetFactory.php └── ViolationBaseline.php ├── Plugin ├── BaselineHandler.php └── Plugin.php ├── Reports └── Baseline.php └── Util ├── CodeSignature.php └── DirectoryUtil.php /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | - **Create feature branches** - Don't ask us to pull from your master branch. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 23 | 24 | 25 | ## Running Tests 26 | 27 | ``` bash 28 | $ composer test 29 | ``` 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 123inkt / DigitalRevolution 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.1-8892BA)](https://php.net/) 2 | ![Run tests](https://github.com/123inkt/php-codesniffer-baseline/workflows/Run%20checks/badge.svg) 3 | 4 | # PHP_Codesniffer baseline 5 | 6 | To be able to add PHP_Codesniffer or adding new rules to an existing project, it is not always possible to solve 7 | all the new issues that appear. As PHPCodesniffer doesn't have a baseline mechanism and while 8 | [PR:3387](https://github.com/squizlabs/PHP_CodeSniffer/pull/3387) is not accepted yet, this package can be used to 9 | baseline your projects current issues. 10 | 11 | ## Getting Started 12 | 13 | ```bash 14 | composer require --dev digitalrevolution/php-codesniffer-baseline 15 | ``` 16 | 17 | ## Create baseline 18 | Create the baseline by using phpcs regularly and writing the report with the Baseline report class. You must write the baseline 19 | to the root of the project and name it `phpcs.baseline.xml`. 20 | ```bash 21 | php vendor/bin/phpcs src tests --report=\\DR\\CodeSnifferBaseline\\Reports\\Baseline --report-file=phpcs.baseline.xml --basepath=. 22 | ``` 23 | 24 | ## Usage 25 | Use phpcs like you normally would. With `phpcs.baseline.xml` in the root of your project, the baseline extension will automatically read the config 26 | file and skip errors that are contained within the baseline. 27 | 28 | ## Under the hood 29 | 30 | As PHP_Codesniffer doesn't have a nice and clean way to add an extension, this package will inject a single line of code 31 | into the `/vendor/squizlabs/php_codesniffer/src/Files/File.php` upon `composer install` or `composer update`. While this 32 | is a fragile solution, this is only until [PR:3387](https://github.com/squizlabs/PHP_CodeSniffer/pull/3387) is accepted 33 | or another baseline method has been added. 34 | 35 | ## About us 36 | 37 | At 123inkt (Part of Digital Revolution B.V.), every day more than 50 development professionals are working on improving our internal ERP 38 | and our several shops. Do you want to join us? [We are looking for developers](https://www.werkenbij123inkt.nl/zoek-op-afdeling/it). 39 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "digitalrevolution/php-codesniffer-baseline", 3 | "description": "Digital Revolution PHP_Codesniffer baseline extension", 4 | "type": "composer-plugin", 5 | "license": "MIT", 6 | "minimum-stability": "stable", 7 | "config": { 8 | "sort-packages": true, 9 | "process-timeout": 0, 10 | "allow-plugins": { 11 | "phpstan/extension-installer": true 12 | }, 13 | "lock": false 14 | }, 15 | "require": { 16 | "php": ">=8.1", 17 | "composer-plugin-api": "^2.0", 18 | "squizlabs/php_codesniffer": "^3.6" 19 | }, 20 | "require-dev": { 21 | "composer/composer": "^2.0", 22 | "mikey179/vfsstream": "1.6.12", 23 | "phpmd/phpmd": "^2.15", 24 | "phpstan/phpstan": "^2.0", 25 | "phpstan/phpstan-phpunit": "^2.0", 26 | "phpstan/phpstan-strict-rules": "^2.0", 27 | "phpstan/extension-installer": "^1.4", 28 | "phpunit/phpunit": "^10.5 || ^11.5", 29 | "roave/security-advisories": "dev-latest" 30 | }, 31 | "scripts": { 32 | "baseline": ["@baseline:phpcs", "@baseline:phpmd", "@baseline:phpstan", "@baseline:phpcqc"], 33 | "baseline:phpcs": "phpcs --report=\\\\DR\\\\CodeSnifferBaseline\\\\Reports\\\\Baseline --report-file=phpcs.baseline.xml --basepath=.", 34 | "baseline:phpmd": "@check:phpmd --generate-baseline", 35 | "baseline:phpstan": "phpstan --generate-baseline", 36 | "run:plugin": "DR\\CodeSnifferBaseline\\Plugin\\Plugin::run", 37 | "check": ["@check:phpstan", "@check:phpmd", "@check:phpcs"], 38 | "check:phpstan": "phpstan analyse", 39 | "check:phpmd": "phpmd src,tests text phpmd.xml.dist --suffixes php", 40 | "check:phpcs": "phpcs src tests", 41 | "fix": "@fix:phpcbf", 42 | "fix:phpcbf": "phpcbf src tests", 43 | "test": "phpunit", 44 | "test:integration": "phpunit --testsuite integration", 45 | "test:unit": "phpunit --testsuite unit" 46 | }, 47 | "extra": { 48 | "class": "DR\\CodeSnifferBaseline\\Plugin\\Plugin" 49 | }, 50 | "autoload": { 51 | "psr-4": { 52 | "DR\\CodeSnifferBaseline\\": "src/" 53 | } 54 | }, 55 | "autoload-dev": { 56 | "psr-4": { 57 | "DR\\CodeSnifferBaseline\\Tests\\Unit\\": "tests/Unit/", 58 | "DR\\CodeSnifferBaseline\\Tests\\": "tests/" 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Baseline/BaselineSet.php: -------------------------------------------------------------------------------- 1 | > */ 9 | private array $violations = []; 10 | 11 | /** 12 | * Add a single entry to the baseline set 13 | */ 14 | public function addEntry(ViolationBaseline $entry): void 15 | { 16 | $this->violations[$entry->getSniffName()][$entry->getSignature()][] = $entry; 17 | } 18 | 19 | /** 20 | * Test if the given sniff and filename is in the baseline collection 21 | */ 22 | public function contains(string $sniffName, string $fileName, string $signature): bool 23 | { 24 | if (isset($this->violations[$sniffName][$signature]) === false) { 25 | return false; 26 | } 27 | 28 | // Normalize slashes in file name. 29 | $fileName = str_replace('\\', '/', $fileName); 30 | 31 | foreach ($this->violations[$sniffName][$signature] as $baseline) { 32 | if ($baseline->matches($fileName) === true) { 33 | return true; 34 | } 35 | } 36 | 37 | return false; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Baseline/BaselineSetFactory.php: -------------------------------------------------------------------------------- 1 | children() as $node) { 28 | if ($node->getName() !== 'violation') { 29 | continue; 30 | } 31 | 32 | if (isset($node['sniff']) === false) { 33 | throw new RuntimeException('Missing `sniff` attribute in `violation` in ' . $fileName); 34 | } 35 | 36 | if (isset($node['file']) === false) { 37 | throw new RuntimeException('Missing `file` attribute in `violation` in ' . $fileName); 38 | } 39 | 40 | if (isset($node['signature']) === false) { 41 | throw new RuntimeException('Missing `signature` attribute in `violation` in ' . $fileName); 42 | } 43 | 44 | // Normalize filepath (if needed). 45 | $filePath = '/' . ltrim(str_replace('\\', '/', (string)$node['file']), '/'); 46 | 47 | $baselineSet->addEntry(new ViolationBaseline((string)$node['sniff'], $filePath, (string)$node['signature'])); 48 | } 49 | 50 | return $baselineSet; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Baseline/ViolationBaseline.php: -------------------------------------------------------------------------------- 1 | sniffName = $sniffName; 34 | $this->fileName = $fileName; 35 | $this->fileNameLength = strlen($fileName); 36 | $this->signature = $signature; 37 | } 38 | 39 | /** 40 | * Get the sniff name that was baselined 41 | */ 42 | public function getSniffName(): string 43 | { 44 | return $this->sniffName; 45 | } 46 | 47 | /** 48 | * Get the code signature for this baseline 49 | */ 50 | public function getSignature(): string 51 | { 52 | return $this->signature; 53 | } 54 | 55 | /** 56 | * Test if the given filepath matches the relative filename in the baseline 57 | */ 58 | public function matches(string $filepath): bool 59 | { 60 | return substr($filepath, -$this->fileNameLength) === $this->fileName; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Plugin/BaselineHandler.php: -------------------------------------------------------------------------------- 1 | baseline = $baseline; 21 | } 22 | 23 | /** 24 | * @codeCoverageIgnore 25 | */ 26 | public static function getInstance(Config $config): self 27 | { 28 | // singleton is required to hook into the php-codesniffer code. 29 | if (self::$instance === null) { 30 | $baseline = null; 31 | // only read baseline if phpcs is not writing one. 32 | if ($config->reportFile === null || strpos($config->reportFile, 'phpcs.baseline.xml') === false) { 33 | $baseline = BaselineSetFactory::fromFile(DirectoryUtil::getProjectRoot() . 'phpcs.baseline.xml'); 34 | } 35 | 36 | self::$instance = new self($baseline); 37 | } 38 | 39 | return self::$instance; 40 | } 41 | 42 | /** 43 | * @param array $tokens All tokens of a given file. 44 | */ 45 | public function isSuppressed(array $tokens, int $lineNr, string $sniffCode, string $path): bool 46 | { 47 | if ($this->baseline === null) { 48 | return false; 49 | } 50 | 51 | return $this->baseline->contains($sniffCode, $path, CodeSignature::createSignature($tokens, $lineNr)); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Plugin/Plugin.php: -------------------------------------------------------------------------------- 1 | codeSnifferFilePath = $codeSnifferFilePath ?? DirectoryUtil::getVendorDir() . 'squizlabs/php_codesniffer/src/Files/File.php'; 23 | } 24 | 25 | /** 26 | * @inheritDoc 27 | */ 28 | public function activate(Composer $composer, IOInterface $stream): void 29 | { 30 | $this->stream = $stream; 31 | } 32 | 33 | /** 34 | * @inheritDoc 35 | * @codeCoverageIgnore 36 | */ 37 | public function deactivate(Composer $composer, IOInterface $stream): void 38 | { 39 | // not necessary 40 | } 41 | 42 | /** 43 | * @inheritDoc 44 | * @codeCoverageIgnore 45 | */ 46 | public function uninstall(Composer $composer, IOInterface $stream): void 47 | { 48 | // not necessary 49 | } 50 | 51 | public function onPostInstall(): void 52 | { 53 | if ($this->stream === null) { 54 | return; 55 | } 56 | 57 | // find code sniffer File 58 | if (file_exists($this->codeSnifferFilePath) === false) { 59 | $this->stream->error('php-codesniffer-baseline: failed to find: ' . $this->codeSnifferFilePath); 60 | 61 | return; 62 | } 63 | 64 | // read file contents of src/Files/File.php 65 | $source = @file_get_contents($this->codeSnifferFilePath); 66 | // @codeCoverageIgnoreStart 67 | if ($source === false) { 68 | $this->stream->error('php-codesniffer-baseline: failed to read contents of: ' . $this->codeSnifferFilePath); 69 | 70 | return; 71 | } 72 | // @codeCoverageIgnoreEnd 73 | $this->stream->info('php-codesniffer-baseline: read: ' . $this->codeSnifferFilePath); 74 | 75 | if (strpos($source, BaselineHandler::class) !== false) { 76 | $this->stream->info('php-codesniffer-baseline: ignored. src/Files/File.php is already modified'); 77 | 78 | return; 79 | } 80 | 81 | $search = '$messageCount++;'; 82 | if (strpos($source, $search) === false) { 83 | $this->stream->error('php-codesniffer-baseline: unable to find `' . $search . '` in `squizlabs/php_codesniffer/src/Files/File.php`'); 84 | 85 | return; 86 | } 87 | 88 | // Upon composer install or update, inject a single line of code into `squizlabs/php_codesniffer/src/Files/File.php` 89 | // This is a fragile solution, but necessary until PR:3387 (https://github.com/squizlabs/PHP_CodeSniffer/pull/3387) 90 | // is accepted. 91 | $code = 'if (\\' . BaselineHandler::class . '::getInstance($this->config)'; 92 | $code .= '->isSuppressed($this->getTokens(), $line, $sniffCode, $this->path)) {'; 93 | $code .= 'return false;}'; 94 | $source = str_replace($search, $code . "\n\n " . $search, $source); 95 | 96 | // write back to src/Files/File.php 97 | file_put_contents($this->codeSnifferFilePath, $source); 98 | $this->stream->info('php-codesniffer-baseline: saved to: ' . $this->codeSnifferFilePath); 99 | } 100 | 101 | /** 102 | * @inheritDoc 103 | */ 104 | public static function getSubscribedEvents(): array 105 | { 106 | return [ 107 | ScriptEvents::POST_INSTALL_CMD => [['onPostInstall', 0]], 108 | ScriptEvents::POST_UPDATE_CMD => [['onPostInstall', 0]], 109 | ]; 110 | } 111 | 112 | /** 113 | * Triggers the plugin's main functionality. 114 | * Makes it possible to run the plugin as a custom command. 115 | * @throws Exception 116 | */ 117 | public static function run(Event $event): void 118 | { 119 | $instance = new self(); 120 | $instance->stream = $event->getIO(); 121 | $instance->onPostInstall(); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Reports/Baseline.php: -------------------------------------------------------------------------------- 1 | 20 | * } $report 21 | * @inheritDoc 22 | */ 23 | public function generateFileReport($report, File $phpcsFile, $showSources = false, $width = 80): bool 24 | { 25 | $out = new XMLWriter(); 26 | $out->openMemory(); 27 | $out->setIndent(true); 28 | $out->setIndentString(' '); 29 | $out->startDocument('1.0', 'UTF-8'); 30 | 31 | if ($report['errors'] === 0 && $report['warnings'] === 0) { 32 | // Nothing to print. 33 | return false; 34 | } 35 | 36 | foreach ($report['messages'] as $lineNr => $lineErrors) { 37 | $signature = Util\CodeSignature::createSignature($phpcsFile->getTokens(), $lineNr); 38 | 39 | foreach ($lineErrors as $colErrors) { 40 | foreach ($colErrors as $error) { 41 | $out->startElement('violation'); 42 | $out->writeAttribute('file', str_replace('\\', '/', $report['filename'])); 43 | $out->writeAttribute('sniff', $error['source']); 44 | $out->writeAttribute('signature', $signature); 45 | 46 | $out->endElement(); 47 | } 48 | } 49 | } 50 | 51 | // Remove the start of the document because we will 52 | // add that manually later. We only have it in here to 53 | // properly set the encoding. 54 | $content = $out->flush(); 55 | $content = preg_replace("/[\n\r]/", PHP_EOL, $content); 56 | $content = substr($content, (int)strpos($content, PHP_EOL) + strlen(PHP_EOL)); 57 | 58 | echo $content; 59 | 60 | return true; 61 | } 62 | 63 | /** 64 | * @inheritDoc 65 | */ 66 | public function generate( 67 | $cachedData, 68 | $totalFiles, 69 | $totalErrors, 70 | $totalWarnings, 71 | $totalFixable, 72 | $showSources = false, 73 | $width = 80, 74 | $interactive = false, 75 | $toScreen = true 76 | ): void { 77 | echo '' . PHP_EOL; 78 | echo ''; 79 | 80 | // Split violations on line-ending, make them unique and sort them. 81 | if ($cachedData !== "") { 82 | $lines = explode(PHP_EOL, $cachedData); 83 | $lines = array_unique($lines); 84 | sort($lines); 85 | $cachedData = implode(PHP_EOL, $lines); 86 | } 87 | 88 | echo $cachedData; 89 | echo PHP_EOL . '' . PHP_EOL; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Util/CodeSignature.php: -------------------------------------------------------------------------------- 1 | $tokens All tokens of a given file. 12 | */ 13 | public static function createSignature(array $tokens, int $lineNr): string 14 | { 15 | // Get all tokens one line before and after. 16 | $start = $lineNr - 1; 17 | $end = $lineNr + 1; 18 | 19 | $content = ''; 20 | foreach ($tokens as $token) { 21 | if ($token['line'] > $end) { 22 | break; 23 | } 24 | 25 | // Concat content excluding line endings. 26 | if ($token['line'] >= $start && isset($token['content']) === true) { 27 | $content .= trim($token['content'], "\r\n"); 28 | } 29 | } 30 | 31 | // Generate sha1 hash. 32 | return hash('sha1', $content); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Util/DirectoryUtil.php: -------------------------------------------------------------------------------- 1 |