diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..49d2667
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,7 @@
+[*]
+charset = utf-8
+end_of_line = lf
+trim_trailing_whitespace = true
+insert_final_newline = true
+indent_style = space
+indent_size = 4
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..43ad3ee
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,12 @@
+* text=auto
+
+/cache export-ignore
+/.editorconfig export-ignore
+/.gitattributes export-ignore
+/.github export-ignore
+/.gitignore export-ignore
+/temp-clone export-ignore
+/composer-dependency-analyser.php export-ignore
+/phpcs.xml.dist export-ignore
+/phpstan.neon.dist export-ignore
+/phpunit.xml.dist export-ignore
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
new file mode 100644
index 0000000..8f8419d
--- /dev/null
+++ b/.github/workflows/checks.yml
@@ -0,0 +1,30 @@
+name: Checks
+on:
+ pull_request:
+ push:
+ branches:
+ - "master"
+ - "v[0-9]"
+jobs:
+ checks:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4']
+ steps:
+ -
+ name: Checkout code
+ uses: actions/checkout@v4
+ -
+ name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ -
+ name: Install dependencies
+ run: composer install --no-progress --prefer-dist --no-interaction
+
+ -
+ name: Run checks
+ run: composer check
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..2273918
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,26 @@
+name: phpcs.xml lint
+on:
+ pull_request:
+ push:
+ branches:
+ - "master"
+ - "v[0-9]"
+
+jobs:
+ xml-linter:
+ runs-on: ubuntu-latest
+ steps:
+ -
+ name: Checkout code
+ uses: actions/checkout@v4
+
+ -
+ name: Install dependencies
+ run: composer install --no-progress --no-interaction
+
+ -
+ name: Lint
+ uses: ChristophWurst/xmllint-action@v1
+ with:
+ xml-file: phpcs.xml.dist
+ xml-schema-file: vendor/squizlabs/php_codesniffer/phpcs.xsd
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..55211a2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+/vendor
+/cache/*
+!/cache/.gitkeep
+/.idea
+/phpstan.neon
+composer.lock
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c79fdeb
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Bedabox, LLC dba ShipMonk
+
+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
new file mode 100644
index 0000000..46ccaec
--- /dev/null
+++ b/README.md
@@ -0,0 +1,206 @@
+# PHPStan Development Utilities
+
+Development utilities for PHPStan rules testing, extracted from [shipmonk/phpstan-rules](https://github.com/shipmonk-rnd/phpstan-rules).
+
+This package provides the `RuleTestCase` class - an enhanced testing framework specifically designed for testing PHPStan rules with additional validation and convenience features.
+
+## Installation
+
+```bash
+composer require --dev shipmonk/phpstan-dev
+```
+
+## Quick Start
+
+### 1. Create Your Rule Test Class
+
+```php
+
+ */
+class YourRuleTest extends RuleTestCase
+{
+ protected function getRule(): Rule
+ {
+ return new YourRule();
+ }
+
+ public function testRule(): void
+ {
+ $this->analyzeFiles([__DIR__ . '/Data/YourRule/code.php']);
+ }
+}
+```
+
+### 2. Create Test Data File
+
+Create `tests/Rule/Data/YourRule/code.php`:
+
+```php
+` comments in test files to specify expected errors:
+
+```php
+analyzeFiles([__DIR__ . '/Data/code.php'], autofix: true);
+}
+```
+
+**⚠️ Important**: Remove `autofix: true` before committing - tests will fail if autofix is enabled.
+
+### 🛡️ Automatic Error Validation
+
+Every error is automatically validated:
+- ✅ Must have an identifier
+- ✅ Errors are matched to specific line numbers
+
+## Advanced Usage
+
+### Multiple Test Scenarios
+
+```php
+class ComplexRuleTest extends RuleTestCase
+{
+ private bool $strictMode = false;
+
+ protected function getRule(): Rule
+ {
+ return new ComplexRule($this->strictMode);
+ }
+
+ public function testDefault(): void
+ {
+ $this->analyzeFiles([__DIR__ . '/Data/ComplexRule/default.php']);
+ }
+
+ public function testStrict(): void
+ {
+ $this->strictMode = true;
+ $this->analyzeFiles([__DIR__ . '/Data/ComplexRule/strict.php']);
+ }
+}
+```
+
+### PHP Version-Specific Tests
+
+```php
+public function testPhp82Features(): void
+{
+ $this->phpVersion = $this->createPhpVersion(80_200);
+ $this->analyzeFiles([__DIR__ . '/Data/Rule/php82-features.php']);
+}
+```
+
+### Custom PHPStan Configuration
+
+Create `tests/Rule/Data/YourRule/config.neon`:
+
+```neon
+parameters:
+ customParameter: value
+```
+
+Then reference it in your test:
+
+```php
+public static function getAdditionalConfigFiles(): array
+{
+ return array_merge(
+ parent::getAdditionalConfigFiles(),
+ [__DIR__ . '/Data/YourRule/config.neon'],
+ );
+}
+```
+
+### Rules with Dependencies
+
+```php
+protected function getRule(): Rule
+{
+ $dependency = self::getContainer()->getByType(SomeService::class);
+ return new RuleWithDependencies($dependency);
+}
+```
+
+## File Organization
+
+Recommended directory structure:
+
+```
+tests/
+├── Rule/
+│ ├── YourRuleTest.php
+│ ├── AnotherRuleTest.php
+│ └── Data/
+│ ├── YourRule/
+│ │ ├── code.php # Main test file
+│ │ ├── edge-cases.php # Additional scenarios
+│ │ └── config.neon # Optional PHPStan config
+│ └── AnotherRule/
+│ └── code.php
+```
+
+## Development
+
+```bash
+# Install dependencies
+composer install
+
+# Run all checks
+composer check
+
+# Individual checks
+composer check:composer # Validate composer.json
+composer check:ec # Check EditorConfig compliance
+composer check:cs # Check coding standards (PHPCS)
+composer check:types # Run PHPStan analysis
+composer check:dependencies # Analyze dependencies
+composer check:collisions # Check for name collisions
+
+# Fix coding standards
+composer fix:cs
+```
+
+## License
+
+MIT License - see [LICENSE](LICENSE) file.
diff --git a/cache/.gitkeep b/cache/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/cache/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php
new file mode 100644
index 0000000..63a75da
--- /dev/null
+++ b/composer-dependency-analyser.php
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ src/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Exception type missing for @throws annotation
+
+
+ Only 1 @return annotation is allowed in a function comment
+
+
+ Extra @param annotation
+
+
+ @param annotation for parameter "%s" missing
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..1e69353
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,38 @@
+includes:
+ - phar://phpstan.phar/conf/config.levelmax.neon
+ - phar://phpstan.phar/conf/bleedingEdge.neon
+ - ./vendor/phpstan/phpstan-strict-rules/rules.neon
+ - ./vendor/phpstan/phpstan-phpunit/extension.neon
+ - ./vendor/phpstan/phpstan-phpunit/rules.neon
+ - ./vendor/phpstan/phpstan-deprecation-rules/rules.neon
+ - ./vendor/shipmonk/dead-code-detector/rules.neon
+
+parameters:
+ phpVersion:
+ min: 70400
+ max: 80499
+ internalErrorsCountLimit: 1
+ paths:
+ - src
+ - tests
+ excludePaths:
+ - tests/Rule/Data/**
+ tmpDir: cache/phpstan/
+ checkMissingCallableSignature: true
+ checkUninitializedProperties: true
+ checkBenevolentUnionTypes: true
+ checkImplicitMixed: true
+ checkTooWideReturnTypesInProtectedAndPublicMethods: true
+ reportAnyTypeWideningInVarTag: true
+ reportPossiblyNonexistentConstantArrayOffset: true
+ reportPossiblyNonexistentGeneralArrayOffset: true
+ exceptions:
+ check:
+ missingCheckedExceptionInThrows: true
+ tooWideThrowType: true
+ implicitThrows: false
+ uncheckedExceptionClasses:
+ - LogicException
+
+ editorUrl: 'jetbrains://php-storm/navigate/reference?project=phpstan-dev&path=%%relFile%%:%%line%%' # requires usage of JetBrains Toolbox
+ editorUrlTitle: '%%relFile%%:%%line%%'
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..b12349e
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/src/RuleTestCase.php b/src/RuleTestCase.php
new file mode 100644
index 0000000..baed3fc
--- /dev/null
+++ b/src/RuleTestCase.php
@@ -0,0 +1,164 @@
+
+ */
+abstract class RuleTestCase extends OriginalRuleTestCase
+{
+
+ /**
+ * @param list $files
+ */
+ protected function analyzeFiles(array $files, bool $autofix = false): void
+ {
+ sort($files);
+
+ $analyserErrors = $this->gatherAnalyserErrors($files);
+
+ if ($autofix) {
+ foreach ($files as $file) {
+ $fileErrors = array_filter($analyserErrors, static fn(Error $error): bool => $error->getFile() === $file);
+ $this->autofix($file, array_values($fileErrors));
+ }
+
+ $filesStr = implode(', ', $files);
+ self::fail("Files {$filesStr} were autofixed. This setup should never remain in the codebase.");
+ }
+
+ foreach ($files as $file) {
+ $fileErrors = array_filter($analyserErrors, static fn(Error $error): bool => $error->getFile() === $file);
+ $actualErrors = $this->processActualErrors(array_values($fileErrors));
+ $expectedErrors = $this->parseExpectedErrors($file);
+
+ self::assertSame(
+ implode("\n", $expectedErrors) . "\n",
+ implode("\n", $actualErrors) . "\n",
+ "Errors in file {$file} do not match",
+ );
+ }
+ }
+
+ /**
+ * @param list $actualErrors
+ * @return list
+ */
+ protected function processActualErrors(array $actualErrors): array
+ {
+ $resultToAssert = [];
+
+ foreach ($actualErrors as $error) {
+ $usedLine = $error->getLine() ?? -1;
+ $key = sprintf('%04d', $usedLine) . '-' . uniqid();
+ $resultToAssert[$key] = $this->formatErrorForAssert($error->getMessage(), $usedLine);
+
+ self::assertNotNull($error->getIdentifier(), "Missing error identifier for error: {$error->getMessage()}");
+ }
+
+ ksort($resultToAssert);
+
+ return array_values($resultToAssert);
+ }
+
+ /**
+ * @return list
+ */
+ private function parseExpectedErrors(string $file): array
+ {
+ $fileLines = $this->getFileLines($file);
+ $expectedErrors = [];
+
+ foreach ($fileLines as $line => $row) {
+ $matched = preg_match_all('#// error:(.*?)(?=// error:|$)#', $row, $matches);
+
+ if ($matched === false) {
+ throw new LogicException('Error while matching errors');
+ }
+
+ foreach ($matches[1] as $error) {
+ $expectedErrors[] = $this->formatErrorForAssert(trim($error), $line + 1);
+ }
+ }
+
+ return $expectedErrors;
+ }
+
+ private function formatErrorForAssert(string $message, int $line): string
+ {
+ return sprintf('%02d: %s', $line, $message);
+ }
+
+ /**
+ * @param list $analyserErrors
+ */
+ private function autofix(string $file, array $analyserErrors): void
+ {
+ $errorsByLines = [];
+
+ foreach ($analyserErrors as $analyserError) {
+ $line = $analyserError->getLine();
+
+ if ($line === null) {
+ throw new LogicException('Error without line number: ' . $analyserError->getMessage());
+ }
+
+ $errorsByLines[$line] = $analyserError;
+ }
+
+ $fileLines = $this->getFileLines($file);
+
+ foreach ($fileLines as $line => &$row) {
+ if (!isset($errorsByLines[$line + 1])) {
+ continue;
+ }
+
+ $errorCommentPattern = '~ ?//.*$~';
+ $errorMessage = $errorsByLines[$line + 1]->getMessage();
+ $errorComment = ' // error: ' . $errorMessage;
+
+ if (preg_match($errorCommentPattern, $row) === 1) {
+ $row = preg_replace($errorCommentPattern, $errorComment, $row);
+ } else {
+ $row .= $errorComment;
+ }
+ }
+
+ file_put_contents($file, implode("\n", $fileLines));
+ }
+
+ /**
+ * @return list
+ */
+ private function getFileLines(string $file): array
+ {
+ $fileData = file_get_contents($file);
+
+ if ($fileData === false) {
+ throw new LogicException('Error while reading data from ' . $file);
+ }
+
+ return explode("\n", $fileData);
+ }
+
+}
diff --git a/tests/Rule/Data/DisallowDivisionByLiteralZeroRule/DisallowDivisionByLiteralZeroRule.php b/tests/Rule/Data/DisallowDivisionByLiteralZeroRule/DisallowDivisionByLiteralZeroRule.php
new file mode 100644
index 0000000..bdd283e
--- /dev/null
+++ b/tests/Rule/Data/DisallowDivisionByLiteralZeroRule/DisallowDivisionByLiteralZeroRule.php
@@ -0,0 +1,36 @@
+
+ */
+class DisallowDivisionByLiteralZeroRule implements Rule
+{
+
+ public function getNodeType(): string
+ {
+ return Div::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ if ($node->right instanceof Int_ && $node->right->value === 0) {
+ return [
+ RuleErrorBuilder::message('Division by literal zero is not allowed')
+ ->identifier('shipmonk.divisionByZero')
+ ->build(),
+ ];
+ }
+
+ return [];
+ }
+
+}
diff --git a/tests/Rule/Data/DisallowDivisionByLiteralZeroRule/code.php b/tests/Rule/Data/DisallowDivisionByLiteralZeroRule/code.php
new file mode 100644
index 0000000..dcd2ad7
--- /dev/null
+++ b/tests/Rule/Data/DisallowDivisionByLiteralZeroRule/code.php
@@ -0,0 +1,24 @@
+
+ */
+class RuleTestCaseTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return new DisallowDivisionByLiteralZeroRule();
+ }
+
+ public function testRule(): void
+ {
+ $this->analyzeFiles([__DIR__ . '/Rule/Data/DisallowDivisionByLiteralZeroRule/code.php']);
+ }
+
+ public function testMultipleErrorsOnSameLine(): void
+ {
+ // Create a dedicated test file for multiple errors demonstration
+ $testFile = __DIR__ . '/Rule/Data/DisallowDivisionByLiteralZeroRule/multiple-errors.php';
+
+ $this->analyzeFiles([$testFile]);
+ }
+
+}