From 14f389313e50f4fe261331f981ea92b9b74bdf0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Sat, 24 Oct 2026 21:08:02 +0200 Subject: [PATCH] Do not treat method templates resolved to their bound as unresolved The unresolved template check skipped every template whose resolved type equals its bound. That is the expected fallback for class templates the call does not provide, but a method template is inferred from the arguments: a template inferred as `mixed` equals its default bound and was wrongly skipped. This suppressed the already-narrowed report for @phpstan-assert calls whose asserted type was already satisfied. --- .../Helper/DefaultNarrowingHelper.php | 2 +- ...sibleCheckTypeStaticMethodCallRuleTest.php | 32 +++++++++++++++ .../data/no-named-arguments-assert.php | 39 +++++++++++++++++++ turbo-ext/src/DefaultNarrowingHelper.cpp | 20 ++++++---- 4 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/Rules/Comparison/data/no-named-arguments-assert.php diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index a0de3ba5e22..f762a308775 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -881,7 +881,7 @@ static function (string $parameterName) use ($argsMap, $getArgType): ?Type { static function (Type $type, callable $traverse) use ($templateTypeMap, &$containsUnresolvedTemplate) { if ($type instanceof TemplateType && $type->getScope()->getClassName() !== null) { $resolvedType = $templateTypeMap->getType($type->getName()); - if ($resolvedType === null || $type->getBound()->equals($resolvedType)) { + if ($resolvedType === null || ($type->getScope()->getFunctionName() === null && $type->getBound()->equals($resolvedType))) { $containsUnresolvedTemplate = true; return $type; } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php index 1cf941dafd9..a8b9db7c3d7 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php @@ -143,6 +143,38 @@ public function testAssertUnresolvedGeneric(): void $this->analyse([__DIR__ . '/data/assert-unresolved-generic.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testNoNamedArgumentsAssertIsAlwaysSatisfied(): void + { + $this->treatPhpDocTypesAsCertain = false; + $this->analyse([__DIR__ . '/data/no-named-arguments-assert.php'], [ + [ + 'Call to static method NoNamedArgumentsAssert\QueryBuilder::validateVariadicParameter() with list will always evaluate to true.', + 14, + ], + [ + 'Call to static method NoNamedArgumentsAssert\QueryBuilder::validateVariadicParameter() with list will always evaluate to true.', + 22, + ], + ]); + } + + #[RequiresPhp('>= 8.0.0')] + public function testNoNamedArgumentsAssertIsAlwaysSatisfiedTreatPhpDocTypesAsCertain(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/no-named-arguments-assert.php'], [ + [ + 'Call to static method NoNamedArgumentsAssert\QueryBuilder::validateVariadicParameter() with list will always evaluate to true.', + 14, + ], + [ + 'Call to static method NoNamedArgumentsAssert\QueryBuilder::validateVariadicParameter() with list will always evaluate to true.', + 22, + ], + ]); + } + public static function dataReportAlwaysTrueInLastCondition(): iterable { yield [false, [ diff --git a/tests/PHPStan/Rules/Comparison/data/no-named-arguments-assert.php b/tests/PHPStan/Rules/Comparison/data/no-named-arguments-assert.php new file mode 100644 index 00000000000..1ea5d54ec8d --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/no-named-arguments-assert.php @@ -0,0 +1,39 @@ += 8.0 +declare(strict_types = 1); + +namespace NoNamedArgumentsAssert; + +final class QueryBuilder +{ + + /** + * @no-named-arguments + */ + public function select(mixed ...$select): void + { + self::validateVariadicParameter($select); + } + + /** + * @no-named-arguments + */ + public function groupBy(string ...$groupBy): void + { + self::validateVariadicParameter($groupBy); + } + + /** + * @template TItem + * @param TItem[] $parameter + * @phpstan-assert list $parameter + */ + private static function validateVariadicParameter(array $parameter): void + { + if (array_is_list($parameter)) { + return; + } + + throw new \InvalidArgumentException(); + } + +} diff --git a/turbo-ext/src/DefaultNarrowingHelper.cpp b/turbo-ext/src/DefaultNarrowingHelper.cpp index 85bdbb3038e..30963b7bae8 100644 --- a/turbo-ext/src/DefaultNarrowingHelper.cpp +++ b/turbo-ext/src/DefaultNarrowingHelper.cpp @@ -2027,15 +2027,19 @@ class DefaultNarrowingHelper if (UNEXPECTED(resolvedType.isUndef())) return; bool unresolved = resolvedType.isNull(); if (!unresolved) { - zv::Val bound = pt_type_call(Z_OBJ_P(type), PT_LC("getbound"), 0, NULL); - if (UNEXPECTED(bound.isUndef())) return; - if (UNEXPECTED(Z_TYPE_P(bound.raw()) != IS_OBJECT)) { - (void) callOnNonObject("equals", bound.raw()); - return; + zv::Val functionName = pt_type_call(Z_OBJ_P(templateScope.raw()), PT_LC("getfunctionname"), 0, NULL); + if (UNEXPECTED(functionName.isUndef())) return; + if (functionName.isNull()) { + zv::Val bound = pt_type_call(Z_OBJ_P(type), PT_LC("getbound"), 0, NULL); + if (UNEXPECTED(bound.isUndef())) return; + if (UNEXPECTED(Z_TYPE_P(bound.raw()) != IS_OBJECT)) { + (void) callOnNonObject("equals", bound.raw()); + return; + } + zv::Val equals = pt_type_op(Z_OBJ_P(bound.raw()), PT_OP_EQUALS, 1, resolvedType.raw()); + if (UNEXPECTED(equals.isUndef())) return; + unresolved = zend_is_true(equals.raw()); } - zv::Val equals = pt_type_op(Z_OBJ_P(bound.raw()), PT_OP_EQUALS, 1, resolvedType.raw()); - if (UNEXPECTED(equals.isUndef())) return; - unresolved = zend_is_true(equals.raw()); } if (unresolved) { zval *target = containsUnresolvedTemplate;