From 0bc1217c00318bd8f4352834a83f53e23dea74ee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:31:34 +0000 Subject: [PATCH] Fix phpstan/phpstan#10231: value-of with OffsetAccessType resolving to *ERROR* - Skip toArrayKey() normalization in TypeNodeResolver when array key type is a LateResolvableType, preventing premature resolution and loss of type information - Resolve late-resolvable types after resolveToBounds in TypehintHelper decideType check, so the PHPDoc type compatibility check works correctly - Handle value-of gracefully in ValueOfType::getResult() when the inner type is a LateResolvableType that resolves to a scalar - New regression test in tests/PHPStan/Analyser/nsrt/bug-10231.php --- src/PhpDoc/TypeNodeResolver.php | 31 +++++++++------ src/Type/TypehintHelper.php | 2 +- src/Type/ValueOfType.php | 10 ++++- tests/PHPStan/Analyser/nsrt/bug-10231.php | 48 +++++++++++++++++++++++ 4 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10231.php diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 8af74bf9264..8226370d449 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -85,6 +85,7 @@ use PHPStan\Type\IntersectionType; use PHPStan\Type\IterableType; use PHPStan\Type\KeyOfType; +use PHPStan\Type\LateResolvableType; use PHPStan\Type\MixedType; use PHPStan\Type\NewObjectType; use PHPStan\Type\NonAcceptingNeverType; @@ -678,20 +679,24 @@ static function (string $variance): TemplateTypeVariance { if (count($genericTypes) === 1) { // array $arrayType = new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), $genericTypes[0]); } elseif (count($genericTypes) === 2) { // array - $keyType = TypeCombinator::intersect($genericTypes[0]->toArrayKey(), new UnionType([ - new IntegerType(), - new StringType(), - ]))->toArrayKey(); - $finiteTypes = $keyType->getFiniteTypes(); - if ( - count($finiteTypes) === 1 - && ($finiteTypes[0] instanceof ConstantStringType || $finiteTypes[0] instanceof ConstantIntegerType) - ) { - $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); - $arrayBuilder->setOffsetValueType($finiteTypes[0], $genericTypes[1], true); - $arrayType = $arrayBuilder->getArray(); + if ($genericTypes[0] instanceof LateResolvableType) { + $arrayType = new ArrayType($genericTypes[0], $genericTypes[1]); } else { - $arrayType = new ArrayType($keyType, $genericTypes[1]); + $keyType = TypeCombinator::intersect($genericTypes[0]->toArrayKey(), new UnionType([ + new IntegerType(), + new StringType(), + ]))->toArrayKey(); + $finiteTypes = $keyType->getFiniteTypes(); + if ( + count($finiteTypes) === 1 + && ($finiteTypes[0] instanceof ConstantStringType || $finiteTypes[0] instanceof ConstantIntegerType) + ) { + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + $arrayBuilder->setOffsetValueType($finiteTypes[0], $genericTypes[1], true); + $arrayType = $arrayBuilder->getArray(); + } else { + $arrayType = new ArrayType($keyType, $genericTypes[1]); + } } } else { return new ErrorType(); diff --git a/src/Type/TypehintHelper.php b/src/Type/TypehintHelper.php index 076fedcaccc..7cbb69609b4 100644 --- a/src/Type/TypehintHelper.php +++ b/src/Type/TypehintHelper.php @@ -123,7 +123,7 @@ public static function decideType( ($type->isCallable()->yes() && $phpDocType->isCallable()->yes()) || ( (!$phpDocType instanceof NeverType || ($type instanceof MixedType && !$type->isExplicitMixed())) - && $type->isSuperTypeOf(TemplateTypeHelper::resolveToBounds($phpDocType))->yes() + && $type->isSuperTypeOf(TypeUtils::resolveLateResolvableTypes(TemplateTypeHelper::resolveToBounds($phpDocType)))->yes() ) ) { $resultType = $phpDocType; diff --git a/src/Type/ValueOfType.php b/src/Type/ValueOfType.php index e2d3f0516c2..78657a2438a 100644 --- a/src/Type/ValueOfType.php +++ b/src/Type/ValueOfType.php @@ -81,7 +81,15 @@ protected function getResult(): Type return new UnionType($valueTypes); } - return $this->type->getIterableValueType(); + $iterableValueType = $this->type->getIterableValueType(); + if ($iterableValueType instanceof ErrorType && $this->type instanceof LateResolvableType) { + $resolvedType = $this->type->resolve(); + if (!$resolvedType instanceof ErrorType) { + return $resolvedType; + } + } + + return $iterableValueType; } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-10231.php b/tests/PHPStan/Analyser/nsrt/bug-10231.php new file mode 100644 index 00000000000..243f2432f39 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10231.php @@ -0,0 +1,48 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug10231; + +use function PHPStan\Testing\assertType; + +class HelloWorld +{ + /** + * @template TGroupColumnName of array-key + * @template TValueColumnName of array-key + * @template TArray of array + * @param array $input + * @param TGroupColumnName $groupByColumn + * @param TValueColumnName $valueColumnName + * + * @return array< + * value-of, + * list> + * > + */ + public static function groupByColumn(array $input, string|int $groupByColumn, string|int $valueColumnName): array + { + $output = []; + foreach ($input as $result) { + $output[$result[$groupByColumn]][] = $result[$valueColumnName]; + } + + return $output; + } +} + +/** @var array $input */ +$input = [ + ['event_id' => '111', 'id' => 1], + ['event_id' => '111', 'id' => 2], + ['event_id' => '222', 'id' => 99], +]; + +$result = HelloWorld::groupByColumn( + $input, + 'event_id', + 'id', +); + +assertType('array>', $result);