From bc14489e2f6ecd7796f306b40d7d7de29ac0c97e Mon Sep 17 00:00:00 2001 From: brianvarskonst Date: Fri, 19 Jun 2026 03:44:14 +0200 Subject: [PATCH 1/2] feat: add class and file length sniffs --- README.md | 18 +- SymPress-Enterprise-LTS/ruleset.xml | 6 + SymPress-Enterprise-Modern/ruleset.xml | 6 + SymPress-Pure/ruleset.xml | 2 + SymPress/Sniffs/Classes/ClassLengthSniff.php | 177 +++++++++++++++++++ SymPress/Sniffs/Files/FileLengthSniff.php | 51 ++++++ docs/Rules.md | 2 + docs/Sniffs.md | 2 + tests/fixtures/class-length.php | 22 +++ tests/fixtures/file-length.php | 8 + 10 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 SymPress/Sniffs/Classes/ClassLengthSniff.php create mode 100644 SymPress/Sniffs/Files/FileLengthSniff.php create mode 100644 tests/fixtures/class-length.php create mode 100644 tests/fixtures/file-length.php diff --git a/README.md b/README.md index 0174b8c..45242cf 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The package is designed for projects that keep domain and application code frame The SymPress Coding Standards package requires: -- PHP 8.4 or newer to run the standard +- PHP 8.5 or newer to run the standard - Composer 2 - PHP_CodeSniffer 3.13.5 or newer on the 3.x line - PHPCompatibility 9.3 or 10 @@ -256,6 +256,22 @@ Example: change the maximum function length: ``` +Example: change class and file length guardrails: + +```xml + + + + + + + + + + + +``` + Example: configure PSR-4 checks: ```xml diff --git a/SymPress-Enterprise-LTS/ruleset.xml b/SymPress-Enterprise-LTS/ruleset.xml index 7c7350c..b63c01f 100644 --- a/SymPress-Enterprise-LTS/ruleset.xml +++ b/SymPress-Enterprise-LTS/ruleset.xml @@ -15,6 +15,9 @@ warning + + warning + warning @@ -24,6 +27,9 @@ warning + + warning + warning diff --git a/SymPress-Enterprise-Modern/ruleset.xml b/SymPress-Enterprise-Modern/ruleset.xml index 5e13ddd..95c071f 100644 --- a/SymPress-Enterprise-Modern/ruleset.xml +++ b/SymPress-Enterprise-Modern/ruleset.xml @@ -15,6 +15,9 @@ warning + + warning + warning @@ -24,6 +27,9 @@ warning + + warning + warning diff --git a/SymPress-Pure/ruleset.xml b/SymPress-Pure/ruleset.xml index ff0157e..b37a6bb 100644 --- a/SymPress-Pure/ruleset.xml +++ b/SymPress-Pure/ruleset.xml @@ -16,6 +16,7 @@ + @@ -30,6 +31,7 @@ + diff --git a/SymPress/Sniffs/Classes/ClassLengthSniff.php b/SymPress/Sniffs/Classes/ClassLengthSniff.php new file mode 100644 index 0000000..3e5d279 --- /dev/null +++ b/SymPress/Sniffs/Classes/ClassLengthSniff.php @@ -0,0 +1,177 @@ + */ + public function register(): array + { + return [ + T_CLASS, + T_ENUM, + T_INTERFACE, + T_TRAIT, + ]; + } + + /** + * @param File $phpcsFile + * @param int $stackPtr + */ + public function process(File $phpcsFile, $stackPtr): void + { + $length = $this->structureLinesCount($phpcsFile, $stackPtr); + if ($length <= $this->maxLength) { + return; + } + + $ignored = $this->ignoredLines(); + $ignoring = $ignored ? sprintf(' (ignoring: %s)', implode(', ', $ignored)) : ''; + $tokenTypeName = Names::tokenTypeName($phpcsFile, $stackPtr); + + $phpcsFile->addWarning( + '%s length (%d) exceeds allowed maximum of %d lines%s', + $stackPtr, + 'TooLong', + [ucfirst($tokenTypeName), $length, $this->maxLength, $ignoring], + ); + } + + private function structureLinesCount(File $file, int $position): int + { + /** @var array> $tokens */ + $tokens = $file->getTokens(); + $token = $tokens[$position] ?? []; + + if ( + !array_key_exists('scope_opener', $token) + || !array_key_exists('scope_closer', $token) + ) { + return 0; + } + + $start = (int) $token['scope_opener']; + $end = (int) $token['scope_closer']; + $length = (int) $tokens[$end]['line'] - (int) $tokens[$start]['line']; + + if ($length <= $this->maxLength) { + return $length; + } + + return $length - $this->collectLinesToExclude($start, $end, $tokens); + } + + /** + * @param int $start + * @param int $end + * @param array> $tokens + */ + private function collectLinesToExclude(int $start, int $end, array $tokens): int + { + $docBlocks = []; + $linesData = []; + $skipLines = [$tokens[$start + 1]['line'], $tokens[$end]['line']]; + + for ($i = $start + 1; $i < $end - 1; $i++) { + if (in_array($tokens[$i]['line'], $skipLines, true)) { + continue; + } + + $docBlocks = $this->docBlocksData($tokens, $i, $docBlocks); + $linesData = $this->ignoredLinesData($tokens[$i], $linesData); + } + + $empty = array_filter(array_column($linesData, 'empty')); + $onlyComment = array_filter(array_column($linesData, 'only-comment')); + $toExcludeCount = (int) array_sum($docBlocks); + + if ($this->ignoreBlankLines) { + $toExcludeCount += count($empty); + } + if ($this->ignoreComments) { + $toExcludeCount += count($onlyComment) - count($empty); + } + + return $toExcludeCount; + } + + /** + * @param array $token + * @param array $lines + * @return array + */ + private function ignoredLinesData(array $token, array $lines): array + { + $line = (int) $token['line']; + if (!array_key_exists($line, $lines)) { + $lines[$line] = ['empty' => true, 'only-comment' => true]; + } + + if (!in_array($token['code'], [T_COMMENT, T_WHITESPACE], true)) { + $lines[$line]['only-comment'] = false; + } + + if ($token['code'] !== T_WHITESPACE) { + $lines[$line]['empty'] = false; + } + + return $lines; + } + + /** + * @param array> $tokens + * @param int $position + * @param list $docBlocks + * @return list + */ + private function docBlocksData(array $tokens, int $position, array $docBlocks): array + { + if ( + !$this->ignoreDocBlocks + || $tokens[$position]['code'] !== T_DOC_COMMENT_OPEN_TAG + ) { + return $docBlocks; + } + + $closer = $tokens[$position]['comment_closer'] ?? null; + + $docBlocks[] = is_numeric($closer) + ? 1 + (int) $tokens[(int) $closer]['line'] - (int) $tokens[$position]['line'] + : 1; + + return $docBlocks; + } + + /** @return list */ + private function ignoredLines(): array + { + $ignored = []; + $flags = [ + 'ignoreBlankLines' => 'blank lines', + 'ignoreComments' => 'inline comments', + 'ignoreDocBlocks' => 'doc blocks', + ]; + + foreach ($flags as $flag => $type) { + if (!filter_var($this->{$flag}, FILTER_VALIDATE_BOOLEAN)) { + continue; + } + + $ignored[] = $type; + } + + return $ignored; + } +} diff --git a/SymPress/Sniffs/Files/FileLengthSniff.php b/SymPress/Sniffs/Files/FileLengthSniff.php new file mode 100644 index 0000000..1a25e38 --- /dev/null +++ b/SymPress/Sniffs/Files/FileLengthSniff.php @@ -0,0 +1,51 @@ + */ + public function register(): array + { + return [ + T_OPEN_TAG, + ]; + } + + /** + * @param File $phpcsFile + * @param int $stackPtr + */ + public function process(File $phpcsFile, $stackPtr): int + { + $length = $this->fileLength($phpcsFile); + if ($length > $this->maxLength) { + $phpcsFile->addWarning( + 'File length (%d) exceeds allowed maximum of %d lines', + $stackPtr, + 'TooLong', + [$length, $this->maxLength], + ); + } + + return $phpcsFile->numTokens + 1; + } + + private function fileLength(File $file): int + { + $contents = file_get_contents($file->getFilename()); + + if (!is_string($contents) || $contents === '') { + return 0; + } + + return substr_count($contents, "\n") + (str_ends_with($contents, "\n") ? 0 : 1); + } +} diff --git a/docs/Rules.md b/docs/Rules.md index a34ee68..d6dd532 100644 --- a/docs/Rules.md +++ b/docs/Rules.md @@ -40,6 +40,7 @@ profile, not in the shared enterprise default. | `SymPress.Arrays.ArrayDoubleArrowAlignment` | Formatting | Error, fixable where safe | Normalizes multiline array alignment. | | `SymPress.Arrays.MultiLineArray` | Formatting | Error, fixable where safe | Keeps multiline arrays stable in diffs. | | `SymPress.Classes.AccessorNaming` | Architecture | Warning in adoption, strict in Next | Opinionated. Tune or demote when a domain model intentionally avoids getter/setter naming. | +| `SymPress.Classes.ClassLength` | Maintainability | Warning | Signals classes that likely need responsibility review. Default threshold: 500 effective lines. | | `SymPress.Classes.DeprecatedSerializableInterface` | Risk | Error | Blocks deprecated serialization APIs. | | `SymPress.Classes.DeprecatedSerializeMagicMethod` | Risk | Error | Blocks deprecated magic serialization APIs. | | `SymPress.Classes.PropertyLimit` | Architecture | Warning in enterprise profiles | Signals large objects. Treat as design pressure, not proof of a bug. | @@ -47,6 +48,7 @@ profile, not in the shared enterprise default. | `SymPress.ControlStructures.AlternativeSyntax` | Template formatting | Warning | Applies to template syntax rules. | | `SymPress.ControlStructures.DisallowElse` | Architecture | Warning in enterprise profiles | Encourages early returns. Exclude when legacy diff churn is too high. | | `SymPress.Encoding.Utf8EncodingComment` | Formatting | Warning | Keeps source encoding comments consistent. | +| `SymPress.Files.FileLength` | Maintainability | Warning | Signals oversized files. Default threshold: 1000 physical lines. | | `SymPress.Files.LineLength` | Maintainability | Warning | WordPress i18n functions are allowed in the WordPress layer. | | `SymPress.Formatting.AlphabeticalUseStatements` | Formatting | Error, fixable | Mechanical import ordering. | | `SymPress.Formatting.TrailingSemicolon` | Template formatting | Error, fixable | Template shorthand output cleanup. | diff --git a/docs/Sniffs.md b/docs/Sniffs.md index 91bce34..64997b2 100644 --- a/docs/Sniffs.md +++ b/docs/Sniffs.md @@ -9,11 +9,13 @@ The package exposes these SymPress custom sniffs: - `SymPress.Classes.AccessorNaming` - `SymPress.Classes.DeprecatedSerializableInterface` - `SymPress.Classes.DeprecatedSerializeMagicMethod` +- `SymPress.Classes.ClassLength` - `SymPress.Classes.PropertyLimit` - `SymPress.Complexity.NestingLevel` - `SymPress.ControlStructures.AlternativeSyntax` - `SymPress.ControlStructures.DisallowElse` - `SymPress.Encoding.Utf8EncodingComment` +- `SymPress.Files.FileLength` - `SymPress.Files.LineLength` - `SymPress.Formatting.AlphabeticalUseStatements` - `SymPress.Formatting.TrailingSemicolon` diff --git a/tests/fixtures/class-length.php b/tests/fixtures/class-length.php new file mode 100644 index 0000000..2fd5a3b --- /dev/null +++ b/tests/fixtures/class-length.php @@ -0,0 +1,22 @@ + Date: Fri, 19 Jun 2026 03:44:15 +0200 Subject: [PATCH 2/2] chore!: require php 8.5 for coding standards BREAKING CHANGE: The coding standards package now requires PHP 8.5 or newer. --- .github/workflows/qa.yml | 8 +------- composer.json | 10 +++++----- docs/Compatibility.md | 2 +- tests/unit/Php85SyntaxTest.php | 13 ------------- 4 files changed, 7 insertions(+), 26 deletions(-) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 4339fc8..98a7111 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -15,14 +15,8 @@ jobs: uses: sympress/workflows/.github/workflows/lint-workflows.yml@v1 qa: - strategy: - fail-fast: false - matrix: - php-version: - - '8.4' - - '8.5' uses: sympress/workflows/.github/workflows/sympress-qa.yml@v1 with: - php_version: ${{ matrix.php-version }} + php_version: '8.5' secrets: COMPOSER_AUTH_JSON: ${{ secrets.COMPOSER_AUTH_JSON }} diff --git a/composer.json b/composer.json index 0e894dd..bdf27e4 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ } ], "require": { - "php": ">=8.4 <9.0", + "php": "^8.5", "automattic/vipwpcs": "^3.0", "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "phpcompatibility/php-compatibility": "^9.3 || ^10.0@alpha", @@ -81,14 +81,14 @@ "Composer\\Config::disableProcessTimeout", "phpunit" ], - "tests:coverage": [ - "Composer\\Config::disableProcessTimeout", - "phpunit --coverage-text --coverage-clover tmp/coverage.xml --coverage-html tmp/coverage" - ], "qa": [ "@cs", "@static-analysis", "@tests" + ], + "tests:coverage": [ + "Composer\\Config::disableProcessTimeout", + "phpunit --coverage-text --coverage-clover tmp/coverage.xml --coverage-html tmp/coverage" ] } } diff --git a/docs/Compatibility.md b/docs/Compatibility.md index ddf2b3f..43162fc 100644 --- a/docs/Compatibility.md +++ b/docs/Compatibility.md @@ -4,7 +4,7 @@ SymPress Coding Standards separates the PHP version required to run the standard ## Runtime Requirement -The package can be installed on PHP 8.4 or newer. Projects that use syntax introduced after the PHP version running PHP_CodeSniffer should run PHPCS on the same PHP minor version as the project target, because tokenization of newer language syntax depends on the executing PHP runtime. +The package can be installed on PHP 8.5 or newer. Projects that use syntax introduced after the PHP version running PHP_CodeSniffer should run PHPCS on the same PHP minor version as the project target, because tokenization of newer language syntax depends on the executing PHP runtime. ## Enterprise Profiles diff --git a/tests/unit/Php85SyntaxTest.php b/tests/unit/Php85SyntaxTest.php index bd574f3..b0a5bdd 100644 --- a/tests/unit/Php85SyntaxTest.php +++ b/tests/unit/Php85SyntaxTest.php @@ -51,8 +51,6 @@ public function callback(): Closure #[Test] public function php85PipeTokenIsClassifiedAsOperator(): void { - $this->requiresPhp85Runtime(); - $file = $this->factoryFile(self::CODE, '8.5'); $tokens = $file->getTokens(); @@ -66,8 +64,6 @@ public function php85PipeTokenIsClassifiedAsOperator(): void #[Test] public function sympressPureParsesPhp85SyntaxWithoutSyntaxErrors(): void { - $this->requiresPhp85Runtime(); - $tempDir = sys_get_temp_dir() . '/sympress-cs-php85-' . bin2hex(random_bytes(8)); self::assertTrue(mkdir($tempDir)); @@ -95,13 +91,4 @@ public function sympressPureParsesPhp85SyntaxWithoutSyntaxErrors(): void rmdir($tempDir); } } - - private function requiresPhp85Runtime(): void - { - if (PHP_VERSION_ID >= 80500) { - return; - } - - self::markTestSkipped('PHP 8.5 syntax tokenization requires a PHP 8.5 runtime.'); - } }