From 113f5c58f737a1e5114a230c0f7ac1cdaa333397 Mon Sep 17 00:00:00 2001 From: Stephan Wentz Date: Tue, 3 Nov 2026 17:10:40 +0100 Subject: [PATCH 01/11] docs: Add CLAUDE.md Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..073a49d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# 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. + +- Abhängigkeiten: `squizlabs/php_codesniffer` ^4, `doctrine/coding-standard` ^14, + `slevomat/coding-standard` ^8.14, Dev: PHPUnit. +- CI (`.github/workflows/build.yml`): PHP 8.3 und 8.4, 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. +- `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` von globalen SPL-Exceptions + (`\RuntimeException`, `\InvalidArgumentException`, …), egal ob voll qualifiziert oder + per `use` (auch mit Alias) importiert. Code: `Brainbits.Exception.GlobalException`. + Nutzt die Helper aus Slevomat (`TokenHelper`, `UseStatementHelper`). +- `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/`) mit dem eigenen Standard. +- `example/`, `docs/` — lose Beispieldateien, nicht Teil von Tests oder 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 +``` + +`composer tests` ist **nicht** verwendbar: das Script verweist zusätzlich auf +`tests/BrainbitsCodingStandard*`, die es nicht mehr gibt. Stattdessen die Befehle oben +einzeln ausführen. + +## 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. +- 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);` 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`, `2.0`, `3.0`; `master` ist veraltet. + +## Bekannte Baustellen + +- `Brainbits/ruleset.xml`: `` + ist ein Tippfehler (`rul` statt `rule`) — die Regel wird dadurch stillschweigend nicht + angewendet. +- `README.md` nennt den Standard `BrainbitsCodingStandard`; korrekt ist `Brainbits`. Der dort + beschriebene Docker-Weg hat im Repo kein Dockerfile. +- `phpunit.xml.dist` verweist noch auf das Schema von PHPUnit 10.1. +- `CHANGELOG.md` endet bei 1.0.3 und wird nicht mehr gepflegt. From b071693581c8571d1468d08aa8d25f2c38d165f5 Mon Sep 17 00:00:00 2001 From: Stephan Wentz Date: Tue, 3 Nov 2026 17:13:35 +0100 Subject: [PATCH 02/11] fix: Fix ruleset typo, require PHP 8.4, clean up project files - Fix `` typo in ruleset so DNFTypeHintFormat is actually applied - Require PHP ^8.4 and PHPUnit ^13.4, test PHP 8.4 and 8.5 in CI - Remove broken `composer tests` script - Update PHPUnit config schema to 13.4 - Use correct standard name `Brainbits` in README, drop Docker section - Remove unmaintained CHANGELOG.md - Update CLAUDE.md accordingly Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 2 +- Brainbits/ruleset.xml | 2 +- CHANGELOG.md | 17 ----------------- CLAUDE.md | 20 +++----------------- README.md | 11 +++-------- composer.json | 11 ++--------- phpunit.xml.dist | 2 +- 7 files changed, 11 insertions(+), 54 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bd8c97c..9696e2b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,8 +18,8 @@ jobs: matrix: dependencies: ["lowest", "highest"] php-version: - - "8.3" - "8.4" + - "8.5" operating-system: ["ubuntu-latest"] steps: diff --git a/Brainbits/ruleset.xml b/Brainbits/ruleset.xml index cc4d0ae..966ea0e 100755 --- a/Brainbits/ruleset.xml +++ b/Brainbits/ruleset.xml @@ -8,5 +8,5 @@ - + 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 index 073a49d..bd74ee7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,9 +8,9 @@ Slevomat einbindet), schaltet einige Doctrine-Regeln ab und ergänzt einen eigen Installiert wird er über `dealerdirect/phpcodesniffer-composer-installer`, der das Verzeichnis `Brainbits/` als Standard registriert. -- Abhängigkeiten: `squizlabs/php_codesniffer` ^4, `doctrine/coding-standard` ^14, - `slevomat/coding-standard` ^8.14, Dev: PHPUnit. -- CI (`.github/workflows/build.yml`): PHP 8.3 und 8.4, jeweils mit `lowest`- und +- PHP ^8.4. Abhängigkeiten: `squizlabs/php_codesniffer` ^4, `doctrine/coding-standard` ^14, + `slevomat/coding-standard` ^8.14, Dev: PHPUnit ^13.4. +- CI (`.github/workflows/build.yml`): PHP 8.4 und 8.5, jeweils mit `lowest`- und `highest`-Abhängigkeiten. ## Aufbau @@ -49,10 +49,6 @@ vendor/bin/phpcs --basepath=. --report=summary --report-file=phpcs.log --standar vendor/bin/phpcs ``` -`composer tests` ist **nicht** verwendbar: das Script verweist zusätzlich auf -`tests/BrainbitsCodingStandard*`, die es nicht mehr gibt. Stattdessen die Befehle oben -einzeln ausführen. - ## Arbeitsweise - Regel ändern (ausschließen/hinzufügen): `Brainbits/ruleset.xml` anpassen, passende @@ -72,13 +68,3 @@ einzeln ausführen. - 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`, `2.0`, `3.0`; `master` ist veraltet. - -## Bekannte Baustellen - -- `Brainbits/ruleset.xml`: `` - ist ein Tippfehler (`rul` statt `rule`) — die Regel wird dadurch stillschweigend nicht - angewendet. -- `README.md` nennt den Standard `BrainbitsCodingStandard`; korrekt ist `Brainbits`. Der dort - beschriebene Docker-Weg hat im Repo kein Dockerfile. -- `phpunit.xml.dist` verweist noch auf das Schema von PHPUnit 10.1. -- `CHANGELOG.md` endet bei 1.0.3 und wird nicht mehr gepflegt. diff --git a/README.md b/README.md index f130e9b..7c167e9 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,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 +28,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 @@ -45,7 +45,7 @@ In Project with Ruleset Customization 2. Create phpcs.xml (See https://github.com/squizlabs/PHP_CodeSniffer/wiki/Advanced-Usage) 1. Include brainbits ruleset: ```xml - + ``` 2. Add default src-dir: ```xml @@ -57,11 +57,6 @@ 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 diff --git a/composer.json b/composer.json index e5361e3..24bcd8c 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", "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/phpunit.xml.dist b/phpunit.xml.dist index 4777f3f..aa74cbf 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,6 +1,6 @@ - + From 414dfe468b93a864afa6ae577fcb866c840feaf4 Mon Sep 17 00:00:00 2001 From: Stephan Wentz Date: Tue, 3 Nov 2026 17:17:59 +0100 Subject: [PATCH 03/11] ci: Add composer to dependabot, remove renovate - Watch composer dependencies via Dependabot (major bumps only) - Group GitHub Actions updates into one PR - Remove unused renovate.json Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 24 ++++++++++++++++++++---- renovate.json | 6 ------ 2 files changed, 20 insertions(+), 10 deletions(-) delete mode 100644 renovate.json 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/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" - ] -} From b010deaacd9a28d8e9e35758691801541f8c37f2 Mon Sep 17 00:00:00 2001 From: Stephan Wentz Date: Tue, 3 Nov 2026 17:25:38 +0100 Subject: [PATCH 04/11] fix: Resolve class names properly in GlobalExceptionSniff - Register on T_THROW and only inspect `throw new `, so `throw $e` no longer reports an unrelated class name further down the file - Resolve names via namespace and use statements: unqualified names in the global namespace, qualified and relative names are handled correctly - Compare class names case-insensitively - Report on the class name and use the resolved name in the message - Use typed constants and native parameter types (PHP 8.4) Co-Authored-By: Claude Opus 5.5 --- .../Sniffs/Exception/GlobalExceptionSniff.php | 168 ++++++++---------- .../Exception/GlobalExceptionSniffTest.php | 88 +++++---- .../Exception/fixture/GlobalNamespace.php | 16 ++ .../Exception/fixture/NamespacedNames.php | 42 +++++ .../Exception/fixture/ThrowWithoutNew.php | 28 +++ 5 files changed, 215 insertions(+), 127 deletions(-) create mode 100644 tests/Brainbits/Sniffs/Exception/fixture/GlobalNamespace.php create mode 100644 tests/Brainbits/Sniffs/Exception/fixture/NamespacedNames.php create mode 100644 tests/Brainbits/Sniffs/Exception/fixture/ThrowWithoutNew.php diff --git a/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php b/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php index a4da6cb..5798c9a 100644 --- a/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php +++ b/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php @@ -6,120 +6,98 @@ use PHP_CodeSniffer\Files\File; use PHP_CodeSniffer\Sniffs\Sniff; +use SlevomatCodingStandard\Helpers\NamespaceHelper; use SlevomatCodingStandard\Helpers\TokenHelper; -use SlevomatCodingStandard\Helpers\UseStatementHelper; -use function defined; use function in_array; +use function ltrim; use function sprintf; +use function str_starts_with; +use function strlen; +use function strtolower; use function substr; -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_NEW; use const T_STRING; use const T_THROW; -// phpcs:disable SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint -// phpcs:disable SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingNativeTypeHint - /** - * Global exception sniff + * Forbids throwing global SPL exceptions, they should be extended locally. */ class GlobalExceptionSniff implements Sniff { - // phpcs:ignore SlevomatCodingStandard.TypeHints.ClassConstantTypeHint.MissingNativeTypeHint - public const CODE_GLOBAL_EXCEPTION = 'GlobalException'; - - /** @return mixed[] */ + 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 list */ public function register(): array { - return [T_OPEN_TAG]; + return [T_THROW]; } - /** @param int $openTagPointer */ - public function process(File $phpcsFile, $openTagPointer): void + public function process(File $phpcsFile, int $stackPtr): void { - $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]; + $newPointer = TokenHelper::findNextEffective($phpcsFile, $stackPtr + 1); + if ($newPointer === null || $phpcsFile->getTokens()[$newPointer]['code'] !== T_NEW) { + // e.g. `throw $exception;` or `throw $this->createException();` + return; + } + + $classPointer = TokenHelper::findNextEffective($phpcsFile, $newPointer + 1); + $nameTokens = [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NAME_RELATIVE]; + if ($classPointer === null || !in_array($phpcsFile->getTokens()[$classPointer]['code'], $nameTokens, 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), + $classPointer, + 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 (str_starts_with(strtolower($name), self::RELATIVE_PREFIX)) { + $namespace = NamespaceHelper::findCurrentNamespaceName($phpcsFile, $classPointer); + $name = substr($name, strlen(self::RELATIVE_PREFIX)); + + return $namespace === null ? $name : $namespace . '\\' . $name; } - do { - $currentPointer = TokenHelper::findNext($phpcsFile, T_THROW, $currentPointer + 1); - if (!$currentPointer) { - break; - } - - $classStartPointer = TokenHelper::findNext($phpcsFile, $classTokens, $currentPointer + 1); - if (!$classStartPointer) { - continue; - } - - $classEndPointer = TokenHelper::findNextExcluding( - $phpcsFile, - $classTokens, - $classStartPointer + 1, - ) - 1; - if (!$classEndPointer) { - 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, - ); - } - } - } - } - } - - $currentPointer = $classEndPointer + 1; - } while ($currentPointer); + return ltrim(NamespaceHelper::resolveClassName($phpcsFile, $name, $classPointer), '\\'); } } diff --git a/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php b/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php index 75be6ac..393df1a 100644 --- a/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php +++ b/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php @@ -1,67 +1,91 @@ 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'); + } +} 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/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; From 4e4ca6fdbdbea8865bc79c7d3d40c7f620533953 Mon Sep 17 00:00:00 2001 From: Stephan Wentz Date: Tue, 3 Nov 2026 17:25:38 +0100 Subject: [PATCH 05/11] fix: Make ruleset integration test meaningful, drop dead ruleset entries - ChangedFromDoctrineCodingStandard.php was not valid PHP and therefore did not test anything; it now covers all excluded Doctrine rules - Remove exclusion of UnionTypeHintFormat (no longer used by Doctrine 14) and the DNFTypeHintFormat reference (already configured by Doctrine) Co-Authored-By: Claude Opus 5.5 --- Brainbits/ruleset.xml | 2 -- .../ChangedFromDoctrineCodingStandard.php | 29 ++++++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/Brainbits/ruleset.xml b/Brainbits/ruleset.xml index 966ea0e..3d32c38 100755 --- a/Brainbits/ruleset.xml +++ b/Brainbits/ruleset.xml @@ -5,8 +5,6 @@ - - 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', + ]; } } From 02d605e360901eb77a08947d2f7bdc0794de845a Mon Sep 17 00:00:00 2001 From: Stephan Wentz Date: Tue, 3 Nov 2026 17:25:38 +0100 Subject: [PATCH 06/11] chore: Check tests with own standard, tidy up tooling - Include tests/ in phpcs.xml.dist (without fixtures) and run it in CI - Require slevomat/coding-standard ^8.23 (minimum of doctrine 14) - Drop no-op --no-suggest, ignore .phpunit.cache, clean PHPUnit config - Update CLAUDE.md Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 8 ++++++-- .gitignore | 1 + CLAUDE.md | 24 +++++++++++++++--------- composer.json | 2 +- phpcs.xml.dist | 6 +++++- phpunit.xml.dist | 2 -- tests/bootstrap.php | 4 ++-- 7 files changed, 30 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9696e2b..a88ce76 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,11 +42,11 @@ jobs: - 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 +54,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/CLAUDE.md b/CLAUDE.md index bd74ee7..cc70db9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,21 +9,25 @@ Installiert wird er über `dealerdirect/phpcodesniffer-composer-installer`, der Verzeichnis `Brainbits/` als Standard registriert. - PHP ^8.4. Abhängigkeiten: `squizlabs/php_codesniffer` ^4, `doctrine/coding-standard` ^14, - `slevomat/coding-standard` ^8.14, Dev: PHPUnit ^13.4. + `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. + 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` von globalen SPL-Exceptions - (`\RuntimeException`, `\InvalidArgumentException`, …), egal ob voll qualifiziert oder - per `use` (auch mit Alias) importiert. Code: `Brainbits.Exception.GlobalException`. - Nutzt die Helper aus Slevomat (`TokenHelper`, `UseStatementHelper`). + - `Exception/GlobalExceptionSniff.php` — meldet `throw new` mit globalen SPL-Exceptions + (`\RuntimeException`, `\InvalidArgumentException`, …). Der Klassenname wird über + Namespace und `use`-Statements aufgelöst (voll qualifiziert, importiert, Alias, + unqualifiziert im globalen Namespace, `namespace\…`), Vergleich case-insensitive. + `throw $e` u. ä. wird ignoriert. Vollständiger Fehlercode: + `Brainbits.Exception.GlobalException.GlobalException`. Nutzt Slevomat-Helper + (`TokenHelper`, `NamespaceHelper`). - `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/`. @@ -31,7 +35,8 @@ Verzeichnis `Brainbits/` als Standard registriert. 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/`) mit dem eigenen Standard. +- `phpcs.xml.dist` — prüft den Code dieses Repos (`Brainbits/`, `tests/` ohne `input/` und + `fixture/`) mit dem eigenen Standard; läuft auch in CI. - `example/`, `docs/` — lose Beispieldateien, nicht Teil von Tests oder CI. ## Befehle @@ -54,12 +59,13 @@ vendor/bin/phpcs - 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. + ü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);` und importierter Funktionen/Konstanten (`use function`, + `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`, diff --git a/composer.json b/composer.json index 24bcd8c..0c25a88 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "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": { diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 57e5347..2bf16b4 100755 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -1,8 +1,12 @@ - + The brainbits coding standard. Brainbits + tests + + tests/*/input/* + tests/*/fixture/* diff --git a/phpunit.xml.dist b/phpunit.xml.dist index aa74cbf..aa42e65 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,5 @@ - - tests/Brainbits/Sniffs 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 @@ Date: Tue, 3 Nov 2026 17:28:49 +0100 Subject: [PATCH 07/11] docs: Add LICENSE, document rules in README, remove outdated examples - Add MIT license text (composer.json already declares MIT) - README: describe the standard, differences to Doctrine, the custom GlobalException sniff and the development workflow - Remove unused example/ and docs/ files from 2017/18 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 - LICENSE | 21 +++++++++++++ README.md | 51 ++++++++++++++++++++++++++++++- docs/test1.php | 19 ------------ docs/test2.php | 27 ---------------- docs/test3.php | 22 ------------- example/AbstractSuperfluous.php | 9 ------ example/ArrayTest.php | 28 ----------------- example/BlankLineBeforeReturn.php | 16 ---------- example/ClosingTag.php | 15 --------- example/DuplicateClassName.php | 9 ------ example/EmptyFunction.php | 13 -------- example/EmptyStatement.php | 24 --------------- example/MultipleClassesA.php | 21 ------------- example/SuperfluousInterface.php | 9 ------ example/UnusedParameter.php | 12 -------- 16 files changed, 71 insertions(+), 226 deletions(-) create mode 100644 LICENSE delete mode 100644 docs/test1.php delete mode 100644 docs/test2.php delete mode 100644 docs/test3.php delete mode 100644 example/AbstractSuperfluous.php delete mode 100644 example/ArrayTest.php delete mode 100644 example/BlankLineBeforeReturn.php delete mode 100644 example/ClosingTag.php delete mode 100644 example/DuplicateClassName.php delete mode 100644 example/EmptyFunction.php delete mode 100644 example/EmptyStatement.php delete mode 100644 example/MultipleClassesA.php delete mode 100644 example/SuperfluousInterface.php delete mode 100644 example/UnusedParameter.php diff --git a/CLAUDE.md b/CLAUDE.md index cc70db9..40c574d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,6 @@ Verzeichnis `Brainbits/` als Standard registriert. (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. -- `example/`, `docs/` — lose Beispieldateien, nicht Teil von Tests oder CI. ## Befehle 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 7c167e9..23ea142 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,37 @@ +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 throwing global SPL exceptions + (`\Exception`, `\RuntimeException`, `\InvalidArgumentException`, …). + Extend them 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 ========== @@ -42,7 +76,7 @@ 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 @@ -61,3 +95,18 @@ 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/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 @@ - Date: Tue, 3 Nov 2026 17:48:27 +0100 Subject: [PATCH 08/11] fix: Resolve imports per namespace in GlobalExceptionSniff Review findings on the Slevomat based name resolution: - Files with several namespaces used the imports of another namespace - Code inside `namespace { }` was resolved against a "{" namespace - Group uses were ignored and resolved against the global namespace - `throw (new Exception())` was not reported anymore Imports are now collected per namespace block, including group uses and skipping function/const imports. Parentheses after throw are skipped. Errors are reported on the throw token again, as before. Co-Authored-By: Claude Opus 5.5 --- .../Sniffs/Exception/GlobalExceptionSniff.php | 209 +++++++++++++++++- .../Exception/GlobalExceptionSniffTest.php | 42 ++++ .../Sniffs/Exception/fixture/GroupUse.php | 13 ++ .../fixture/MultipleNamespacesBracketed.php | 19 ++ .../fixture/MultipleNamespacesUnbracketed.php | 16 ++ .../Exception/fixture/ThrowExpressions.php | 32 +++ 6 files changed, 319 insertions(+), 12 deletions(-) create mode 100644 tests/Brainbits/Sniffs/Exception/fixture/GroupUse.php create mode 100644 tests/Brainbits/Sniffs/Exception/fixture/MultipleNamespacesBracketed.php create mode 100644 tests/Brainbits/Sniffs/Exception/fixture/MultipleNamespacesUnbracketed.php create mode 100644 tests/Brainbits/Sniffs/Exception/fixture/ThrowExpressions.php diff --git a/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php b/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php index 5798c9a..4cd83b3 100644 --- a/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php +++ b/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php @@ -6,26 +6,39 @@ use PHP_CodeSniffer\Files\File; use PHP_CodeSniffer\Sniffs\Sniff; -use SlevomatCodingStandard\Helpers\NamespaceHelper; use SlevomatCodingStandard\Helpers\TokenHelper; +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 str_starts_with; use function strlen; +use function strpos; +use function strrpos; use function strtolower; use function substr; +use function trim; +use const T_COMMENT; +use const T_DOC_COMMENT; 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; +use const T_USE; +use const T_WHITESPACE; /** - * Forbids throwing global SPL exceptions, they should be extended locally. + * Forbids throwing the built-in global exceptions, they should be extended locally. */ class GlobalExceptionSniff implements Sniff { @@ -51,6 +64,8 @@ class GlobalExceptionSniff implements Sniff private const string RELATIVE_PREFIX = 'namespace\\'; + private const array NAME_TOKENS = [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NAME_RELATIVE]; + /** @return list */ public function register(): array { @@ -59,15 +74,21 @@ public function register(): array public function process(File $phpcsFile, int $stackPtr): void { + $tokens = $phpcsFile->getTokens(); + + // skip parentheses, e.g. `throw (new Exception());` $newPointer = TokenHelper::findNextEffective($phpcsFile, $stackPtr + 1); - if ($newPointer === null || $phpcsFile->getTokens()[$newPointer]['code'] !== T_NEW) { + 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); - $nameTokens = [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NAME_RELATIVE]; - if ($classPointer === null || !in_array($phpcsFile->getTokens()[$classPointer]['code'], $nameTokens, true)) { + if ($classPointer === null || !in_array($tokens[$classPointer]['code'], self::NAME_TOKENS, true)) { // e.g. `throw new $class();` or `throw new class extends Foo {};` return; } @@ -79,7 +100,7 @@ public function process(File $phpcsFile, int $stackPtr): void $phpcsFile->addError( sprintf('Global exception "%s" used. It should be locally extended.', $className), - $classPointer, + $stackPtr, self::CODE_GLOBAL_EXCEPTION, ); } @@ -91,13 +112,177 @@ private function resolveClassName(File $phpcsFile, int $classPointer): string { $name = $phpcsFile->getTokens()[$classPointer]['content']; - if (str_starts_with(strtolower($name), self::RELATIVE_PREFIX)) { - $namespace = NamespaceHelper::findCurrentNamespaceName($phpcsFile, $classPointer); - $name = substr($name, strlen(self::RELATIVE_PREFIX)); + 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); + } + + /** + * 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 + { + $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 { + $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); - return $namespace === null ? $name : $namespace . '\\' . $name; + if ($namePointer === null || !in_array($tokens[$namePointer]['code'], [T_STRING, T_NAME_QUALIFIED], true)) { + // `namespace { ... }` is the global namespace + return ''; } - return ltrim(NamespaceHelper::resolveClassName($phpcsFile, $name, $classPointer), '\\'); + 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; + } + + if ($tokens[$usePointer]['conditions'] !== $expectedConditions) { + // trait use or import of another namespace + continue; + } + + $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 (in_array($tokens[$pointer]['code'], [T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + $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; + } + + $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/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php b/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php index 393df1a..2ca91a6 100644 --- a/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php +++ b/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php @@ -88,4 +88,46 @@ public function testNamespacedNames(): void 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, 12, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'LogicException'); + self::assertSniffError($report, 13, 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/GroupUse.php b/tests/Brainbits/Sniffs/Exception/fixture/GroupUse.php new file mode 100644 index 0000000..121a0f1 --- /dev/null +++ b/tests/Brainbits/Sniffs/Exception/fixture/GroupUse.php @@ -0,0 +1,13 @@ + '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(); + } +} From d86f71b6e8977f539973259041b4047558d619ab Mon Sep 17 00:00:00 2001 From: Stephan Wentz Date: Tue, 3 Nov 2026 17:48:27 +0100 Subject: [PATCH 09/11] ci: Fix composer cache, restrict token permissions - Cache the actual composer cache dir, ~/.composer/cache does not exist on the runner and nothing was cached - Separate cache keys for lowest and highest dependencies - Read-only GITHUB_TOKEN Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a88ce76..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" @@ -33,12 +36,16 @@ 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' }} From 8b7a221653d45012c0baec236198d2e9bbc15ebf Mon Sep 17 00:00:00 2001 From: Stephan Wentz Date: Tue, 3 Nov 2026 17:48:27 +0100 Subject: [PATCH 10/11] docs: Describe GlobalExceptionSniff precisely, update branch info Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 20 ++++++++++++-------- README.md | 7 ++++--- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 40c574d..61d22ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,13 +21,15 @@ Verzeichnis `Brainbits/` als Standard registriert. - `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 globalen SPL-Exceptions - (`\RuntimeException`, `\InvalidArgumentException`, …). Der Klassenname wird über - Namespace und `use`-Statements aufgelöst (voll qualifiziert, importiert, Alias, - unqualifiziert im globalen Namespace, `namespace\…`), Vergleich case-insensitive. - `throw $e` u. ä. wird ignoriert. Vollständiger Fehlercode: - `Brainbits.Exception.GlobalException.GlobalException`. Nutzt Slevomat-Helper - (`TokenHelper`, `NamespaceHelper`). + - `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/`. @@ -72,4 +74,6 @@ vendor/bin/phpcs 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`, `2.0`, `3.0`; `master` ist veraltet. + 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/README.md b/README.md index 23ea142..e5b17f8 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ Disabled rules: Additional rules: -- `Brainbits.Exception.GlobalException` – forbids throwing global SPL exceptions - (`\Exception`, `\RuntimeException`, `\InvalidArgumentException`, …). - Extend them in your own namespace instead: +- `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 From 321d54b6fc0923387a0a4056d3693c196d70f9da Mon Sep 17 00:00:00 2001 From: Stephan Wentz Date: Tue, 3 Nov 2026 17:56:45 +0100 Subject: [PATCH 11/11] fix: Ignore doc comments inside use statements in GlobalExceptionSniff Doc comments are tokenized as T_DOC_COMMENT_* tokens and ended up in the parsed import name, e.g. `use Foo\{ /** doc */ Exception };`. Co-Authored-By: Claude Opus 5.5 --- Brainbits/Sniffs/Exception/GlobalExceptionSniff.php | 5 ++--- .../Sniffs/Exception/GlobalExceptionSniffTest.php | 4 ++-- tests/Brainbits/Sniffs/Exception/fixture/GroupUse.php | 7 +++++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php b/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php index 4cd83b3..ca1e1de 100644 --- a/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php +++ b/Brainbits/Sniffs/Exception/GlobalExceptionSniff.php @@ -6,6 +6,7 @@ use PHP_CodeSniffer\Files\File; use PHP_CodeSniffer\Sniffs\Sniff; +use PHP_CodeSniffer\Util\Tokens; use SlevomatCodingStandard\Helpers\TokenHelper; use function explode; @@ -23,8 +24,6 @@ use function substr; use function trim; -use const T_COMMENT; -use const T_DOC_COMMENT; use const T_NAME_FULLY_QUALIFIED; use const T_NAME_QUALIFIED; use const T_NAME_RELATIVE; @@ -233,7 +232,7 @@ private function parseUseStatement(File $phpcsFile, int $startPointer, int $endP $tokens = $phpcsFile->getTokens(); $statement = ''; for ($pointer = $startPointer; $pointer <= $endPointer; $pointer++) { - if (in_array($tokens[$pointer]['code'], [T_COMMENT, T_DOC_COMMENT], true)) { + if (isset(Tokens::COMMENT_TOKENS[$tokens[$pointer]['code']])) { continue; } diff --git a/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php b/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php index 2ca91a6..ad1ec31 100644 --- a/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php +++ b/tests/Brainbits/Sniffs/Exception/GlobalExceptionSniffTest.php @@ -113,8 +113,8 @@ public function testGroupUseAndFunctionOrConstImports(): void $report = self::checkFile(__DIR__ . '/fixture/GroupUse.php'); self::assertSame(2, $report->getErrorCount()); - self::assertSniffError($report, 12, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'LogicException'); - self::assertSniffError($report, 13, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'DomainException'); + self::assertSniffError($report, 17, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'LogicException'); + self::assertSniffError($report, 18, GlobalExceptionSniff::CODE_GLOBAL_EXCEPTION, 'DomainException'); } public function testThrowExpressions(): void diff --git a/tests/Brainbits/Sniffs/Exception/fixture/GroupUse.php b/tests/Brainbits/Sniffs/Exception/fixture/GroupUse.php index 121a0f1..f1855b9 100644 --- a/tests/Brainbits/Sniffs/Exception/fixture/GroupUse.php +++ b/tests/Brainbits/Sniffs/Exception/fixture/GroupUse.php @@ -6,8 +6,15 @@ use Foo\{Bar as RuntimeException, function baz}; use function Foo\LogicException; use const Foo\DomainException; +use Baz\{ + /** doc comment */ + InvalidArgumentException, +}; +use Baz\LengthException /** doc comment */; throw new Exception(); throw new RuntimeException(); throw new LogicException(); throw new DomainException(); +throw new InvalidArgumentException(); +throw new LengthException();