diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 229a1b0..f7ee48d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,28 @@ version: 2 updates: - - commit-message: - include: "scope" - prefix: "ci" + - package-ecosystem: "github-actions" directory: "/" + schedule: + interval: "weekly" labels: - "dependencies" open-pull-requests-limit: 10 - package-ecosystem: "github-actions" + commit-message: + prefix: "ci" + include: "scope" + groups: + github-actions: + patterns: ["*"] + + - package-ecosystem: "composer" + directory: "/" schedule: interval: "weekly" + labels: + - "dependencies" + open-pull-requests-limit: 10 + versioning-strategy: "increase-if-necessary" + commit-message: + prefix: "fix" + prefix-development: "chore" + include: "scope" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bd8c97c..7ebbcac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,9 @@ on: branches: - "main" +permissions: + contents: "read" + jobs: tests: name: "Tests" @@ -18,8 +21,8 @@ jobs: matrix: dependencies: ["lowest", "highest"] php-version: - - "8.3" - "8.4" + - "8.5" operating-system: ["ubuntu-latest"] steps: @@ -33,20 +36,24 @@ jobs: php-version: "${{ matrix.php-version }}" extensions: mbstring + - name: "Determine composer cache directory" + id: "composer-cache" + run: "echo \"dir=$(composer config cache-files-dir)\" >> \"$GITHUB_OUTPUT\"" + - name: "Cache dependencies" uses: "actions/cache@v4" with: - path: "~/.composer/cache" - key: "php-${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }}" - restore-keys: "php-${{ matrix.php-version }}-composer-" + path: "${{ steps.composer-cache.outputs.dir }}" + key: "php-${{ matrix.php-version }}-${{ matrix.dependencies }}-composer-${{ hashFiles('**/composer.json') }}" + restore-keys: "php-${{ matrix.php-version }}-${{ matrix.dependencies }}-composer-" - name: "Install lowest dependencies" if: ${{ matrix.dependencies == 'lowest' }} - run: "composer update --prefer-lowest --prefer-dist --no-interaction --no-progress --no-suggest" + run: "composer update --prefer-lowest --prefer-dist --no-interaction --no-progress" - name: "Install highest dependencies" if: ${{ matrix.dependencies == 'highest' }} - run: "composer update --prefer-dist --no-interaction --no-progress --no-suggest" + run: "composer update --prefer-dist --no-interaction --no-progress" - name: "Tests" run: "vendor/bin/phpunit" @@ -54,3 +61,7 @@ jobs: - name: "Test Brainbits" if: ${{ matrix.dependencies == 'highest' }} run: "(vendor/bin/phpcs --basepath=. --report=summary --report-file=phpcs.log --standard=Brainbits `find tests/Brainbits/input/* | sort`) || true; diff -u tests/Brainbits/expected_report.txt phpcs.log" + + - name: "Coding Standard" + if: ${{ matrix.dependencies == 'highest' }} + run: "vendor/bin/phpcs" diff --git a/.gitignore b/.gitignore index 0be38b8..b39a36a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.phpunit.cache .phpunit.result.cache composer.lock phpcs.log diff --git a/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php b/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php index a4da6cb..ca1e1de 100644 --- a/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php +++ b/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php @@ -6,120 +6,282 @@ use PHP_CodeSniffer\Files\File; use PHP_CodeSniffer\Sniffs\Sniff; +use PHP_CodeSniffer\Util\Tokens; use SlevomatCodingStandard\Helpers\TokenHelper; -use SlevomatCodingStandard\Helpers\UseStatementHelper; -use function defined; +use function explode; use function in_array; +use function ltrim; +use function preg_match; +use function preg_replace; +use function preg_split; +use function rtrim; use function sprintf; +use function strlen; +use function strpos; +use function strrpos; +use function strtolower; use function substr; +use function trim; -use const T_NS_SEPARATOR; -use const T_OPEN_TAG; +use const T_NAME_FULLY_QUALIFIED; +use const T_NAME_QUALIFIED; +use const T_NAME_RELATIVE; +use const T_NAMESPACE; +use const T_NEW; +use const T_OPEN_PARENTHESIS; +use const T_SEMICOLON; use const T_STRING; use const T_THROW; - -// phpcs:disable SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint -// phpcs:disable SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingNativeTypeHint +use const T_USE; +use const T_WHITESPACE; /** - * Global exception sniff + * Forbids throwing the built-in global exceptions, they should be extended locally. */ class GlobalExceptionSniff implements Sniff { - // phpcs:ignore SlevomatCodingStandard.TypeHints.ClassConstantTypeHint.MissingNativeTypeHint - public const CODE_GLOBAL_EXCEPTION = 'GlobalException'; + public const string CODE_GLOBAL_EXCEPTION = 'GlobalException'; + + private const array GLOBAL_EXCEPTIONS = [ + 'badfunctioncallexception', + 'badmethodcallexception', + 'domainexception', + 'errorexception', + 'exception', + 'invalidargumentexception', + 'lengthexception', + 'logicexception', + 'outofboundsexception', + 'outofrangeexception', + 'overflowexception', + 'rangeexception', + 'runtimeexception', + 'underflowexception', + 'unexpectedvalueexception', + ]; + + private const string RELATIVE_PREFIX = 'namespace\\'; - /** @return mixed[] */ + private const array NAME_TOKENS = [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NAME_RELATIVE]; + + /** @return list */ public function register(): array { - return [T_OPEN_TAG]; + return [T_THROW]; + } + + public function process(File $phpcsFile, int $stackPtr): void + { + $tokens = $phpcsFile->getTokens(); + + // skip parentheses, e.g. `throw (new Exception());` + $newPointer = TokenHelper::findNextEffective($phpcsFile, $stackPtr + 1); + while ($newPointer !== null && $tokens[$newPointer]['code'] === T_OPEN_PARENTHESIS) { + $newPointer = TokenHelper::findNextEffective($phpcsFile, $newPointer + 1); + } + + if ($newPointer === null || $tokens[$newPointer]['code'] !== T_NEW) { + // e.g. `throw $exception;` or `throw $this->createException();` + return; + } + + $classPointer = TokenHelper::findNextEffective($phpcsFile, $newPointer + 1); + if ($classPointer === null || !in_array($tokens[$classPointer]['code'], self::NAME_TOKENS, true)) { + // e.g. `throw new $class();` or `throw new class extends Foo {};` + return; + } + + $className = $this->resolveClassName($phpcsFile, $classPointer); + if (!in_array(strtolower($className), self::GLOBAL_EXCEPTIONS, true)) { + return; + } + + $phpcsFile->addError( + sprintf('Global exception "%s" used. It should be locally extended.', $className), + $stackPtr, + self::CODE_GLOBAL_EXCEPTION, + ); + } + + /** + * Returns the fully qualified class name without leading backslash. + */ + private function resolveClassName(File $phpcsFile, int $classPointer): string + { + $name = $phpcsFile->getTokens()[$classPointer]['content']; + + if ($name[0] === '\\') { + return ltrim($name, '\\'); + } + + $namespacePointer = $this->findNamespacePointer($phpcsFile, $classPointer); + $namespace = $namespacePointer === null ? '' : $this->getNamespaceName($phpcsFile, $namespacePointer); + + if (strtolower(substr($name, 0, strlen(self::RELATIVE_PREFIX))) === self::RELATIVE_PREFIX) { + return $this->prefixNamespace($namespace, substr($name, strlen(self::RELATIVE_PREFIX))); + } + + $separatorPosition = strpos($name, '\\'); + $firstPart = $separatorPosition === false ? $name : substr($name, 0, $separatorPosition); + $rest = $separatorPosition === false ? '' : substr($name, $separatorPosition); + + $imports = $this->getClassImports($phpcsFile, $namespacePointer, $classPointer); + $import = $imports[strtolower($firstPart)] ?? null; + if ($import !== null) { + return $import . $rest; + } + + return $this->prefixNamespace($namespace, $name); } - /** @param int $openTagPointer */ - public function process(File $phpcsFile, $openTagPointer): void + /** + * Returns the pointer of the namespace declaration the given pointer belongs to, + * null for code outside of any namespace declaration. + */ + private function findNamespacePointer(File $phpcsFile, int $pointer): int|null { - $currentPointer = $openTagPointer; - - $globalExceptions = [ - 'BadFunctionCallException', - 'BadMethodCallException', - 'DomainException', - 'ErrorException', - 'Exception', - 'InvalidArgumentException', - 'LengthException', - 'LogicException', - 'OutOfBoundsException', - 'OutOfRangeException', - 'OverflowException', - 'RangeException', - 'RuntimeException', - 'UnderflowException', - 'UnexpectedValueException', - ]; - - $useStatements = UseStatementHelper::getFileUseStatements($phpcsFile); - - if (defined('T_NAME_FULLY_QUALIFIED')) { - // php 8.x - // phpcs:ignore SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName - $classTokens = [T_NAME_FULLY_QUALIFIED, T_NS_SEPARATOR, T_STRING]; - } else { - $classTokens = [T_NS_SEPARATOR, T_STRING]; + $tokens = $phpcsFile->getTokens(); + + // bracketed syntax: `namespace Foo { ... }` or `namespace { ... }` + foreach ($tokens[$pointer]['conditions'] as $conditionPointer => $conditionCode) { + if ($conditionCode === T_NAMESPACE) { + return $conditionPointer; + } } + // unbracketed syntax: `namespace Foo;` + $namespacePointer = $pointer; do { - $currentPointer = TokenHelper::findNext($phpcsFile, T_THROW, $currentPointer + 1); - if (!$currentPointer) { + $namespacePointer = TokenHelper::findPrevious($phpcsFile, T_NAMESPACE, $namespacePointer - 1); + } while ( + $namespacePointer !== null + && ( + isset($tokens[$namespacePointer]['scope_closer']) + || $tokens[$namespacePointer]['conditions'] !== [] + ) + ); + + return $namespacePointer; + } + + private function getNamespaceName(File $phpcsFile, int $namespacePointer): string + { + $tokens = $phpcsFile->getTokens(); + $namePointer = TokenHelper::findNextEffective($phpcsFile, $namespacePointer + 1); + + if ($namePointer === null || !in_array($tokens[$namePointer]['code'], [T_STRING, T_NAME_QUALIFIED], true)) { + // `namespace { ... }` is the global namespace + return ''; + } + + return $tokens[$namePointer]['content']; + } + + /** + * Returns the class imports (lower case alias => fully qualified name) of the namespace + * the given pointer belongs to. + * + * @return array + */ + private function getClassImports(File $phpcsFile, int|null $namespacePointer, int $pointer): array + { + $tokens = $phpcsFile->getTokens(); + $expectedConditions = $namespacePointer !== null && isset($tokens[$namespacePointer]['scope_closer']) + ? [$namespacePointer => T_NAMESPACE] + : []; + + $imports = []; + $usePointer = $namespacePointer ?? 0; + while (true) { + $usePointer = TokenHelper::findNext($phpcsFile, T_USE, $usePointer + 1, $pointer); + if ($usePointer === null) { break; } - $classStartPointer = TokenHelper::findNext($phpcsFile, $classTokens, $currentPointer + 1); - if (!$classStartPointer) { + if ($tokens[$usePointer]['conditions'] !== $expectedConditions) { + // trait use or import of another namespace continue; } - $classEndPointer = TokenHelper::findNextExcluding( - $phpcsFile, - $classTokens, - $classStartPointer + 1, - ) - 1; - if (!$classEndPointer) { + $nextPointer = TokenHelper::findNextEffective($phpcsFile, $usePointer + 1); + if ($nextPointer === null || $tokens[$nextPointer]['code'] === T_OPEN_PARENTHESIS) { + // closure use + continue; + } + + $endPointer = TokenHelper::findNext($phpcsFile, T_SEMICOLON, $usePointer + 1); + if ($endPointer === null) { + break; + } + + $imports = [...$imports, ...$this->parseUseStatement($phpcsFile, $usePointer + 1, $endPointer - 1)]; + $usePointer = $endPointer; + } + + return $imports; + } + + /** + * Parses `Foo\Bar`, `Foo\Bar as Baz, Qux` and group uses like `Foo\{Bar, Baz as Qux}`. + * + * @return array + */ + private function parseUseStatement(File $phpcsFile, int $startPointer, int $endPointer): array + { + $tokens = $phpcsFile->getTokens(); + $statement = ''; + for ($pointer = $startPointer; $pointer <= $endPointer; $pointer++) { + if (isset(Tokens::COMMENT_TOKENS[$tokens[$pointer]['code']])) { continue; } - $class = TokenHelper::getContent($phpcsFile, $classStartPointer, $classEndPointer); - - if (substr($class, 0, 1) === '\\') { - // fully qualfied - $class = substr($class, 1); - if (in_array($class, $globalExceptions)) { - $phpcsFile->addError( - sprintf('Global exception "%s" used. It should be locally extended.', $class), - $currentPointer, - self::CODE_GLOBAL_EXCEPTION, - ); - } - } else { - // referenced - foreach ($useStatements as $useStatementX) { - foreach ($useStatementX as $useStatement) { - // phpcs:ignore - if ($useStatement->getNameAsReferencedInFile() === $class || $useStatement->getFullyQualifiedTypeName() === $class) { - if (in_array($useStatement->getFullyQualifiedTypeName(), $globalExceptions)) { - $phpcsFile->addError( - sprintf('Global exception "%s" used. It should be locally extended.', $class), - $classStartPointer, - self::CODE_GLOBAL_EXCEPTION, - ); - } - } - } - } + $statement .= $tokens[$pointer]['code'] === T_WHITESPACE ? ' ' : $tokens[$pointer]['content']; + } + + $statement = trim((string) preg_replace('/\s+/', ' ', $statement)); + if ($this->isFunctionOrConstImport($statement)) { + return []; + } + + $prefix = ''; + $groupStart = strpos($statement, '{'); + if ($groupStart !== false) { + $prefix = rtrim(trim(substr($statement, 0, $groupStart)), '\\') . '\\'; + $statement = rtrim(substr($statement, $groupStart + 1), '} '); + } + + $imports = []; + foreach (explode(',', $statement) as $entry) { + $entry = trim($entry); + if ($entry === '' || $this->isFunctionOrConstImport($entry)) { + continue; } - $currentPointer = $classEndPointer + 1; - } while ($currentPointer); + $parts = (array) preg_split('/\s+as\s+/i', $entry); + $fullyQualifiedName = ltrim($prefix . trim((string) $parts[0]), '\\'); + $alias = isset($parts[1]) ? trim((string) $parts[1]) : $this->getLastNamePart($fullyQualifiedName); + + $imports[strtolower($alias)] = $fullyQualifiedName; + } + + return $imports; + } + + private function isFunctionOrConstImport(string $statement): bool + { + return preg_match('/^(function|const)\s/i', $statement) === 1; + } + + private function getLastNamePart(string $name): string + { + $separatorPosition = strrpos($name, '\\'); + + return $separatorPosition === false ? $name : substr($name, $separatorPosition + 1); + } + + private function prefixNamespace(string $namespace, string $name): string + { + return $namespace === '' ? $name : $namespace . '\\' . $name; } } diff --git a/Brainbits/ruleset.xml b/Brainbits/ruleset.xml index cc4d0ae..3d32c38 100755 --- a/Brainbits/ruleset.xml +++ b/Brainbits/ruleset.xml @@ -5,8 +5,6 @@ - - diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index aba5bbd..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,17 +0,0 @@ -# Changelog - -## 1.0.3 - -* Exclude sniff `SlevomatCodingStandard.Classes.SuperfluousInterfaceNaming.SuperfluousSuffix` - -## 1.0.2 - -* Exclude sniff `SlevomatCodingStandard.Classes.SuperfluousExceptionNaming.SuperfluousSuffix` - -## 1.0.1 - -* Exclude sniff `SlevomatCodingStandard.Commenting.RequireOneLinePropertyDocComment` - -## 1.0.0 - -* Release diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..61d22ed --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +## Projekt + +`brainbits/phpcs-standard` ist ein PHP_CodeSniffer-Standard (Composer-Typ +`phpcodesniffer-standard`). Er basiert auf dem Doctrine Coding Standard (der wiederum +Slevomat einbindet), schaltet einige Doctrine-Regeln ab und ergänzt einen eigenen Sniff. +Installiert wird er über `dealerdirect/phpcodesniffer-composer-installer`, der das +Verzeichnis `Brainbits/` als Standard registriert. + +- PHP ^8.4. Abhängigkeiten: `squizlabs/php_codesniffer` ^4, `doctrine/coding-standard` ^14, + `slevomat/coding-standard` ^8.23 (Minimum von Doctrine 14), Dev: PHPUnit ^13.4. +- CI (`.github/workflows/build.yml`): PHP 8.4 und 8.5, jeweils mit `lowest`- und + `highest`-Abhängigkeiten. + +## Aufbau + +- `Brainbits/ruleset.xml` — der Standard. Name des Standards ist **`Brainbits`**. + Bindet `Doctrine` ein und schließt einzelne Sniffs aus. Konfigurationen, die Doctrine + bereits mitbringt (z. B. `DNFTypeHintFormat`), nicht erneut referenzieren. +- `Brainbits/Sniffs/` — eigene Sniffs, Namespace `Brainbits\Sniffs\` (PSR-4). Sniffs in + diesem Verzeichnis werden von PHPCS automatisch Teil des Standards, sie müssen nicht im + Ruleset referenziert werden. + - `Exception/GlobalExceptionSniff.php` — meldet `throw new` mit eingebauten globalen + Exceptions (`\Exception`, `\ErrorException` und die SPL-Exceptions wie + `\RuntimeException`). Der Klassenname wird selbst aufgelöst, pro Namespace-Block (auch + mehrere Namespaces pro Datei, `namespace { }`), über `use`-Statements inkl. Alias und + Gruppen-Use (`use function`/`use const` zählen nicht), `namespace\…`; Vergleich + case-insensitive. Klammern nach `throw` werden übersprungen. Nicht erkannt (bewusst): + `throw $e`, `throw new $class`, anonyme Klassen, `throw X::create()`. Fehler sitzt auf + dem `throw`-Token. Vollständiger Fehlercode: + `Brainbits.Exception.GlobalException.GlobalException`. +- `tests/Brainbits/Sniffs/` — PHPUnit-Tests der Sniffs. Sie erben von + `SlevomatCodingStandard\Sniffs\TestCase`; die Sniff-Klasse wird aus dem Testklassennamen + abgeleitet (`FooSniffTest` → `FooSniff`), Fixtures liegen in `fixture/`. +- `tests/Brainbits/input/` + `tests/Brainbits/expected_report.txt` — Integrationstest des + gesamten Rulesets: PHPCS läuft über alle Input-Dateien, die Summary muss exakt dem + erwarteten Report entsprechen. Dateien ohne Verstöße tauchen in der Summary nicht auf + (z. B. `ChangedFromDoctrineCodingStandard.php`, das die ausgeschlossenen Regeln belegt). +- `phpcs.xml.dist` — prüft den Code dieses Repos (`Brainbits/`, `tests/` ohne `input/` und + `fixture/`) mit dem eigenen Standard; läuft auch in CI. + +## Befehle + +```bash +composer install + +# Unit-Tests der Sniffs +vendor/bin/phpunit + +# Integrationstest des Rulesets (wie in CI) +vendor/bin/phpcs --basepath=. --report=summary --report-file=phpcs.log --standard=Brainbits `find tests/Brainbits/input/* | sort`; diff -u tests/Brainbits/expected_report.txt phpcs.log + +# Code des Repos selbst prüfen +vendor/bin/phpcs +``` + +## Arbeitsweise + +- Regel ändern (ausschließen/hinzufügen): `Brainbits/ruleset.xml` anpassen, passende + Input-Datei unter `tests/Brainbits/input/` ergänzen und `expected_report.txt` + aktualisieren — den neuen Report erzeugen und den Diff bewusst prüfen, nicht blind + übernehmen. Input-Dateien müssen gültiges PHP sein, sonst prüfen sie nichts. Für + Ausschlüsse gegenprüfen, dass `--standard=Doctrine` den Verstoß in der Datei meldet. +- Neuer Sniff: Klasse unter `Brainbits/Sniffs//Sniff.php`, Test unter + `tests/Brainbits/Sniffs//SniffTest.php` mit Fixtures in `fixture/`. + Fehlercodes als öffentliche Konstante `CODE_*` definieren. +- Code muss den eigenen Standard erfüllen (`vendor/bin/phpcs`), inkl. + `declare(strict_types=1);`, `self::assert…` in Tests und importierter Funktionen/Konstanten (`use function`, + `use const`). +- Abhängigkeits-Upgrades (PHPCS, Doctrine, Slevomat) können den Integrationsreport ändern; + nach einem Upgrade immer beide Testarten laufen lassen. CI testet auch `--prefer-lowest`, + Versionsconstraints also nicht höher setzen als nötig, aber auch nicht so niedrig, dass + die niedrigste Version die Tests bricht. +- Commits im Stil Conventional Commits (`fix:`, `fix(deps):`, `chore:`). +- Branch für Entwicklung ist `main` (CI läuft auf Push nach `main` und auf PRs). Ältere + Versionszweige: `1.0`, `3.0`. `main` ist geschützt: nur per PR, alle CI-Jobs + `Tests (…)` sind Pflicht-Checks. Ändert sich die PHP-Matrix, müssen die Pflicht-Checks in + der Branch-Protection angepasst werden. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a1bbca8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017-2026 brainbits GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f130e9b..e5b17f8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,38 @@ +brainbits Coding Standard +========================= + +[PHP_CodeSniffer](https://github.com/PHPCSStandards/PHP_CodeSniffer) ruleset used at brainbits. +It is based on the [Doctrine Coding Standard](https://github.com/doctrine/coding-standard) +(which includes the [Slevomat Coding Standard](https://github.com/slevomat/coding-standard)), +relaxes a few of its rules and adds a custom sniff. + +Requires PHP 8.4 or newer and PHP_CodeSniffer 4. The name of the standard is `Brainbits`. + +Differences to Doctrine +======================= + +Disabled rules: + +- `Generic.Formatting.MultipleStatementAlignment.NotSame` – assignments don't need to be aligned +- `Generic.Formatting.SpaceAfterNot.Incorrect` – no space required after `!` +- `Squiz.Arrays.ArrayDeclaration.MultiLineNotAllowed` – multi-line arrays with a single item are allowed + +Additional rules: + +- `Brainbits.Exception.GlobalException` – forbids `throw new` with PHP's built-in global + exceptions (`\Exception`, `\ErrorException` and the SPL exceptions like `\RuntimeException`, + `\InvalidArgumentException`, …). Only direct `throw new …` statements are checked. + Extend the exceptions in your own namespace instead: + + ```php + // error + throw new \RuntimeException('Not found'); + + // ok + final class NotFound extends \RuntimeException {} + throw new NotFound('Not found'); + ``` + How to Use ========== @@ -11,7 +46,7 @@ Global ``` 3. Configure standard: ```bash - {install-dir}/vendor/bin/phpcs --config-set default_standard BrainbitsCodingStandard + {install-dir}/vendor/bin/phpcs --config-set default_standard Brainbits ``` 4. Execute: @@ -28,7 +63,7 @@ In Project ``` 2. Configure standard: ```bash - vendor/bin/phpcs --config-set default_standard BrainbitsCodingStandard + vendor/bin/phpcs --config-set default_standard Brainbits ``` 3. Execute: ```bash @@ -42,10 +77,10 @@ In Project with Ruleset Customization ```bash composer require brainbits/phpcs-standard ``` -2. Create phpcs.xml (See https://github.com/squizlabs/PHP_CodeSniffer/wiki/Advanced-Usage) +2. Create phpcs.xml (see https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki/Advanced-Usage) 1. Include brainbits ruleset: ```xml - + ``` 2. Add default src-dir: ```xml @@ -57,12 +92,22 @@ In Project with Ruleset Customization vendor/bin/phpcs ``` -Docker ------- - -1. docker run -it --rm -v $PWD:/app brainbits/phpcs-standard {src-dir} - Used Code Styles ================ - Slevomat https://github.com/slevomat/coding-standard - Doctrine https://github.com/doctrine/coding-standard + +Development +=========== + +```bash +composer install +vendor/bin/phpunit # unit tests of the custom sniffs +vendor/bin/phpcs # check this repository with its own standard +vendor/bin/phpcs --basepath=. --report=summary --report-file=phpcs.log --standard=Brainbits `find tests/Brainbits/input/* | sort`; diff -u tests/Brainbits/expected_report.txt phpcs.log # ruleset integration test +``` + +License +======= + +MIT, see [LICENSE](LICENSE). diff --git a/composer.json b/composer.json index e5361e3..0c25a88 100644 --- a/composer.json +++ b/composer.json @@ -18,21 +18,14 @@ } }, "require": { + "php": "^8.4", "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "doctrine/coding-standard": "^14.0", - "slevomat/coding-standard": "^8.14", + "slevomat/coding-standard": "^8.23", "squizlabs/php_codesniffer": "^4.0" }, "require-dev": { - "phpunit/phpunit": "^12.0.5" - }, - "scripts": { - "tests": [ - "vendor/bin/phpunit", - "vendor/bin/phpcs --basepath=. --report=summary --report-file=phpcs.log --standard=Brainbits `find tests/Brainbits/input/* | sort`; diff -u tests/Brainbits/expected_report.txt phpcs.log", - "vendor/bin/phpcs --basepath=. --report=summary --report-file=phpcs.log --standard=BrainbitsCodingStandard `find tests/BrainbitsCodingStandard/input/* | sort`; diff -u tests/BrainbitsCodingStandard/expected_report.txt phpcs.log", - "vendor/bin/phpcs --basepath=. --report=summary --report-file=phpcs.log --standard=BrainbitsCodingStandardTest `find tests/BrainbitsCodingStandardTest/input/* | sort`; diff -u tests/BrainbitsCodingStandardTest/expected_report.txt phpcs.log" - ] + "phpunit/phpunit": "^13.4" }, "config": { "allow-plugins": { diff --git a/docs/test1.php b/docs/test1.php deleted file mode 100644 index 82bfbf4..0000000 --- a/docs/test1.php +++ /dev/null @@ -1,19 +0,0 @@ - diff --git a/example/DuplicateClassName.php b/example/DuplicateClassName.php deleted file mode 100644 index 1a1dd5b..0000000 --- a/example/DuplicateClassName.php +++ /dev/null @@ -1,9 +0,0 @@ - - + The brainbits coding standard. Brainbits + tests + + tests/*/input/* + tests/*/fixture/* diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4777f3f..aa42e65 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,5 @@ - - - + tests/Brainbits/Sniffs diff --git a/renovate.json b/renovate.json deleted file mode 100644 index 5db72dd..0000000 --- a/renovate.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended" - ] -} diff --git a/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php b/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php index 75be6ac..ad1ec31 100644 --- a/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php +++ b/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php @@ -1,67 +1,133 @@ checkFile(__DIR__.'/fixture/NoNamespace.php'); + $report = self::checkFile(__DIR__ . '/fixture/NoNamespace.php'); - $this->assertNoSniffErrorInFile($report); + self::assertNoSniffErrorInFile($report); } - public function testNoException() + public function testNoException(): void { - $report = $this->checkFile(__DIR__.'/fixture/NoException.php'); + $report = self::checkFile(__DIR__ . '/fixture/NoException.php'); - $this->assertNoSniffErrorInFile($report); + self::assertNoSniffErrorInFile($report); } - public function testExceptionInAnnotation() + public function testExceptionInAnnotation(): void { - $report = $this->checkFile(__DIR__.'/fixture/ExceptionInAnnotation.php'); + $report = self::checkFile(__DIR__ . '/fixture/ExceptionInAnnotation.php'); - $this->assertNoSniffErrorInFile($report); + self::assertNoSniffErrorInFile($report); } - public function testAllowedExceptions() + public function testAllowedExceptions(): void { - $report = $this->checkFile(__DIR__.'/fixture/AllowedExceptions.php'); + $report = self::checkFile(__DIR__ . '/fixture/AllowedExceptions.php'); - $this->assertNoSniffErrorInFile($report); + self::assertNoSniffErrorInFile($report); } - public function testExceptionInUseWithoutAlias() + public function testExceptionInUseWithoutAlias(): void { - $report = $this->checkFile(__DIR__.'/fixture/ExceptionInUseWithoutAlias.php'); + $report = self::checkFile(__DIR__ . '/fixture/ExceptionInUseWithoutAlias.php'); - $this->assertSame(3, $report->getErrorCount()); - $this->assertSniffError($report, 15, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION); - $this->assertSniffError($report, 20, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION); - $this->assertSniffError($report, 25, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION); + self::assertSame(3, $report->getErrorCount()); + self::assertSniffError($report, 15, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'LogicException'); + self::assertSniffError($report, 20, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'RuntimeException'); + self::assertSniffError($report, 25, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, '"Exception"'); } - public function testExceptionInUseWithAlias() + public function testExceptionInUseWithAlias(): void { - $report = $this->checkFile(__DIR__.'/fixture/ExceptionInUseWithAlias.php'); + $report = self::checkFile(__DIR__ . '/fixture/ExceptionInUseWithAlias.php'); - $this->assertSame(2, $report->getErrorCount()); - $this->assertSniffError($report, 14, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION); - $this->assertSniffError($report, 19, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION); + self::assertSame(2, $report->getErrorCount()); + self::assertSniffError($report, 14, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, '"Exception"'); + self::assertSniffError($report, 19, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'RuntimeException'); } - public function testFullQualifiedException() + public function testFullQualifiedException(): void { - $report = $this->checkFile(__DIR__.'/fixture/FullQualifiedException.php'); + $report = self::checkFile(__DIR__ . '/fixture/FullQualifiedException.php'); - $this->assertSame(3, $report->getErrorCount()); - $this->assertSniffError($report, 11, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION); - $this->assertSniffError($report, 16, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION); - $this->assertSniffError($report, 21, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION); + self::assertSame(3, $report->getErrorCount()); + self::assertSniffError($report, 11, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, '"Exception"'); + self::assertSniffError($report, 16, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'InvalidArgumentException'); + self::assertSniffError($report, 21, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'RuntimeException'); } -} \ No newline at end of file + + public function testUnqualifiedExceptionInGlobalNamespace(): void + { + $report = self::checkFile(__DIR__ . '/fixture/GlobalNamespace.php'); + + self::assertSame(1, $report->getErrorCount()); + self::assertSniffError($report, 9, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, '"Exception"'); + } + + public function testThrowWithoutNewIsIgnored(): void + { + $report = self::checkFile(__DIR__ . '/fixture/ThrowWithoutNew.php'); + + self::assertNoSniffErrorInFile($report); + } + + public function testNamespacedNames(): void + { + $report = self::checkFile(__DIR__ . '/fixture/NamespacedNames.php'); + + self::assertSame(2, $report->getErrorCount()); + self::assertSniffError($report, 35, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'runtimeexception'); + self::assertSniffError($report, 40, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'LogicException'); + } + + public function testMultipleNamespacesBracketed(): void + { + $report = self::checkFile(__DIR__ . '/fixture/MultipleNamespacesBracketed.php'); + + self::assertSame(3, $report->getErrorCount()); + self::assertSniffError($report, 8, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, '"Exception"'); + self::assertSniffError($report, 16, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'LogicException'); + self::assertSniffError($report, 17, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'RuntimeException'); + } + + public function testMultipleNamespacesUnbracketed(): void + { + $report = self::checkFile(__DIR__ . '/fixture/MultipleNamespacesUnbracketed.php'); + + self::assertSame(2, $report->getErrorCount()); + self::assertSniffError($report, 9, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, '"Exception"'); + self::assertSniffError($report, 16, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'RuntimeException'); + } + + public function testGroupUseAndFunctionOrConstImports(): void + { + $report = self::checkFile(__DIR__ . '/fixture/GroupUse.php'); + + self::assertSame(2, $report->getErrorCount()); + self::assertSniffError($report, 17, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'LogicException'); + self::assertSniffError($report, 18, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'DomainException'); + } + + public function testThrowExpressions(): void + { + $report = self::checkFile(__DIR__ . '/fixture/ThrowExpressions.php'); + + self::assertSame(7, $report->getErrorCount()); + self::assertSniffError($report, 13, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'LogicException'); + self::assertSniffError($report, 14, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'RuntimeException'); + self::assertSniffError($report, 15, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'UnexpectedValueException'); + self::assertSniffError($report, 18, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'DomainException'); + self::assertSniffError($report, 20, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, '"Exception"'); + self::assertSniffError($report, 21, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, '"Exception"'); + self::assertSniffError($report, 22, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, '"Exception"'); + } +} diff --git a/tests/Brainbits/Sniffs/Exception/fixture/GlobalNamespace.php b/tests/Brainbits/Sniffs/Exception/fixture/GlobalNamespace.php new file mode 100644 index 0000000..bb5de0d --- /dev/null +++ b/tests/Brainbits/Sniffs/Exception/fixture/GlobalNamespace.php @@ -0,0 +1,16 @@ + throw new \LogicException(); + } +} diff --git a/tests/Brainbits/Sniffs/Exception/fixture/ThrowExpressions.php b/tests/Brainbits/Sniffs/Exception/fixture/ThrowExpressions.php new file mode 100644 index 0000000..b30f3b4 --- /dev/null +++ b/tests/Brainbits/Sniffs/Exception/fixture/ThrowExpressions.php @@ -0,0 +1,32 @@ + 'one', + default => throw new \DomainException(), + }; + throw /* comment */ new /* comment */ \Exception(); + throw new foo(); + throw new \Exception; + } + + public function ignored(): void + { + throw new class extends \Exception { + }; + throw new static(); + throw \RuntimeException::create(); + } +} diff --git a/tests/Brainbits/Sniffs/Exception/fixture/ThrowWithoutNew.php b/tests/Brainbits/Sniffs/Exception/fixture/ThrowWithoutNew.php new file mode 100644 index 0000000..3a88117 --- /dev/null +++ b/tests/Brainbits/Sniffs/Exception/fixture/ThrowWithoutNew.php @@ -0,0 +1,28 @@ +createException(); + } + + public function dynamic(string $class): void + { + throw new $class(); + } +} + +$x = static fn ($e) => throw $e; +$y = RuntimeException::class; diff --git a/tests/Brainbits/input/ChangedFromDoctrineCodingStandard.php b/tests/Brainbits/input/ChangedFromDoctrineCodingStandard.php index f8ac257..59626ec 100644 --- a/tests/Brainbits/input/ChangedFromDoctrineCodingStandard.php +++ b/tests/Brainbits/input/ChangedFromDoctrineCodingStandard.php @@ -9,17 +9,32 @@ class ChangedFromDoctrineCodingStandard /** * Generic.Formatting.MultipleStatementAlignment.NotSame */ - public static FOO = 1; - public static SOME_CONSTANT = 1; - public static AND_ANOTHER = 1; + public function alignment(): int + { + $foo = 1; + $someVariable = 2; + $andAnother = 3; + + return $foo + $someVariable + $andAnother; + } /** * Generic.Formatting.SpaceAfterNot.Incorrect */ - public function test(): void + public function spaceAfterNot(bool $flag): bool + { + return !$flag; + } + + /** + * Squiz.Arrays.ArrayDeclaration.MultiLineNotAllowed + * + * @return list + */ + public function multiLineArrayWithSingleItem(): array { - if (!true) { - return; - } + return [ + 'a', + ]; } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index d149dfa..16fc4c7 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,8 +1,8 @@