From 4c948ccd5271c58797194c08ce5870c9304b236b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Mor=C3=A1vek?= Date: Wed, 2 Sep 2026 21:12:15 +0200 Subject: [PATCH 1/6] Open 2.1-dev --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 53b2591..06369b9 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ }, "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "2.1-dev" }, "phpstan": { "includes": [ From 7382ed0f8b94258407dfd852a696cc0ab61adce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Mor=C3=A1vek?= Date: Wed, 2 Sep 2026 21:36:23 +0200 Subject: [PATCH 2/6] Drop support for PHP 7 Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yaml | 3 +-- composer.json | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 00bf554..9cf9ca6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,7 +16,6 @@ jobs: fail-fast: false matrix: php: - - 7.4 - 8.0 - 8.1 - 8.2 @@ -54,7 +53,7 @@ jobs: - uses: actions/checkout@v7 - uses: shivammathur/setup-php@v2 with: - php-version: 7.4 + php-version: 8.0 coverage: none - name: Build run: composer update --no-interaction --no-progress --prefer-dist --prefer-stable diff --git a/composer.json b/composer.json index 06369b9..dbb396a 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ } ], "require": { - "php": ">=7.4 <8.6", + "php": ">=8.0 <8.6", "nikic/php-parser": "^4.13.2 || ^v5.0.2", "phpstan/phpstan": "^2.0" }, From 7db1d05b4203a8805fbf281fb7ce3b05dea21a74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Mor=C3=A1vek?= Date: Wed, 2 Sep 2026 21:37:22 +0200 Subject: [PATCH 3/6] Fix code style with PHP 8.0 as the minimum version Slevomat sniffs enable checks based on the PHP version phpcs runs on: native mixed type hints and trailing commas in multi-line declarations. Co-Authored-By: Claude Opus 5 --- .../AssertTypeSpecifyingExtension.php | 4 +-- .../AssertTypeSpecifyingExtensionTest.php | 3 +-- tests/Type/NetteTester/Fixtures/Foo.php | 25 ++----------------- 3 files changed, 5 insertions(+), 27 deletions(-) diff --git a/src/Type/NetteTester/AssertTypeSpecifyingExtension.php b/src/Type/NetteTester/AssertTypeSpecifyingExtension.php index a5b1de7..8b0c86a 100644 --- a/src/Type/NetteTester/AssertTypeSpecifyingExtension.php +++ b/src/Type/NetteTester/AssertTypeSpecifyingExtension.php @@ -42,7 +42,7 @@ public function getClass(): string public function isStaticMethodSupported( MethodReflection $staticMethodReflection, StaticCall $node, - TypeSpecifierContext $context + TypeSpecifierContext $context, ): bool { $methodName = $staticMethodReflection->getName(); @@ -55,7 +55,7 @@ public function specifyTypes( MethodReflection $staticMethodReflection, StaticCall $node, Scope $scope, - TypeSpecifierContext $context + TypeSpecifierContext $context, ): SpecifiedTypes { if ($node->isFirstClassCallable()) { diff --git a/tests/Type/NetteTester/AssertTypeSpecifyingExtensionTest.php b/tests/Type/NetteTester/AssertTypeSpecifyingExtensionTest.php index 82241d9..d832e2f 100644 --- a/tests/Type/NetteTester/AssertTypeSpecifyingExtensionTest.php +++ b/tests/Type/NetteTester/AssertTypeSpecifyingExtensionTest.php @@ -27,12 +27,11 @@ public function dataFileAsserts(): iterable /** * @dataProvider dataFileAsserts - * @param mixed ...$args */ public function testFileAsserts( string $assertType, string $file, - ...$args + mixed ...$args, ): void { $this->assertFileAsserts($assertType, $file, ...$args); diff --git a/tests/Type/NetteTester/Fixtures/Foo.php b/tests/Type/NetteTester/Fixtures/Foo.php index d10c687..521ac64 100644 --- a/tests/Type/NetteTester/Fixtures/Foo.php +++ b/tests/Type/NetteTester/Fixtures/Foo.php @@ -11,29 +11,11 @@ class Foo { /** - * @param mixed $a - * @param mixed $b - * @param mixed $c - * @param mixed $d - * @param mixed $e * @param string[] $f * @param int[] $g - * @param mixed $h - * @param mixed $i - * @param mixed $j - * @param mixed $k - * @param mixed $l - * @param mixed $m - * @param mixed $n - * @param mixed $o - * @param mixed $p - * @param mixed $q - * @param mixed $r - * @param mixed $s - * @param mixed $t * @param string|NULL $u */ - public function doFoo($a, $b, $c, $d, $e, array $f, array $g, $h, $i, $j, $k, $l, $m, $n, $o, $p, $q, $r, $s, $t, ?string $u): void + public function doFoo(mixed $a, mixed $b, mixed $c, mixed $d, mixed $e, array $f, array $g, mixed $h, mixed $i, mixed $j, mixed $k, mixed $l, mixed $m, mixed $n, mixed $o, mixed $p, mixed $q, mixed $r, mixed $s, mixed $t, ?string $u): void { Assert::null($a); assertType('null', $a); @@ -116,10 +98,7 @@ public function doFoo($a, $b, $c, $d, $e, array $f, array $g, $h, $i, $j, $k, $l assertType("''", $z); } - /** - * @param mixed $value - */ - public function testTypeWithMultiplePossibilities($value): void + public function testTypeWithMultiplePossibilities(mixed $value): void { $type = rand(0, 1) > 0 ? 'int' : 'string'; assertType("'int'|'string'", $type); From 8e9f5745c41b6bee2341d241b5679288480c46e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Mor=C3=A1vek?= Date: Wed, 2 Sep 2026 21:38:24 +0200 Subject: [PATCH 4/6] Fix handling of unpacked arguments Assert::same(...$args) crashed the analysis with an ArgumentCountError, Assert::null(...$args) specified `$args === null` and thus made all the following code unreachable. Bail out instead of specifying anything when the arguments cannot be resolved positionally. Co-Authored-By: Claude Opus 5 --- ...ssertMethodExpressionResolversProvider.php | 23 +++++++++++++++++++ .../AssertTypeSpecifyingExtension.php | 14 +++++++++++ tests/Type/NetteTester/Fixtures/Foo.php | 12 ++++++++++ 3 files changed, 49 insertions(+) diff --git a/src/Type/NetteTester/AssertMethodExpressionResolversProvider.php b/src/Type/NetteTester/AssertMethodExpressionResolversProvider.php index 7c158b8..2eb67b6 100644 --- a/src/Type/NetteTester/AssertMethodExpressionResolversProvider.php +++ b/src/Type/NetteTester/AssertMethodExpressionResolversProvider.php @@ -30,6 +30,11 @@ final class AssertMethodExpressionResolversProvider */ private static ?array $typeResolvers = null; + /** + * @var array|NULL + */ + private static ?array $requiredArgumentCounts = null; + /** * @return array */ @@ -124,6 +129,24 @@ public static function getResolvers(): array return self::$resolvers; } + /** + * Number of `Arg` parameters (i.e. excluding the leading `Scope`) each resolver needs to be called with. + * + * @return array + */ + public static function getRequiredArgumentCounts(): array + { + if (self::$requiredArgumentCounts === null) { + $requiredArgumentCounts = []; + foreach (self::getResolvers() as $name => $resolver) { + $requiredArgumentCounts[$name] = (new \ReflectionFunction($resolver))->getNumberOfRequiredParameters() - 1; + } + self::$requiredArgumentCounts = $requiredArgumentCounts; + } + + return self::$requiredArgumentCounts; + } + /** * @return array */ diff --git a/src/Type/NetteTester/AssertTypeSpecifyingExtension.php b/src/Type/NetteTester/AssertTypeSpecifyingExtension.php index 8b0c86a..9f653ad 100644 --- a/src/Type/NetteTester/AssertTypeSpecifyingExtension.php +++ b/src/Type/NetteTester/AssertTypeSpecifyingExtension.php @@ -13,6 +13,8 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\StaticMethodTypeSpecifyingExtension; +use function array_slice; +use function count; class AssertTypeSpecifyingExtension implements StaticMethodTypeSpecifyingExtension, TypeSpecifierAwareExtension { @@ -24,6 +26,18 @@ class AssertTypeSpecifyingExtension implements StaticMethodTypeSpecifyingExtensi */ private static function createExpression(Scope $scope, string $name, array $args): ?Expr { + $requiredArgumentCount = AssertMethodExpressionResolversProvider::getRequiredArgumentCounts()[$name]; + if (count($args) < $requiredArgumentCount) { + return null; + } + + $args = array_slice($args, 0, $requiredArgumentCount); + foreach ($args as $arg) { + if ($arg->unpack) { + return null; + } + } + $resolvers = AssertMethodExpressionResolversProvider::getResolvers(); $resolver = $resolvers[$name]; return $resolver($scope, ...$args); diff --git a/tests/Type/NetteTester/Fixtures/Foo.php b/tests/Type/NetteTester/Fixtures/Foo.php index 521ac64..bb6f3be 100644 --- a/tests/Type/NetteTester/Fixtures/Foo.php +++ b/tests/Type/NetteTester/Fixtures/Foo.php @@ -106,4 +106,16 @@ public function testTypeWithMultiplePossibilities(mixed $value): void assertType('int|string', $value); } + /** + * @param array $args + */ + public function testUnpackedArguments(array $args): void + { + Assert::null(...$args); + assertType('array', $args); + + Assert::same(...$args); + assertType('array', $args); + } + } From 853fe85d3bcab0de2f50897ef7d76524356eb85b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Mor=C3=A1vek?= Date: Wed, 2 Sep 2026 21:40:22 +0200 Subject: [PATCH 5/6] Ignore calls with unresolved named arguments PHPStan normalizes named arguments to their positions before invoking the extension, but only when the call is valid - Assert::null(description: $x) used to be read as Assert::null($x) and narrowed the description instead. Also adds test coverage for regular named argument calls. Co-Authored-By: Claude Opus 5 --- .../AssertTypeSpecifyingExtension.php | 2 +- tests/Type/NetteTester/Fixtures/Foo.php | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/Type/NetteTester/AssertTypeSpecifyingExtension.php b/src/Type/NetteTester/AssertTypeSpecifyingExtension.php index 9f653ad..eb4f1af 100644 --- a/src/Type/NetteTester/AssertTypeSpecifyingExtension.php +++ b/src/Type/NetteTester/AssertTypeSpecifyingExtension.php @@ -33,7 +33,7 @@ private static function createExpression(Scope $scope, string $name, array $args $args = array_slice($args, 0, $requiredArgumentCount); foreach ($args as $arg) { - if ($arg->unpack) { + if ($arg->unpack || $arg->name !== null) { // PHPStan resolves named arguments to their positions, unless the call is invalid return null; } } diff --git a/tests/Type/NetteTester/Fixtures/Foo.php b/tests/Type/NetteTester/Fixtures/Foo.php index bb6f3be..b6bbf11 100644 --- a/tests/Type/NetteTester/Fixtures/Foo.php +++ b/tests/Type/NetteTester/Fixtures/Foo.php @@ -118,4 +118,25 @@ public function testUnpackedArguments(array $args): void assertType('array', $args); } + /** + * @param array $c + */ + public function testNamedArguments(mixed $a, mixed $b, array $c): void + { + Assert::null(description: 'must be null', actual: $a); + assertType('null', $a); + + Assert::type(value: $b, type: 'int'); + assertType('int', $b); + + Assert::count(value: $c, count: 1); + assertType('non-empty-array', $c); + } + + public function testUnresolvableNamedArguments(?string $description): void + { + Assert::null(description: $description); + assertType('string|null', $description); + } + } From 0ff6c298b916122bb8523001b88efbd7d97cb944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Mor=C3=A1vek?= Date: Wed, 2 Sep 2026 21:41:10 +0200 Subject: [PATCH 6/6] Add support for Assert::hasKey() and Assert::hasNotKey() Co-Authored-By: Claude Opus 5 --- README.md | 4 ++- ...ssertMethodExpressionResolversProvider.php | 11 ++++++++ tests/Type/NetteTester/Fixtures/Foo.php | 26 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4d53955..844e525 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ This extension specifies types of values passed to: * `Assert::notSame()` * `Assert::type()` * `Assert::count()` +* `Assert::hasKey()` +* `Assert::hasNotKey()` ## Installation @@ -61,7 +63,7 @@ If you have enabled `checkAlwaysTrueCheckTypeFunctionCall: true`, you will need ``` parameters: ignoreErrors: - - '~Call to static method Tester\\Assert::(type|count|same|notSame)\(\) with .* and .* will always evaluate to true\.~' + - '~Call to static method Tester\\Assert::(type|count|same|notSame|hasKey|hasNotKey)\(\) with .* and .* will always evaluate to true\.~' - '~Call to static method Tester\\Assert::(null|notNull|true|false|truthy|falsey|nan)\(\) with .* will always evaluate to true\.~' ``` diff --git a/src/Type/NetteTester/AssertMethodExpressionResolversProvider.php b/src/Type/NetteTester/AssertMethodExpressionResolversProvider.php index 2eb67b6..f95267f 100644 --- a/src/Type/NetteTester/AssertMethodExpressionResolversProvider.php +++ b/src/Type/NetteTester/AssertMethodExpressionResolversProvider.php @@ -10,6 +10,7 @@ use PhpParser\Node\Expr\BinaryOp\Equal; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\BinaryOp\NotIdentical; +use PhpParser\Node\Expr\BooleanNot; use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Instanceof_; @@ -123,6 +124,16 @@ public static function getResolvers(): array $count->value, ), ), + 'hasKey' => fn (Scope $scope, Arg $key, Arg $value): Expr => new FuncCall( + new Name('array_key_exists'), + [$key, $value], + ), + 'hasNotKey' => fn (Scope $scope, Arg $key, Arg $value): Expr => new BooleanNot( + new FuncCall( + new Name('array_key_exists'), + [$key, $value], + ), + ), ]; } diff --git a/tests/Type/NetteTester/Fixtures/Foo.php b/tests/Type/NetteTester/Fixtures/Foo.php index b6bbf11..264a678 100644 --- a/tests/Type/NetteTester/Fixtures/Foo.php +++ b/tests/Type/NetteTester/Fixtures/Foo.php @@ -139,4 +139,30 @@ public function testUnresolvableNamedArguments(?string $description): void assertType('string|null', $description); } + /** + * @param array{foo?: int, bar: string} $shape + * @param array $array + */ + public function testHasKey(array $shape, array $array): void + { + Assert::hasKey('foo', $shape); + assertType('array{foo: int, bar: string}', $shape); + + Assert::hasKey('foo', $array, 'description is ignored'); + assertType("non-empty-array&hasOffset('foo')", $array); + } + + /** + * @param array{foo?: int, bar: string} $shape + * @param array $array + */ + public function testHasNotKey(array $shape, array $array): void + { + Assert::hasNotKey('foo', $shape); + assertType('array{bar: string}', $shape); + + Assert::hasNotKey(actual: $array, key: 'foo'); + assertType('array', $array); + } + }