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/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/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/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 @@
+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.');
- }
}