From 83b717d01e4dc0525d6707508d9af9dcbd4d8049 Mon Sep 17 00:00:00 2001 From: chris Date: Tue, 25 Nov 2025 22:17:31 +0100 Subject: [PATCH 01/23] ci: Add tests for PHP 8.5 --- .github/workflows/build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 979caa4..b74773b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,7 @@ jobs: - "8.2" - "8.3" - "8.4" + - "8.5" steps: - name: "Checkout" @@ -98,6 +99,7 @@ jobs: - "8.2" - "8.3" - "8.4" + - "8.5" dependencies: - "lowest" - "highest" @@ -164,6 +166,7 @@ jobs: - "8.2" - "8.3" - "8.4" + - "8.5" dependencies: - "lowest" - "highest" @@ -230,6 +233,7 @@ jobs: - "8.2" - "8.3" - "8.4" + - "8.5" operating-system: [ubuntu-latest] steps: From cda423c709dae0126987fec04a381ec819c9d9a7 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 5 Dec 2025 09:04:44 +0100 Subject: [PATCH 02/23] Implement AttributeRequiresPhpVersionRule --- rules.neon | 7 ++ .../AttributeRequiresPhpVersionRule.php | 99 +++++++++++++++++++ src/Rules/PHPUnit/PHPUnitVersion.php | 35 ++++++- src/Rules/PHPUnit/PHPUnitVersionDetector.php | 7 +- src/Rules/PHPUnit/TestMethodsHelper.php | 12 +++ .../AttributeRequiresPhpVersionRuleTest.php | 95 ++++++++++++++++++ .../PHPUnit/DataProviderDataRuleTest.php | 2 +- .../DataProviderDeclarationRuleTest.php | 2 +- .../PHPUnit/data/requires-php-version.php | 24 +++++ 9 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php create mode 100644 tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php create mode 100644 tests/Rules/PHPUnit/data/requires-php-version.php diff --git a/rules.neon b/rules.neon index 8272f47..8469bd1 100644 --- a/rules.neon +++ b/rules.neon @@ -25,6 +25,13 @@ services: tags: - phpstan.rules.rule + - + class: PHPStan\Rules\PHPUnit\AttributeRequiresPhpVersionRule + arguments: + deprecationRulesInstalled: %deprecationRulesInstalled% + tags: + - phpstan.rules.rule + - class: PHPStan\Rules\PHPUnit\AssertEqualsIsDiscouragedRule diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php new file mode 100644 index 0000000..f106bf8 --- /dev/null +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -0,0 +1,99 @@ + + */ +class AttributeRequiresPhpVersionRule implements Rule +{ + + private PHPUnitVersion $PHPUnitVersion; + + private TestMethodsHelper $testMethodsHelper; + + /** + * When phpstan-deprecation-rules is installed, it reports deprecated usages. + */ + private bool $deprecationRulesInstalled; + + public function __construct( + PHPUnitVersion $PHPUnitVersion, + TestMethodsHelper $testMethodsHelper, + bool $deprecationRulesInstalled + ) + { + $this->PHPUnitVersion = $PHPUnitVersion; + $this->testMethodsHelper = $testMethodsHelper; + $this->deprecationRulesInstalled = $deprecationRulesInstalled; + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $scope->getClassReflection(); + if ($classReflection === null || $classReflection->is(TestCase::class) === false) { + return []; + } + + $reflectionMethod = $this->testMethodsHelper->getTestMethodReflection($classReflection, $node->getMethodReflection(), $scope); + if ($reflectionMethod === null) { + return []; + } + + /** @phpstan-ignore function.alreadyNarrowedType */ + if (!method_exists($reflectionMethod, 'getAttributes')) { + return []; + } + + $errors = []; + foreach ($reflectionMethod->getAttributes('PHPUnit\Framework\Attributes\RequiresPhp') as $attr) { + $args = $attr->getArguments(); + if (count($args) !== 1) { + continue; + } + + if ( + !is_numeric($args[0]) + ) { + continue; + } + + if ($this->PHPUnitVersion->requiresPhpversionAttributeWithOperator()->yes()) { + $errors[] = RuleErrorBuilder::message( + sprintf('Version requirement is missing operator.'), + ) + ->identifier('phpunit.attributeRequiresPhpVersion') + ->build(); + } elseif ( + $this->deprecationRulesInstalled + && $this->PHPUnitVersion->deprecatesPhpversionAttributeWithoutOperator()->yes() + ) { + $errors[] = RuleErrorBuilder::message( + sprintf('Version requirement without operator is deprecated.'), + ) + ->identifier('phpunit.attributeRequiresPhpVersion') + ->build(); + } + + } + + return $errors; + } + +} diff --git a/src/Rules/PHPUnit/PHPUnitVersion.php b/src/Rules/PHPUnit/PHPUnitVersion.php index b7259a8..56eb755 100644 --- a/src/Rules/PHPUnit/PHPUnitVersion.php +++ b/src/Rules/PHPUnit/PHPUnitVersion.php @@ -9,9 +9,12 @@ class PHPUnitVersion private ?int $majorVersion; - public function __construct(?int $majorVersion) + private ?int $minorVersion; + + public function __construct(?int $majorVersion, ?int $minorVersion) { $this->majorVersion = $majorVersion; + $this->minorVersion = $minorVersion; } public function supportsDataProviderAttribute(): TrinaryLogic @@ -46,4 +49,34 @@ public function supportsNamedArgumentsInDataProvider(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->majorVersion >= 11); } + public function requiresPhpversionAttributeWithOperator(): TrinaryLogic + { + if ($this->majorVersion === null) { + return TrinaryLogic::createMaybe(); + } + return TrinaryLogic::createFromBoolean($this->majorVersion >= 13); + } + + public function deprecatesPhpversionAttributeWithoutOperator(): TrinaryLogic + { + return $this->minVersion(12, 4); + } + + private function minVersion(int $major, int $minor): TrinaryLogic + { + if ($this->majorVersion === null || $this->minorVersion === null) { + return TrinaryLogic::createMaybe(); + } + + if ($this->majorVersion > $major) { + return TrinaryLogic::createYes(); + } + + if ($this->majorVersion === $major && $this->minorVersion >= $minor) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createNo(); + } + } diff --git a/src/Rules/PHPUnit/PHPUnitVersionDetector.php b/src/Rules/PHPUnit/PHPUnitVersionDetector.php index f0e2c4b..b82caaf 100644 --- a/src/Rules/PHPUnit/PHPUnitVersionDetector.php +++ b/src/Rules/PHPUnit/PHPUnitVersionDetector.php @@ -23,6 +23,7 @@ public function __construct(ReflectionProvider $reflectionProvider) public function createPHPUnitVersion(): PHPUnitVersion { $majorVersion = null; + $minorVersion = null; if ($this->reflectionProvider->hasClass(TestCase::class)) { $testCase = $this->reflectionProvider->getClass(TestCase::class); $file = $testCase->getFileName(); @@ -35,14 +36,16 @@ public function createPHPUnitVersion(): PHPUnitVersion $json = json_decode($composerJson, true); $version = $json['extra']['branch-alias']['dev-main'] ?? null; if ($version !== null) { - $majorVersion = (int) explode('.', $version)[0]; + $versionParts = explode('.', $version); + $majorVersion = (int) $versionParts[0]; + $minorVersion = (int) $versionParts[1]; } } } } } - return new PHPUnitVersion($majorVersion); + return new PHPUnitVersion($majorVersion, $minorVersion); } } diff --git a/src/Rules/PHPUnit/TestMethodsHelper.php b/src/Rules/PHPUnit/TestMethodsHelper.php index 5eb274e..a215c8f 100644 --- a/src/Rules/PHPUnit/TestMethodsHelper.php +++ b/src/Rules/PHPUnit/TestMethodsHelper.php @@ -5,6 +5,7 @@ use PHPStan\Analyser\Scope; use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\MethodReflection; use PHPStan\Type\FileTypeMapper; use PHPUnit\Framework\TestCase; use ReflectionMethod; @@ -27,6 +28,17 @@ public function __construct( $this->PHPUnitVersion = $PHPUnitVersion; } + public function getTestMethodReflection(ClassReflection $classReflection, MethodReflection $methodReflection, Scope $scope): ?ReflectionMethod + { + foreach ($this->getTestMethods($classReflection, $scope) as $testMethod) { + if ($testMethod->getName() === $methodReflection->getName()) { + return $testMethod; + } + } + + return null; + } + /** * @return array */ diff --git a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php new file mode 100644 index 0000000..92d9715 --- /dev/null +++ b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php @@ -0,0 +1,95 @@ + + */ +final class AttributeRequiresPhpVersionRuleTest extends RuleTestCase +{ + + private ?int $phpunitMajorVersion; + + private ?int $phpunitMinorVersion; + + private bool $deprecationRulesInstalled = true; + + public function testRuleOnPHPUnitUnknown(): void + { + $this->phpunitMajorVersion = null; + $this->phpunitMinorVersion = null; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testRuleOnPHPUnit115(): void + { + $this->phpunitMajorVersion = 11; + $this->phpunitMinorVersion = 5; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testRuleOnPHPUnit123(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 3; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testRuleOnPHPUnit124DeprecationsOn(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 4; + $this->deprecationRulesInstalled = true; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], [ + [ + 'Version requirement without operator is deprecated.', + 12, + ], + ]); + } + + public function testRuleOnPHPUnit124DeprecationsOff(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 4; + $this->deprecationRulesInstalled = false; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testRuleOnPHPUnit13(): void + { + $this->phpunitMajorVersion = 13; + $this->phpunitMinorVersion = 0; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], [ + [ + 'Version requirement is missing operator.', + 12, + ], + ]); + } + + protected function getRule(): Rule + { + $phpunitVersion = new PHPUnitVersion($this->phpunitMajorVersion, $this->phpunitMinorVersion); + + return new AttributeRequiresPhpVersionRule( + $phpunitVersion, + new TestMethodsHelper( + self::getContainer()->getByType(FileTypeMapper::class), + $phpunitVersion, + ), + $this->deprecationRulesInstalled, + ); + } + +} diff --git a/tests/Rules/PHPUnit/DataProviderDataRuleTest.php b/tests/Rules/PHPUnit/DataProviderDataRuleTest.php index cca88e7..012fce7 100644 --- a/tests/Rules/PHPUnit/DataProviderDataRuleTest.php +++ b/tests/Rules/PHPUnit/DataProviderDataRuleTest.php @@ -22,7 +22,7 @@ class DataProviderDataRuleTest extends RuleTestCase protected function getRule(): Rule { $reflectionProvider = $this->createReflectionProvider(); - $phpunitVersion = new PHPUnitVersion($this->phpunitVersion); + $phpunitVersion = new PHPUnitVersion($this->phpunitVersion, 0); /** @var list> $rules */ $rules = [ diff --git a/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php index 2bf9d87..f63c9e6 100644 --- a/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php +++ b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php @@ -24,7 +24,7 @@ protected function getRule(): Rule $reflection, self::getContainer()->getByType(FileTypeMapper::class), self::getContainer()->getService('defaultAnalysisParser'), - new PHPUnitVersion($this->phpunitVersion) + new PHPUnitVersion($this->phpunitVersion, 0) ), true, true diff --git a/tests/Rules/PHPUnit/data/requires-php-version.php b/tests/Rules/PHPUnit/data/requires-php-version.php new file mode 100644 index 0000000..5550edf --- /dev/null +++ b/tests/Rules/PHPUnit/data/requires-php-version.php @@ -0,0 +1,24 @@ +=8.0')] + public function testHappyPath(): void { + + } +} From 8d61a5854e7497d95bc85188e13537e99bd7aae7 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 6 Dec 2025 12:15:39 +0100 Subject: [PATCH 03/23] Remove checkDataProviderData and introduce reportMissingDataProviderReturnType --- extension.neon | 8 ++++---- rules.neon | 2 +- tests/Type/PHPUnit/data/data-provider-iterable-value.neon | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/extension.neon b/extension.neon index 23ae52c..4b2d9c5 100644 --- a/extension.neon +++ b/extension.neon @@ -1,7 +1,7 @@ parameters: phpunit: convertUnionToIntersectionType: true - checkDataProviderData: %featureToggles.bleedingEdge% + reportMissingDataProviderReturnType: false additionalConstructors: - PHPUnit\Framework\TestCase::setUp earlyTerminatingMethodCalls: @@ -24,8 +24,8 @@ parameters: parametersSchema: phpunit: structure([ - convertUnionToIntersectionType: bool() - checkDataProviderData: bool(), + convertUnionToIntersectionType: bool(), + reportMissingDataProviderReturnType: bool(), ]) services: @@ -76,4 +76,4 @@ conditionalTags: PHPStan\PhpDoc\PHPUnit\MockObjectTypeNodeResolverExtension: phpstan.phpDoc.typeNodeResolverExtension: %phpunit.convertUnionToIntersectionType% PHPStan\Type\PHPUnit\DataProviderReturnTypeIgnoreExtension: - phpstan.ignoreErrorExtension: %phpunit.checkDataProviderData% + phpstan.ignoreErrorExtension: [%featureToggles.bleedingEdge%, not(%phpunit.reportMissingDataProviderReturnType%)] diff --git a/rules.neon b/rules.neon index 8469bd1..7bff016 100644 --- a/rules.neon +++ b/rules.neon @@ -14,7 +14,7 @@ conditionalTags: phpstan.rules.rule: [%strictRulesInstalled%, %featureToggles.bleedingEdge%] PHPStan\Rules\PHPUnit\DataProviderDataRule: - phpstan.rules.rule: %phpunit.checkDataProviderData% + phpstan.rules.rule: %featureToggles.bleedingEdge% services: - diff --git a/tests/Type/PHPUnit/data/data-provider-iterable-value.neon b/tests/Type/PHPUnit/data/data-provider-iterable-value.neon index eed12a5..e5597bc 100644 --- a/tests/Type/PHPUnit/data/data-provider-iterable-value.neon +++ b/tests/Type/PHPUnit/data/data-provider-iterable-value.neon @@ -1,6 +1,6 @@ parameters: - phpunit: - checkDataProviderData: true + featureToggles: + bleedingEdge: true includes: - ../../../../extension.neon From 202afe95f3832c62b578f9bbeebba8b2e840260b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:46:46 +0000 Subject: [PATCH 04/23] chore(deps): update github-actions --- .github/workflows/build.yml | 4 ++-- .github/workflows/lock-closed-issues.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b74773b..2df9936 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -271,7 +271,7 @@ jobs: echo "name=$(git remote show origin | sed -n '/HEAD branch/s/.*: //p')" >> $GITHUB_OUTPUT - name: "Restore result cache" - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: ./tmp key: "result-cache-v1-${{ matrix.php-version }}-${{ github.run_id }}" @@ -292,7 +292,7 @@ jobs: --logger-text=php://stdout - name: "Save result cache" - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 if: ${{ !cancelled() }} with: path: ./tmp diff --git a/.github/workflows/lock-closed-issues.yml b/.github/workflows/lock-closed-issues.yml index 9a8fea7..e5ac070 100644 --- a/.github/workflows/lock-closed-issues.yml +++ b/.github/workflows/lock-closed-issues.yml @@ -8,7 +8,7 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v5 + - uses: dessant/lock-threads@v6 with: github-token: ${{ github.token }} issue-inactive-days: '31' From 5e30669bef866eff70db8b58d72a5c185aa82414 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 19 Dec 2025 10:05:35 +0100 Subject: [PATCH 05/23] Perf: Refactor PHPUnitVersionDetector to use runtime reflection --- src/Rules/PHPUnit/PHPUnitVersionDetector.php | 51 ++++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/src/Rules/PHPUnit/PHPUnitVersionDetector.php b/src/Rules/PHPUnit/PHPUnitVersionDetector.php index b82caaf..d35c3e7 100644 --- a/src/Rules/PHPUnit/PHPUnitVersionDetector.php +++ b/src/Rules/PHPUnit/PHPUnitVersionDetector.php @@ -2,45 +2,44 @@ namespace PHPStan\Rules\PHPUnit; -use PHPStan\Reflection\ReflectionProvider; use PHPUnit\Framework\TestCase; +use ReflectionClass; +use ReflectionException; use function dirname; use function explode; use function file_get_contents; -use function is_file; use function json_decode; class PHPUnitVersionDetector { - private ReflectionProvider $reflectionProvider; - - public function __construct(ReflectionProvider $reflectionProvider) - { - $this->reflectionProvider = $reflectionProvider; - } - public function createPHPUnitVersion(): PHPUnitVersion { + $file = false; $majorVersion = null; $minorVersion = null; - if ($this->reflectionProvider->hasClass(TestCase::class)) { - $testCase = $this->reflectionProvider->getClass(TestCase::class); - $file = $testCase->getFileName(); - if ($file !== null) { - $phpUnitRoot = dirname($file, 3); - $phpUnitComposer = $phpUnitRoot . '/composer.json'; - if (is_file($phpUnitComposer)) { - $composerJson = @file_get_contents($phpUnitComposer); - if ($composerJson !== false) { - $json = json_decode($composerJson, true); - $version = $json['extra']['branch-alias']['dev-main'] ?? null; - if ($version !== null) { - $versionParts = explode('.', $version); - $majorVersion = (int) $versionParts[0]; - $minorVersion = (int) $versionParts[1]; - } - } + + try { + // uses runtime reflection to reduce unnecessary work while bootstrapping PHPStan. + // static reflection would need to AST parse and build up reflection for a lot of files otherwise. + $reflection = new ReflectionClass(TestCase::class); + $file = $reflection->getFileName(); + } catch (ReflectionException $e) { + // PHPUnit might not be installed + } + + if ($file !== false) { + $phpUnitRoot = dirname($file, 3); + $phpUnitComposer = $phpUnitRoot . '/composer.json'; + + $composerJson = @file_get_contents($phpUnitComposer); + if ($composerJson !== false) { + $json = json_decode($composerJson, true); + $version = $json['extra']['branch-alias']['dev-main'] ?? null; + if ($version !== null) { + $versionParts = explode('.', $version); + $majorVersion = (int) $versionParts[0]; + $minorVersion = (int) $versionParts[1]; } } } From 80091f9908e5095ea7d83066fe19da5a27d934dc Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 22 Jan 2026 14:30:36 +0100 Subject: [PATCH 06/23] TestMethodsHelper - speed-up by asking for immediate methods only --- .../AttributeRequiresPhpVersionRule.php | 8 +------- src/Rules/PHPUnit/DataProviderHelper.php | 19 +++---------------- src/Rules/PHPUnit/TestMethodsHelper.php | 8 ++++---- 3 files changed, 8 insertions(+), 27 deletions(-) diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index f106bf8..899ff1c 100644 --- a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -10,7 +10,6 @@ use PHPUnit\Framework\TestCase; use function count; use function is_numeric; -use function method_exists; use function sprintf; /** @@ -56,13 +55,8 @@ public function processNode(Node $node, Scope $scope): array return []; } - /** @phpstan-ignore function.alreadyNarrowedType */ - if (!method_exists($reflectionMethod, 'getAttributes')) { - return []; - } - $errors = []; - foreach ($reflectionMethod->getAttributes('PHPUnit\Framework\Attributes\RequiresPhp') as $attr) { + foreach ($reflectionMethod->getAttributesByName('PHPUnit\Framework\Attributes\RequiresPhp') as $attr) { $args = $attr->getArguments(); if (count($args) !== 1) { continue; diff --git a/src/Rules/PHPUnit/DataProviderHelper.php b/src/Rules/PHPUnit/DataProviderHelper.php index d40d05e..a05c59c 100644 --- a/src/Rules/PHPUnit/DataProviderHelper.php +++ b/src/Rules/PHPUnit/DataProviderHelper.php @@ -11,6 +11,7 @@ use PhpParser\Node\Stmt\ClassMethod; use PhpParser\NodeFinder; use PHPStan\Analyser\Scope; +use PHPStan\BetterReflection\Reflection\ReflectionMethod; use PHPStan\Parser\Parser; use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; @@ -20,11 +21,9 @@ use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\FileTypeMapper; -use ReflectionMethod; use function array_merge; use function count; use function explode; -use function method_exists; use function preg_match; use function sprintf; @@ -282,21 +281,13 @@ private function yieldDataProviderAttributes($node, ClassReflection $classReflec if ( $node instanceof ReflectionMethod ) { - /** @phpstan-ignore function.alreadyNarrowedType */ - if (!method_exists($node, 'getAttributes')) { - return; - } - - foreach ($node->getAttributes('PHPUnit\Framework\Attributes\DataProvider') as $attr) { + foreach ($node->getAttributesByName('PHPUnit\Framework\Attributes\DataProvider') as $attr) { $args = $attr->getArguments(); if (count($args) !== 1) { continue; } $startLine = $node->getStartLine(); - if ($startLine === false) { - $startLine = -1; - } yield [$classReflection, $args[0], $startLine]; } @@ -329,7 +320,7 @@ private function yieldDataProviderAttributes($node, ClassReflection $classReflec private function yieldDataProviderAnnotations($node, Scope $scope, ClassReflection $classReflection): iterable { $docComment = $node->getDocComment(); - if ($docComment === null || $docComment === false) { + if ($docComment === null) { return; } @@ -348,10 +339,6 @@ private function yieldDataProviderAnnotations($node, Scope $scope, ClassReflecti } $startLine = $node->getStartLine(); - if ($startLine === false) { - $startLine = -1; - } - $dataProviderMethod = $this->parseDataProviderAnnotationValue($scope, $dataProviderValue); $dataProviderMethod[] = $startLine; diff --git a/src/Rules/PHPUnit/TestMethodsHelper.php b/src/Rules/PHPUnit/TestMethodsHelper.php index a215c8f..f12d4e3 100644 --- a/src/Rules/PHPUnit/TestMethodsHelper.php +++ b/src/Rules/PHPUnit/TestMethodsHelper.php @@ -3,12 +3,12 @@ namespace PHPStan\Rules\PHPUnit; use PHPStan\Analyser\Scope; +use PHPStan\BetterReflection\Reflection\ReflectionMethod; use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\FileTypeMapper; use PHPUnit\Framework\TestCase; -use ReflectionMethod; use function str_starts_with; use function strtolower; @@ -49,7 +49,7 @@ public function getTestMethods(ClassReflection $classReflection, Scope $scope): } $testMethods = []; - foreach ($classReflection->getNativeReflection()->getMethods() as $reflectionMethod) { + foreach ($classReflection->getNativeReflection()->getBetterReflection()->getImmediateMethods() as $reflectionMethod) { if (!$reflectionMethod->isPublic()) { continue; } @@ -60,7 +60,7 @@ public function getTestMethods(ClassReflection $classReflection, Scope $scope): } $docComment = $reflectionMethod->getDocComment(); - if ($docComment !== false) { + if ($docComment !== null) { $methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( $scope->getFile(), $classReflection->getName(), @@ -79,7 +79,7 @@ public function getTestMethods(ClassReflection $classReflection, Scope $scope): continue; } - $testAttributes = $reflectionMethod->getAttributes('PHPUnit\Framework\Attributes\Test'); // @phpstan-ignore argument.type + $testAttributes = $reflectionMethod->getAttributesByName('PHPUnit\Framework\Attributes\Test'); // @phpstan-ignore argument.type if ($testAttributes === []) { continue; } From e4c5a22bf43d3d2bd5a780ad261a622ff62c49a4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 22 Jan 2026 14:39:41 +0100 Subject: [PATCH 07/23] TestMethodsHelper - class methods cache --- src/Rules/PHPUnit/TestMethodsHelper.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Rules/PHPUnit/TestMethodsHelper.php b/src/Rules/PHPUnit/TestMethodsHelper.php index f12d4e3..f7eff08 100644 --- a/src/Rules/PHPUnit/TestMethodsHelper.php +++ b/src/Rules/PHPUnit/TestMethodsHelper.php @@ -9,6 +9,7 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Type\FileTypeMapper; use PHPUnit\Framework\TestCase; +use function array_key_exists; use function str_starts_with; use function strtolower; @@ -19,6 +20,9 @@ final class TestMethodsHelper private PHPUnitVersion $PHPUnitVersion; + /** @var array> */ + private array $methodCache = []; + public function __construct( FileTypeMapper $fileTypeMapper, PHPUnitVersion $PHPUnitVersion @@ -44,8 +48,11 @@ public function getTestMethodReflection(ClassReflection $classReflection, Method */ public function getTestMethods(ClassReflection $classReflection, Scope $scope): array { + if (array_key_exists($classReflection->getName(), $this->methodCache)) { + return $this->methodCache[$classReflection->getName()]; + } if (!$classReflection->is(TestCase::class)) { - return []; + return $this->methodCache[$classReflection->getName()] = []; } $testMethods = []; @@ -87,7 +94,7 @@ public function getTestMethods(ClassReflection $classReflection, Scope $scope): $testMethods[] = $reflectionMethod; } - return $testMethods; + return $this->methodCache[$classReflection->getName()] = $testMethods; } private function hasTestAnnotation(?ResolvedPhpDocBlock $phpDoc): bool From 4d1d77610d68c8ff059ab17c76e0aaea2615d7c6 Mon Sep 17 00:00:00 2001 From: "Barry vd. Heuvel" Date: Tue, 3 Feb 2026 09:35:18 +0100 Subject: [PATCH 08/23] Add keywords for static analysis in composer.json Prevent installs in non-dev --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 39d7a03..08cd088 100644 --- a/composer.json +++ b/composer.json @@ -5,6 +5,7 @@ "license": [ "MIT" ], + "keywords": ["static analysis"], "require": { "php": "^7.4 || ^8.0", "phpstan/phpstan": "^2.1.32" From 590b84d06149a55e630d538ed99011697d80524f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 01:02:26 +0000 Subject: [PATCH 09/23] chore(deps): update wyrihaximus/github-action-get-previous-tag action to v2 --- .github/workflows/create-tag.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index a946f1c..206477e 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -28,7 +28,7 @@ jobs: - name: 'Get Previous tag' id: previoustag - uses: "WyriHaximus/github-action-get-previous-tag@v1" + uses: "WyriHaximus/github-action-get-previous-tag@v2" env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" From 45bc1ad30669c3ebb19417d5c272b8104d155415 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 23 Dec 2025 22:41:35 +0100 Subject: [PATCH 10/23] Implement DynamicCallToAssertionIgnoreExtension --- extension.neon | 5 ++ .../DynamicCallToAssertionIgnoreExtension.php | 49 +++++++++++++++++++ ...amicCallToAssertionIgnoreExtensionTest.php | 38 ++++++++++++++ .../data/dynamic-call-to-assertion.neon | 10 ++++ .../data/dynamic-call-to-assertion.php | 23 +++++++++ 5 files changed, 125 insertions(+) create mode 100644 src/Type/PHPUnit/DynamicCallToAssertionIgnoreExtension.php create mode 100644 tests/Type/PHPUnit/DynamicCallToAssertionIgnoreExtensionTest.php create mode 100644 tests/Type/PHPUnit/data/dynamic-call-to-assertion.neon create mode 100644 tests/Type/PHPUnit/data/dynamic-call-to-assertion.php diff --git a/extension.neon b/extension.neon index 4b2d9c5..2502494 100644 --- a/extension.neon +++ b/extension.neon @@ -72,8 +72,13 @@ services: - class: PHPStan\Type\PHPUnit\DataProviderReturnTypeIgnoreExtension + - + class: PHPStan\Type\PHPUnit\DynamicCallToAssertionIgnoreExtension + conditionalTags: PHPStan\PhpDoc\PHPUnit\MockObjectTypeNodeResolverExtension: phpstan.phpDoc.typeNodeResolverExtension: %phpunit.convertUnionToIntersectionType% PHPStan\Type\PHPUnit\DataProviderReturnTypeIgnoreExtension: phpstan.ignoreErrorExtension: [%featureToggles.bleedingEdge%, not(%phpunit.reportMissingDataProviderReturnType%)] + PHPStan\Type\PHPUnit\DynamicCallToAssertionIgnoreExtension: + phpstan.ignoreErrorExtension: %featureToggles.bleedingEdge% diff --git a/src/Type/PHPUnit/DynamicCallToAssertionIgnoreExtension.php b/src/Type/PHPUnit/DynamicCallToAssertionIgnoreExtension.php new file mode 100644 index 0000000..ccf5b0f --- /dev/null +++ b/src/Type/PHPUnit/DynamicCallToAssertionIgnoreExtension.php @@ -0,0 +1,49 @@ +var instanceof Node\Expr\Variable) { + return false; + } + + if (!is_string($node->var->name) || $node->var->name !== 'this') { + return false; + } + + if ($error->getIdentifier() !== 'staticMethod.dynamicCall') { + return false; + } + + if ( + !$node->name instanceof Node\Identifier + || !str_starts_with($node->name->name, 'assert') + ) { + return false; + } + + if (!$scope->isInClass()) { + return false; + } + + $classReflection = $scope->getClassReflection(); + return $classReflection->is(TestCase::class); + } + +} diff --git a/tests/Type/PHPUnit/DynamicCallToAssertionIgnoreExtensionTest.php b/tests/Type/PHPUnit/DynamicCallToAssertionIgnoreExtensionTest.php new file mode 100644 index 0000000..2e789ba --- /dev/null +++ b/tests/Type/PHPUnit/DynamicCallToAssertionIgnoreExtensionTest.php @@ -0,0 +1,38 @@ + + */ +class DynamicCallToAssertionIgnoreExtensionTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + /** @phpstan-ignore phpstanApi.classConstant */ + return self::getContainer()->getByType(DynamicCallOnStaticMethodsRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/dynamic-call-to-assertion.php'], [ + [ + 'Dynamic call to static method DynamicCallToAssertion\Foo::staticFn().', + 17, + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/data/dynamic-call-to-assertion.neon', + ]; + } + +} diff --git a/tests/Type/PHPUnit/data/dynamic-call-to-assertion.neon b/tests/Type/PHPUnit/data/dynamic-call-to-assertion.neon new file mode 100644 index 0000000..9ed078f --- /dev/null +++ b/tests/Type/PHPUnit/data/dynamic-call-to-assertion.neon @@ -0,0 +1,10 @@ +parameters: + featureToggles: + bleedingEdge: true + +includes: + - ../../../../extension.neon + +services: + - + class: PHPStan\Rules\StrictCalls\DynamicCallOnStaticMethodsRule diff --git a/tests/Type/PHPUnit/data/dynamic-call-to-assertion.php b/tests/Type/PHPUnit/data/dynamic-call-to-assertion.php new file mode 100644 index 0000000..2ffcb06 --- /dev/null +++ b/tests/Type/PHPUnit/data/dynamic-call-to-assertion.php @@ -0,0 +1,23 @@ +assertTrue($b); + } + + public function testBar(bool $b):void { + self::assertTrue($b); + } + + public function foo():void { + $x = $this->staticFn(); + } + + static protected function staticFn():bool { + return true; + } +} From f7553d6c613878d04f7e7ef129d4607118cd7cd4 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Tue, 10 Feb 2026 12:57:48 +0100 Subject: [PATCH 11/23] Add support for createMockForIntersectionOfInterfaces --- extension.neon | 5 + ...IntersectionDynamicReturnTypeExtension.php | 91 +++++++++++++++++++ .../Rules/PHPUnit/MockMethodCallRuleTest.php | 13 +++ tests/Rules/PHPUnit/data/mock-method-call.php | 24 +++++ ...rsectionDynamicReturnTypeExtensionTest.php | 42 +++++++++ .../PHPUnit/data/mock-for-intersection.php | 37 ++++++++ 6 files changed, 212 insertions(+) create mode 100644 src/Type/PHPUnit/MockForIntersectionDynamicReturnTypeExtension.php create mode 100644 tests/Type/PHPUnit/MockForIntersectionDynamicReturnTypeExtensionTest.php create mode 100644 tests/Type/PHPUnit/data/mock-for-intersection.php diff --git a/extension.neon b/extension.neon index 2502494..665d85c 100644 --- a/extension.neon +++ b/extension.neon @@ -47,6 +47,11 @@ services: class: PHPStan\Type\PHPUnit\MockBuilderDynamicReturnTypeExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: PHPStan\Type\PHPUnit\MockForIntersectionDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + - phpstan.broker.dynamicStaticMethodReturnTypeExtension - class: PHPStan\Rules\PHPUnit\CoversHelper - diff --git a/src/Type/PHPUnit/MockForIntersectionDynamicReturnTypeExtension.php b/src/Type/PHPUnit/MockForIntersectionDynamicReturnTypeExtension.php new file mode 100644 index 0000000..66d8758 --- /dev/null +++ b/src/Type/PHPUnit/MockForIntersectionDynamicReturnTypeExtension.php @@ -0,0 +1,91 @@ +getName() === 'createMockForIntersectionOfInterfaces'; + } + + public function isStaticMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'createStubForIntersectionOfInterfaces'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + return $this->getTypeFromCall($methodReflection, $methodCall->getArgs(), $scope); + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + return $this->getTypeFromCall($methodReflection, $methodCall->getArgs(), $scope); + } + + /** + * @param array $args + */ + private function getTypeFromCall(MethodReflection $methodReflection, array $args, Scope $scope): ?Type + { + if (!isset($args[0])) { + return null; + } + + $interfaces = $scope->getType($args[0]->value); + $constantArrays = $interfaces->getConstantArrays(); + if (count($constantArrays) !== 1) { + return null; + } + + $constantArray = $constantArrays[0]; + if (count($constantArray->getOptionalKeys()) > 0) { + return null; + } + + $result = []; + if ($methodReflection->getName() === 'createMockForIntersectionOfInterfaces') { + $result[] = new ObjectType(MockObject::class); + } else { + $result[] = new ObjectType(Stub::class); + } + + foreach ($constantArray->getValueTypes() as $valueType) { + if (!$valueType->isClassString()->yes()) { + return null; + } + + $values = $valueType->getConstantScalarValues(); + if (count($values) !== 1) { + return null; + } + + $result[] = new ObjectType((string) $values[0]); + } + + return TypeCombinator::intersect(...$result); + } + +} diff --git a/tests/Rules/PHPUnit/MockMethodCallRuleTest.php b/tests/Rules/PHPUnit/MockMethodCallRuleTest.php index f7e89c7..363f438 100644 --- a/tests/Rules/PHPUnit/MockMethodCallRuleTest.php +++ b/tests/Rules/PHPUnit/MockMethodCallRuleTest.php @@ -4,6 +4,8 @@ use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\TestCase; +use function method_exists; use const PHP_VERSION_ID; /** @@ -34,6 +36,17 @@ public function testRule(): void ], ]; + if (method_exists(TestCase::class, 'createMockForIntersectionOfInterfaces')) { // @phpstan-ignore function.alreadyNarrowedType + $expectedErrors[] = [ + 'Trying to mock an undefined method bazMethod() on class MockMethodCall\FooInterface&MockMethodCall\BarInterface.', + 49, + ]; + $expectedErrors[] = [ + 'Trying to mock an undefined method bazMethod() on class MockMethodCall\FooInterface&MockMethodCall\BarInterface.', + 57, + ]; + } + $this->analyse([__DIR__ . '/data/mock-method-call.php'], $expectedErrors); } diff --git a/tests/Rules/PHPUnit/data/mock-method-call.php b/tests/Rules/PHPUnit/data/mock-method-call.php index a4f5aaa..51de6a3 100644 --- a/tests/Rules/PHPUnit/data/mock-method-call.php +++ b/tests/Rules/PHPUnit/data/mock-method-call.php @@ -41,6 +41,22 @@ public function testMockObject(\PHPUnit\Framework\MockObject\MockObject $mock) $mock->method('doFoo'); } + public function testMockForIntersection() + { + $mock = $this->createMockForIntersectionOfInterfaces([FooInterface::class, BarInterface::class]); + $mock->method('fooMethod'); + $mock->method('barMethod'); + $mock->method('bazMethod'); + } + + public function testStubForIntersection() + { + $stub = static::createStubForIntersectionOfInterfaces([FooInterface::class, BarInterface::class]); + $stub->method('fooMethod'); + $stub->method('barMethod'); + $stub->method('bazMethod'); + } + } class Bar { @@ -71,3 +87,11 @@ public function testMockFinalClass() } } + +interface FooInterface { + public function fooMethod(): int; +} + +interface BarInterface { + public function barMethod(): string; +} diff --git a/tests/Type/PHPUnit/MockForIntersectionDynamicReturnTypeExtensionTest.php b/tests/Type/PHPUnit/MockForIntersectionDynamicReturnTypeExtensionTest.php new file mode 100644 index 0000000..dff5384 --- /dev/null +++ b/tests/Type/PHPUnit/MockForIntersectionDynamicReturnTypeExtensionTest.php @@ -0,0 +1,42 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../../extension.neon']; + } + +} diff --git a/tests/Type/PHPUnit/data/mock-for-intersection.php b/tests/Type/PHPUnit/data/mock-for-intersection.php new file mode 100644 index 0000000..e6b72ec --- /dev/null +++ b/tests/Type/PHPUnit/data/mock-for-intersection.php @@ -0,0 +1,37 @@ +createMockForIntersectionOfInterfaces([FooInterface::class, BarInterface::class]), + ); + assertType( + 'MockForIntersection\BarInterface&MockForIntersection\FooInterface&PHPUnit\Framework\MockObject\Stub', + self::createStubForIntersectionOfInterfaces([FooInterface::class, BarInterface::class]), + ); + + + assertType( + 'PHPUnit\Framework\MockObject\MockObject', + $this->createMockForIntersectionOfInterfaces($bool ? [FooInterface::class, BarInterface::class] : [FooInterface::class]), + ); + assertType( + 'PHPUnit\Framework\MockObject\MockObject', + $this->createMockForIntersectionOfInterfaces($bool ? [FooInterface::class] : [BarInterface::class]), + ); + } + +} + +interface FooInterface {} +interface BarInterface {} From 0f6ba8e22a3e919353a8f4615ea3cd2e91626c7f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 11 Feb 2026 15:42:03 +0100 Subject: [PATCH 12/23] DataProviderReturnTypeIgnoreExtension - ignore Iterator, Generator, IteratorAggregate too --- .../DataProviderReturnTypeIgnoreExtension.php | 6 +++- ...aProviderReturnTypeIgnoreExtensionTest.php | 12 ++++--- .../data/data-provider-iterable-value.php | 35 +++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/Type/PHPUnit/DataProviderReturnTypeIgnoreExtension.php b/src/Type/PHPUnit/DataProviderReturnTypeIgnoreExtension.php index be6af67..ebe30d5 100644 --- a/src/Type/PHPUnit/DataProviderReturnTypeIgnoreExtension.php +++ b/src/Type/PHPUnit/DataProviderReturnTypeIgnoreExtension.php @@ -8,6 +8,7 @@ use PHPStan\Analyser\Scope; use PHPStan\Rules\PHPUnit\DataProviderHelper; use PHPStan\Rules\PHPUnit\TestMethodsHelper; +use function in_array; final class DataProviderReturnTypeIgnoreExtension implements IgnoreErrorExtension { @@ -27,7 +28,10 @@ public function __construct( public function shouldIgnore(Error $error, Node $node, Scope $scope): bool { - if ($error->getIdentifier() !== 'missingType.iterableValue') { + if (!in_array($error->getIdentifier(), [ + 'missingType.iterableValue', + 'missingType.generics', + ], true)) { return false; } diff --git a/tests/Type/PHPUnit/DataProviderReturnTypeIgnoreExtensionTest.php b/tests/Type/PHPUnit/DataProviderReturnTypeIgnoreExtensionTest.php index fb5b927..5335fc0 100644 --- a/tests/Type/PHPUnit/DataProviderReturnTypeIgnoreExtensionTest.php +++ b/tests/Type/PHPUnit/DataProviderReturnTypeIgnoreExtensionTest.php @@ -5,6 +5,7 @@ use PHPStan\Rules\Methods\MissingMethodReturnTypehintRule; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use function array_merge; /** * @extends RuleTestCase @@ -23,7 +24,7 @@ public function testRule(): void $this->analyse([__DIR__ . '/data/data-provider-iterable-value.php'], [ [ 'Method DataProviderIterableValueTest\Foo::notADataProvider() return type has no value type specified in iterable type iterable.', - 32, + 41, 'See: https://phpstan.org/blog/solving-phpstan-no-value-type-specified-in-iterable-type' ], ]); @@ -31,8 +32,11 @@ public function testRule(): void static public function getAdditionalConfigFiles(): array { - return [ - __DIR__ . '/data/data-provider-iterable-value.neon' - ]; + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/data/data-provider-iterable-value.neon' + ], + ); } } diff --git a/tests/Type/PHPUnit/data/data-provider-iterable-value.php b/tests/Type/PHPUnit/data/data-provider-iterable-value.php index 613d3b1..8b2f6f3 100644 --- a/tests/Type/PHPUnit/data/data-provider-iterable-value.php +++ b/tests/Type/PHPUnit/data/data-provider-iterable-value.php @@ -2,12 +2,21 @@ namespace DataProviderIterableValueTest; +use ArrayObject; +use Generator; +use Iterator; +use IteratorAggregate; use PHPUnit\Framework\TestCase; +use Traversable; class Foo extends TestCase { /** * @dataProvider dataProvider * @dataProvider dataProvider2 + * @dataProvider dataProvider3 + * @dataProvider dataProvider4 + * @dataProvider dataProvider5 + * @dataProvider dataProvider6 */ public function testFoo():void { @@ -36,4 +45,30 @@ public function notADataProvider(): iterable { [5, 6], ]; } + + public function dataProvider3(): Iterator { + $i = rand(0, 10); + + yield [$i, 2]; + } + + public function dataProvider4(): IteratorAggregate { + $i = rand(0, 10); + + return new ArrayObject([ + [$i, 2], + ]); + } + + public function dataProvider5(): Generator { + $i = rand(0, 10); + + yield [$i, 2]; + } + + public function dataProvider6(): Traversable { + $i = rand(0, 10); + + yield [$i, 2]; + } } From 6ab598e1bc106e6827fd346ae4a12b4a5d634c32 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 14 Feb 2026 08:00:58 +0100 Subject: [PATCH 13/23] DataProviderDataRule: Optimize hot path --- src/Rules/PHPUnit/DataProviderDataRule.php | 12 ++++++------ src/Rules/PHPUnit/TestMethodsHelper.php | 11 ++++++----- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/Rules/PHPUnit/DataProviderDataRule.php b/src/Rules/PHPUnit/DataProviderDataRule.php index ce99467..ac800f5 100644 --- a/src/Rules/PHPUnit/DataProviderDataRule.php +++ b/src/Rules/PHPUnit/DataProviderDataRule.php @@ -58,18 +58,13 @@ public function processNode(Node $node, Scope $scope): array return []; } - $arraysTypes = $this->buildArrayTypesFromNode($node, $scope); - if ($arraysTypes === []) { - return []; - } - - $method = $scope->getFunction(); $classReflection = $scope->getClassReflection(); if ($classReflection === null) { return []; } $testsWithProvider = []; + $method = $scope->getFunction(); $testMethods = $this->testMethodsHelper->getTestMethods($classReflection, $scope); foreach ($testMethods as $testMethod) { foreach ($this->dataProviderHelper->getDataProviderMethods($scope, $testMethod, $classReflection) as [, $providerMethodName]) { @@ -84,6 +79,11 @@ public function processNode(Node $node, Scope $scope): array return []; } + $arraysTypes = $this->buildArrayTypesFromNode($node, $scope); + if ($arraysTypes === []) { + return []; + } + $maxNumberOfParameters = null; foreach ($testsWithProvider as $testMethod) { $num = $testMethod->getNumberOfParameters(); diff --git a/src/Rules/PHPUnit/TestMethodsHelper.php b/src/Rules/PHPUnit/TestMethodsHelper.php index f7eff08..f1efc97 100644 --- a/src/Rules/PHPUnit/TestMethodsHelper.php +++ b/src/Rules/PHPUnit/TestMethodsHelper.php @@ -48,11 +48,12 @@ public function getTestMethodReflection(ClassReflection $classReflection, Method */ public function getTestMethods(ClassReflection $classReflection, Scope $scope): array { - if (array_key_exists($classReflection->getName(), $this->methodCache)) { - return $this->methodCache[$classReflection->getName()]; + $className = $classReflection->getName(); + if (array_key_exists($className, $this->methodCache)) { + return $this->methodCache[$className]; } if (!$classReflection->is(TestCase::class)) { - return $this->methodCache[$classReflection->getName()] = []; + return $this->methodCache[$className] = []; } $testMethods = []; @@ -70,7 +71,7 @@ public function getTestMethods(ClassReflection $classReflection, Scope $scope): if ($docComment !== null) { $methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( $scope->getFile(), - $classReflection->getName(), + $className, $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, $reflectionMethod->getName(), $docComment, @@ -94,7 +95,7 @@ public function getTestMethods(ClassReflection $classReflection, Scope $scope): $testMethods[] = $reflectionMethod; } - return $this->methodCache[$classReflection->getName()] = $testMethods; + return $this->methodCache[$className] = $testMethods; } private function hasTestAnnotation(?ResolvedPhpDocBlock $phpDoc): bool From ffd7013e423702cb423321dab75fde9e19be39a7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 18 Feb 2026 12:15:20 +0100 Subject: [PATCH 14/23] Add Claude React on Comment workflow --- .github/workflows/claude-react-on-comment.yml | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/claude-react-on-comment.yml diff --git a/.github/workflows/claude-react-on-comment.yml b/.github/workflows/claude-react-on-comment.yml new file mode 100644 index 0000000..e7eee11 --- /dev/null +++ b/.github/workflows/claude-react-on-comment.yml @@ -0,0 +1,63 @@ +name: "Claude React on comment" + +on: + issue_comment: + types: [created] + pull_request_review: + types: [submitted] + pull_request_review_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + issues: write + actions: read + +concurrency: + group: claude-pr-reactions-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: false + +jobs: + check-trigger: + name: "Check trigger phrase" + runs-on: ubuntu-latest + timeout-minutes: 1 + outputs: + triggered: ${{ steps.check.outputs.triggered }} + steps: + - name: "Check for trigger phrase" + id: check + env: + COMMENT_BODY: ${{ github.event.comment.body || github.event.review.body || '' }} + run: | + if echo "$COMMENT_BODY" | grep -qF "@phpstan-bot"; then + echo "triggered=true" >> "$GITHUB_OUTPUT" + else + echo "triggered=false" >> "$GITHUB_OUTPUT" + fi + + react: + name: "React on comment" + needs: check-trigger + if: needs.check-trigger.outputs.triggered == 'true' + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: "Checkout" + uses: actions/checkout@v4 + with: + token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + + - name: "React to feedback" + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + trigger_phrase: "@phpstan-bot" + claude_args: "--model claude-opus-4-6" + bot_name: "phpstan-bot" + bot_id: "79867460" + additional_permissions: | + actions: read From 22028902aceb1d142a99714b4516c05d78d027f7 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Feb 2026 07:45:06 +0000 Subject: [PATCH 15/23] Add CLAUDE.md with project documentation for AI assistants Comprehensive guide covering project overview, PHP/PHPUnit version requirements, common commands, project structure, architecture, testing patterns, coding standards, and CI pipeline details. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8421eed --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,130 @@ +# CLAUDE.md + +This file provides guidance for AI assistants working with the phpstan/phpstan-phpunit repository. + +## Project Overview + +This is a PHPStan extension that provides advanced static analysis support for PHPUnit test suites. It offers: + +- **Type extensions**: Correct return types for `createMock()`, `getMockForAbstractClass()`, `getMockFromWsdl()`, `MockBuilder::getMock()`, etc., returning intersection types (e.g., `MockObject&Foo`) so both mock and original class methods are available. +- **PHPDoc interpretation**: Converts `Foo|MockObject` union types in phpDocs to intersection types. +- **Assert type narrowing**: Specifies types of expressions passed to `assertInstanceOf`, `assertTrue`, `assertInternalType`, etc. +- **Early terminating methods**: Defines `fail()`, `markTestIncomplete()`, `markTestSkipped()` as early terminating to prevent false positive undefined variable errors. +- **Strict rules** (in `rules.neon`): Checks for better assertion usage (e.g., prefer `assertTrue()` over `assertSame(true, ...)`), `@covers` validation, data provider declaration checks, and more. + +## PHP Version Requirements + +This repository supports **PHP 7.4+**. Do not use language features unavailable in PHP 7.4 (e.g., enums, fibers, readonly properties, intersection types in code — though they appear in stubs/phpDocs). + +## PHPUnit Compatibility + +The extension supports multiple PHPUnit versions: **^9.5, ^10.5, ^11.5, ^12.0**. Code must be compatible across all these versions. The CI matrix tests all combinations. + +## Common Commands + +```bash +# Install dependencies +composer install + +# Run all checks (lint, coding standard, tests, PHPStan) +make check + +# Run tests only +make tests + +# Run PHPStan analysis +make phpstan + +# Run linting +make lint + +# Install coding standard tool (first time only) +make cs-install + +# Run coding standard checks +make cs + +# Fix coding standard issues +make cs-fix + +# Generate PHPStan baseline +make phpstan-generate-baseline +``` + +## Project Structure + +``` +src/ +├── PhpDoc/PHPUnit/ # PHPDoc type resolution extensions +├── Rules/PHPUnit/ # Static analysis rules for PHPUnit +└── Type/PHPUnit/ # Type-specifying and dynamic return type extensions + └── Assert/ # Assert method type narrowing + +tests/ +├── Rules/PHPUnit/ # Rule tests and test data (data/ subdirectory) +├── Rules/Methods/ # Method call rule tests +├── Type/PHPUnit/ # Type extension tests and test data (data/ subdirectory) +└── bootstrap.php # Test bootstrap (loads Composer autoloader) + +stubs/ # PHPUnit stub files for type definitions +``` + +## Configuration Files + +- **`extension.neon`** — Main extension configuration registered via phpstan/extension-installer. Defines parameters, services (type extensions, helpers), and stub files. +- **`rules.neon`** — Strict PHPUnit-specific rules. Loaded separately; users opt in by including this file. +- **`phpstan.neon`** — Self-analysis configuration (level 8, with strict rules and deprecation rules). +- **`phpstan-baseline.neon`** — Baseline for known PHPStan errors in the project itself. +- **`phpunit.xml`** — PHPUnit configuration for running the test suite. + +## Architecture + +### Type Extensions (`src/Type/PHPUnit/`) + +These implement PHPStan interfaces to provide correct types: + +- `MockBuilderDynamicReturnTypeExtension` — Preserves `MockBuilder` generic type through chained method calls. +- `MockForIntersectionDynamicReturnTypeExtension` — Returns `MockObject&T` intersection types for mock creation methods. +- `Assert/AssertMethodTypeSpecifyingExtension` (and function/static variants) — Narrows types after assert calls (e.g., after `assertInstanceOf(Foo::class, $x)`, `$x` is known to be `Foo`). + +### Rules (`src/Rules/PHPUnit/`) + +These implement `PHPStan\Rules\Rule` to report errors: + +- `AssertSameBooleanExpectedRule`, `AssertSameNullExpectedRule` — Suggest specific assertions over generic `assertSame`. +- `AssertSameWithCountRule` — Suggest `assertCount()` over `assertSame(count(...), ...)`. +- `ClassCoversExistsRule`, `ClassMethodCoversExistsRule` — Validate `@covers` annotations reference existing code. +- `DataProviderDeclarationRule`, `DataProviderDataRule` — Validate data provider declarations and data. +- `MockMethodCallRule` — Check mock method calls are valid. +- `ShouldCallParentMethodsRule` — Verify `setUp()`/`tearDown()` call parent methods. + +### Stubs (`stubs/`) + +PHPStan stub files that provide generic type information for PHPUnit classes (e.g., `TestCase::createMock()` returns `MockObject&T`). + +## Writing Tests + +- **Rule tests** extend `PHPStan\Testing\RuleTestCase`. They implement `getRule()` and call `$this->analyse()` with a test data file path and expected errors array. Test data files live in `tests/Rules/PHPUnit/data/`. +- **Type tests** extend `PHPStan\Testing\TypeInferenceTestCase`. They use `@dataProvider` with `self::gatherAssertTypes()` or `self::dataFileAsserts()` and call `$this->assertFileAsserts()`. Test data files live in `tests/Type/PHPUnit/data/`. +- Both types override `getAdditionalConfigFiles()` to return the path to `extension.neon` (and sometimes `rules.neon`). + +## Coding Standards + +- Uses tabs for indentation (PHP, XML, NEON files). +- Uses spaces for YAML files (indent size 2). +- Coding standard is enforced via [phpstan/build-cs](https://github.com/phpstan/build-cs) (PHPCS with a custom standard). +- Run `make cs` to check, `make cs-fix` to auto-fix. + +## CI Pipeline + +The GitHub Actions workflow (`.github/workflows/build.yml`) runs on the `2.0.x` branch and pull requests: + +1. **Lint** — PHP syntax check across PHP 7.4–8.5. +2. **Coding Standard** — PHPCS checks using build-cs. +3. **Tests** — PHPUnit across PHP 7.4–8.5 × lowest/highest dependencies × PHPUnit 9.5/10.5/11.5/12.0 (with version-appropriate exclusions). +4. **Static Analysis** — PHPStan self-analysis with the same matrix. +5. **Mutation Testing** — Infection framework on PHP 8.2–8.5, requires 100% MSI on changed lines. + +## Development Branch + +The main development branch is `2.0.x`. From 10e8509f852bdd0d27f2d14e45959a216a299b68 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Feb 2026 08:01:01 +0000 Subject: [PATCH 16/23] Add CLAUDE.md to export-ignore in .gitattributes Exclude CLAUDE.md from distribution archives since it is only needed for development and AI assistant guidance, not by end users. Co-Authored-By: Claude Opus 4.6 --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index aae3fd1..581d6e3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,4 @@ /phpstan.neon export-ignore /phpstan-baseline.neon export-ignore /phpunit.xml export-ignore +/CLAUDE.md export-ignore From bb6479cd0493088ece5ce3541caa25bffc48ff29 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Feb 2026 08:16:22 +0000 Subject: [PATCH 17/23] React on issues opened and fall back to issue body Add `issues: types: [opened]` trigger so the workflow also fires when a new issue is created containing the trigger phrase. Add `github.event.issue.body` as a fallback in COMMENT_BODY so the issue description is checked when neither a comment nor a review body is available. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/claude-react-on-comment.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-react-on-comment.yml b/.github/workflows/claude-react-on-comment.yml index e7eee11..487d084 100644 --- a/.github/workflows/claude-react-on-comment.yml +++ b/.github/workflows/claude-react-on-comment.yml @@ -1,6 +1,8 @@ name: "Claude React on comment" on: + issues: + types: [opened] issue_comment: types: [created] pull_request_review: @@ -29,7 +31,7 @@ jobs: - name: "Check for trigger phrase" id: check env: - COMMENT_BODY: ${{ github.event.comment.body || github.event.review.body || '' }} + COMMENT_BODY: ${{ github.event.comment.body || github.event.review.body || github.event.issue.body || '' }} run: | if echo "$COMMENT_BODY" | grep -qF "@phpstan-bot"; then echo "triggered=true" >> "$GITHUB_OUTPUT" From bfdcb6241bde109219a34005952050b09afc1f48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Mirtes?= Date: Tue, 24 Feb 2026 10:08:25 +0100 Subject: [PATCH 18/23] Remove PHPSTAN_BOT_TOKEN from checkout step Removed unused token parameter from checkout step. --- .github/workflows/claude-react-on-comment.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/claude-react-on-comment.yml b/.github/workflows/claude-react-on-comment.yml index 487d084..982a027 100644 --- a/.github/workflows/claude-react-on-comment.yml +++ b/.github/workflows/claude-react-on-comment.yml @@ -49,14 +49,11 @@ jobs: steps: - name: "Checkout" uses: actions/checkout@v4 - with: - token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - name: "React to feedback" uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - github_token: ${{ secrets.PHPSTAN_BOT_TOKEN }} trigger_phrase: "@phpstan-bot" claude_args: "--model claude-opus-4-6" bot_name: "phpstan-bot" From fca9e3acfd28591b380ec04c14326d3bc0143657 Mon Sep 17 00:00:00 2001 From: StepSecurity Bot Date: Tue, 24 Feb 2026 16:37:02 +0000 Subject: [PATCH 19/23] [StepSecurity] ci: Harden GitHub Actions Signed-off-by: StepSecurity Bot --- .github/workflows/build.yml | 51 ++++++++++++++----- .github/workflows/claude-react-on-comment.yml | 14 ++++- .github/workflows/create-tag.yml | 15 ++++-- .github/workflows/lock-closed-issues.yml | 12 ++++- .github/workflows/release-toot.yml | 7 ++- .github/workflows/release-tweet.yml | 7 ++- .github/workflows/release.yml | 11 ++-- 7 files changed, 91 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2df9936..877f0af 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,11 +29,16 @@ jobs: - "8.5" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "${{ matrix.php-version }}" @@ -53,18 +58,23 @@ jobs: runs-on: "ubuntu-latest" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "Checkout build-cs" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: "phpstan/build-cs" path: "build-cs" ref: "2.x" - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.2" @@ -129,11 +139,16 @@ jobs: phpunit-version: "^12.0.9" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "${{ matrix.php-version }}" @@ -196,11 +211,16 @@ jobs: phpunit-version: "^12.0.9" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "${{ matrix.php-version }}" @@ -237,11 +257,16 @@ jobs: operating-system: [ubuntu-latest] steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "Checkout build-infection" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: "phpstan/build-infection" path: "build-infection" @@ -271,7 +296,7 @@ jobs: echo "name=$(git remote show origin | sed -n '/HEAD branch/s/.*: //p')" >> $GITHUB_OUTPUT - name: "Restore result cache" - uses: actions/cache/restore@v5 + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ./tmp key: "result-cache-v1-${{ matrix.php-version }}-${{ github.run_id }}" @@ -292,7 +317,7 @@ jobs: --logger-text=php://stdout - name: "Save result cache" - uses: actions/cache/save@v5 + uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 if: ${{ !cancelled() }} with: path: ./tmp diff --git a/.github/workflows/claude-react-on-comment.yml b/.github/workflows/claude-react-on-comment.yml index 982a027..8d00d6c 100644 --- a/.github/workflows/claude-react-on-comment.yml +++ b/.github/workflows/claude-react-on-comment.yml @@ -28,6 +28,11 @@ jobs: outputs: triggered: ${{ steps.check.outputs.triggered }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Check for trigger phrase" id: check env: @@ -47,11 +52,16 @@ jobs: timeout-minutes: 60 steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "React to feedback" - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@35a9e0292d36f1186f5d842b14eb575074e8b450 # v1.0.57 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} trigger_phrase: "@phpstan-bot" diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index 206477e..07b7281 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -20,33 +20,38 @@ jobs: name: "Create tag" runs-on: "ubuntu-latest" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - name: 'Get Previous tag' id: previoustag - uses: "WyriHaximus/github-action-get-previous-tag@v2" + uses: "WyriHaximus/github-action-get-previous-tag@61819f33034117e6c686e6a31dba995a85afc9de" # v2.0.0 env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" - name: 'Get next versions' id: semvers - uses: "WyriHaximus/github-action-next-semvers@v1" + uses: "WyriHaximus/github-action-next-semvers@d079934efaf011a4cf8912d4637097fe35d32b93" # v1 with: version: ${{ steps.previoustag.outputs.tag }} - name: "Create new minor tag" - uses: rickstaa/action-create-tag@v1 + uses: rickstaa/action-create-tag@a1c7777fcb2fee4f19b0f283ba888afa11678b72 # v1.7.2 if: inputs.version == 'minor' with: tag: ${{ steps.semvers.outputs.minor }} message: ${{ steps.semvers.outputs.minor }} - name: "Create new patch tag" - uses: rickstaa/action-create-tag@v1 + uses: rickstaa/action-create-tag@a1c7777fcb2fee4f19b0f283ba888afa11678b72 # v1.7.2 if: inputs.version == 'patch' with: tag: ${{ steps.semvers.outputs.patch }} diff --git a/.github/workflows/lock-closed-issues.yml b/.github/workflows/lock-closed-issues.yml index e5ac070..c5d27f4 100644 --- a/.github/workflows/lock-closed-issues.yml +++ b/.github/workflows/lock-closed-issues.yml @@ -4,11 +4,21 @@ on: schedule: - cron: '7 0 * * *' +permissions: + contents: read + jobs: lock: + permissions: + issues: write # for dessant/lock-threads to lock issues runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v6 + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0 with: github-token: ${{ github.token }} issue-inactive-days: '31' diff --git a/.github/workflows/release-toot.yml b/.github/workflows/release-toot.yml index 1ba4fd7..21f0951 100644 --- a/.github/workflows/release-toot.yml +++ b/.github/workflows/release-toot.yml @@ -10,7 +10,12 @@ jobs: toot: runs-on: ubuntu-latest steps: - - uses: cbrgm/mastodon-github-action@v2 + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - uses: cbrgm/mastodon-github-action@845250b56b82d94e26bf23984d5e0cf5ced6d18f # v2.1.25 if: ${{ !github.event.repository.private }} with: # GitHub event payload diff --git a/.github/workflows/release-tweet.yml b/.github/workflows/release-tweet.yml index d81f34c..18545f0 100644 --- a/.github/workflows/release-tweet.yml +++ b/.github/workflows/release-tweet.yml @@ -10,7 +10,12 @@ jobs: tweet: runs-on: ubuntu-latest steps: - - uses: Eomm/why-don-t-you-tweet@v2 + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - uses: Eomm/why-don-t-you-tweet@d9ec12835f4d494dda920f95f885df3dba380493 # v2.0.0 if: ${{ !github.event.repository.private }} with: # GitHub event payload diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efb9bbe..be3c273 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,18 +13,23 @@ jobs: runs-on: "ubuntu-latest" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Generate changelog id: changelog - uses: metcalfc/changelog-generator@v4.6.2 + uses: metcalfc/changelog-generator@3f82cef08fe5dcf57c591fe165e70e1d5032e15a # v4.6.2 with: myToken: ${{ secrets.PHPSTAN_BOT_TOKEN }} - name: "Create release" id: create-release - uses: actions/create-release@v1 + uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1.1.4 env: GITHUB_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} with: From 53c1301375cfbe16365f09b43c24603f7c7b90eb Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 24 Feb 2026 21:28:31 +0100 Subject: [PATCH 20/23] Lint workflows --- .github/actionlint-matcher.json | 17 ++++ .github/actionlint.yaml | 22 ++++++ .github/workflows/build.yml | 4 +- .github/workflows/lint-workflows.yml | 113 +++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 .github/actionlint-matcher.json create mode 100644 .github/actionlint.yaml create mode 100644 .github/workflows/lint-workflows.yml diff --git a/.github/actionlint-matcher.json b/.github/actionlint-matcher.json new file mode 100644 index 0000000..4613e16 --- /dev/null +++ b/.github/actionlint-matcher.json @@ -0,0 +1,17 @@ +{ + "problemMatcher": [ + { + "owner": "actionlint", + "pattern": [ + { + "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$", + "file": 1, + "line": 2, + "column": 3, + "message": 4, + "code": 5 + } + ] + } + ] +} diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..b24efa8 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,22 @@ +self-hosted-runner: + # Labels of self-hosted runner in array of strings. + labels: [] + +# Configuration variables in array of strings defined in your repository or +# organization. `null` means disabling configuration variables check. +# Empty array means no configuration variable is allowed. +config-variables: null + +# Configuration for file paths. The keys are glob patterns to match to file +# paths relative to the repository root. The values are the configurations for +# the file paths. Note that the path separator is always '/'. +# The following configurations are available. +# +# "ignore" is an array of regular expression patterns. Matched error messages +# are ignored. This is similar to the "-ignore" command line option. +paths: + .github/workflows/**/*.{yml,yaml}: + # List of regular expressions to filter errors by the error messages. + ignore: + # Ignore the specific error from shellcheck + - 'shellcheck reported issue in this script: SC2129:.+' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 877f0af..38a16d5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -288,12 +288,12 @@ jobs: run: | php build-infection/bin/infection-config.php \ > infection.json5 - cat infection.json5 | jq + jq . infection.json5 - name: "Determine default branch" id: default-branch run: | - echo "name=$(git remote show origin | sed -n '/HEAD branch/s/.*: //p')" >> $GITHUB_OUTPUT + echo "name=$(git remote show origin | sed -n '/HEAD branch/s/.*: //p')" >> "$GITHUB_OUTPUT" - name: "Restore result cache" uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 diff --git a/.github/workflows/lint-workflows.yml b/.github/workflows/lint-workflows.yml new file mode 100644 index 0000000..92a5729 --- /dev/null +++ b/.github/workflows/lint-workflows.yml @@ -0,0 +1,113 @@ +# Configuration from: +# https://github.com/johnbillion/plugin-infrastructure/blob/571cba96190304963285181e2b928d941b9ec7c4/.github/workflows/reusable-workflow-lint.yml + +name: Lint GitHub Actions workflows +on: + pull_request: + push: + branches: + - "2.0.x" + +permissions: {} + +jobs: + actionlint: + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Check workflow files + run: | + echo "::add-matcher::.github/actionlint-matcher.json" + bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + ./actionlint -color + shell: bash + + octoscan: + name: Octoscan + runs-on: ubuntu-latest + permissions: + security-events: write # Required for codeql-action/upload-sarif to upload SARIF files. + timeout-minutes: 10 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Run octoscan + id: octoscan + uses: synacktiv/action-octoscan@6b1cf2343893dfb9e5f75652388bd2dc83f456b0 # v1.0.0 + with: + filter_triggers: '' + + - name: Upload SARIF file to GitHub + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + with: + sarif_file: "${{steps.octoscan.outputs.sarif_output}}" + category: octoscan + wait-for-processing: false + + poutine: + name: Poutine + runs-on: ubuntu-latest + permissions: + security-events: write # Required for codeql-action/upload-sarif to upload SARIF files. + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run Poutine + uses: boostsecurityio/poutine-action@84c0a0d32e8d57ae12651222be1eb15351429228 # v0.15.2 + + - name: Upload poutine SARIF file + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + with: + sarif_file: results.sarif + category: poutine + wait-for-processing: false + + zizmor: + name: Zizmor + runs-on: ubuntu-latest + permissions: + security-events: write # Required for codeql-action/upload-sarif to upload SARIF files. + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 + with: + enable-cache: false + + - name: Run zizmor + run: uvx zizmor@1.20.0 --persona=auditor --format=sarif --strict-collection . > results.sarif + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + with: + sarif_file: results.sarif + category: zizmor + wait-for-processing: false From 1c9e1df984ae2fe05ae7d6df14657b4e4848ccc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Mirtes?= Date: Tue, 24 Feb 2026 21:44:18 +0100 Subject: [PATCH 21/23] Refactor build workflow to use GITHUB_BASE_REF Removed the step to determine the default branch and updated git fetch and infection commands to use GITHUB_BASE_REF. --- .github/workflows/build.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 38a16d5..5e9d53f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -290,11 +290,6 @@ jobs: > infection.json5 jq . infection.json5 - - name: "Determine default branch" - id: default-branch - run: | - echo "name=$(git remote show origin | sed -n '/HEAD branch/s/.*: //p')" >> "$GITHUB_OUTPUT" - - name: "Restore result cache" uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: @@ -305,9 +300,9 @@ jobs: - name: "Run infection" run: | - git fetch --depth=1 origin ${{ steps.default-branch.outputs.name }} + git fetch --depth=1 origin "$GITHUB_BASE_REF" infection \ - --git-diff-base=origin/${{ steps.default-branch.outputs.name }} \ + --git-diff-base=origin/"$GITHUB_BASE_REF" \ --git-diff-lines \ --ignore-msi-with-no-mutations \ --min-msi=100 \ From db27c1f04dc3bc1e960c5124a1f7596a0ec88016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Mirtes?= Date: Thu, 26 Feb 2026 13:25:18 +0100 Subject: [PATCH 22/23] Delete .github/workflows/claude-react-on-comment.yml --- .github/workflows/claude-react-on-comment.yml | 72 ------------------- 1 file changed, 72 deletions(-) delete mode 100644 .github/workflows/claude-react-on-comment.yml diff --git a/.github/workflows/claude-react-on-comment.yml b/.github/workflows/claude-react-on-comment.yml deleted file mode 100644 index 8d00d6c..0000000 --- a/.github/workflows/claude-react-on-comment.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: "Claude React on comment" - -on: - issues: - types: [opened] - issue_comment: - types: [created] - pull_request_review: - types: [submitted] - pull_request_review_comment: - types: [created] - -permissions: - contents: write - pull-requests: write - issues: write - actions: read - -concurrency: - group: claude-pr-reactions-${{ github.event.pull_request.number || github.event.issue.number }} - cancel-in-progress: false - -jobs: - check-trigger: - name: "Check trigger phrase" - runs-on: ubuntu-latest - timeout-minutes: 1 - outputs: - triggered: ${{ steps.check.outputs.triggered }} - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 - with: - egress-policy: audit - - - name: "Check for trigger phrase" - id: check - env: - COMMENT_BODY: ${{ github.event.comment.body || github.event.review.body || github.event.issue.body || '' }} - run: | - if echo "$COMMENT_BODY" | grep -qF "@phpstan-bot"; then - echo "triggered=true" >> "$GITHUB_OUTPUT" - else - echo "triggered=false" >> "$GITHUB_OUTPUT" - fi - - react: - name: "React on comment" - needs: check-trigger - if: needs.check-trigger.outputs.triggered == 'true' - runs-on: ubuntu-latest - timeout-minutes: 60 - - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 - with: - egress-policy: audit - - - name: "Checkout" - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - name: "React to feedback" - uses: anthropics/claude-code-action@35a9e0292d36f1186f5d842b14eb575074e8b450 # v1.0.57 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - trigger_phrase: "@phpstan-bot" - claude_args: "--model claude-opus-4-6" - bot_name: "phpstan-bot" - bot_id: "79867460" - additional_permissions: | - actions: read From e2c6207c34ef023635f15ed135a22468d1856631 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 12 Mar 2026 14:39:03 +0100 Subject: [PATCH 23/23] Fix build (#287) --- composer.json | 2 +- src/Rules/PHPUnit/AnnotationHelper.php | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index 08cd088..f842bb4 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "keywords": ["static analysis"], "require": { "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.1.32" + "phpstan/phpstan": "^2.1.41" }, "conflict": { "phpunit/phpunit": "<7.0" diff --git a/src/Rules/PHPUnit/AnnotationHelper.php b/src/Rules/PHPUnit/AnnotationHelper.php index 21623ca..dc89bdb 100644 --- a/src/Rules/PHPUnit/AnnotationHelper.php +++ b/src/Rules/PHPUnit/AnnotationHelper.php @@ -5,7 +5,6 @@ use PhpParser\Comment\Doc; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\RuleErrorBuilder; -use function array_key_exists; use function in_array; use function preg_match; use function preg_split; @@ -43,14 +42,10 @@ public function processDocComment(Doc $docComment): array foreach ($docCommentLines as $docCommentLine) { // These annotations can't be retrieved using the getResolvedPhpDoc method on the FileTypeMapper as they are not present when they are invalid $annotation = preg_match('/(?@(?[a-zA-Z]+)(?\s*)(?.*))/', $docCommentLine, $matches); - if ($annotation === false) { + if ($annotation === false || $matches === []) { continue; // Line without annotation } - if (array_key_exists('property', $matches) === false || array_key_exists('whitespace', $matches) === false || array_key_exists('annotation', $matches) === false) { - continue; - } - if (!in_array($matches['property'], self::ANNOTATIONS_WITH_PARAMS, true) || $matches['whitespace'] !== '') { continue; }