diff --git a/build/phpstan.neon b/build/phpstan.neon index 3469b8fca5..ea0ff67ade 100644 --- a/build/phpstan.neon +++ b/build/phpstan.neon @@ -90,6 +90,9 @@ parameters: paths: - ../tests/PHPStan/Fixture reportUnmatched: false # constants on enums, not reported on PHP8- + - + identifier: shipmonk.deadMethod + path: ../src/Type/Traits/MaybeOffsetAccessibleTypeTrait.php - message: ''' #^Access to constant on deprecated class DeprecatedAnnotations\\DeprecatedFoo\: diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 02b0bb3538..b7a37d6e3b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1380,7 +1380,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\ArrayType is error-prone and deprecated. Use Type::isArray() or Type::getArrays() instead.' identifier: phpstanApi.instanceofType - count: 4 + count: 5 path: src/Type/IntersectionType.php - diff --git a/src/Analyser/ForeachSourceTracking.php b/src/Analyser/ForeachSourceTracking.php new file mode 100644 index 0000000000..9ef95719d9 --- /dev/null +++ b/src/Analyser/ForeachSourceTracking.php @@ -0,0 +1,22 @@ + */ private array $falseyScopes = []; + /** @var array */ + private array $foreachSources = []; + private ?self $fiberScope = null; /** @var non-empty-string|null */ @@ -750,6 +753,15 @@ public function getMaybeDefinedVariables(): array return $variables; } + /** + * @internal + * @return array + */ + public function getForeachSources(): array + { + return $this->foreachSources; + } + private function isGlobalVariable(string $variableName): bool { return in_array($variableName, self::SUPERGLOBAL_VARIABLES, true); @@ -1986,6 +1998,7 @@ public function pushInFunctionCall($reflection, ?ParameterReflection $parameter, if ($rememberTypes) { $functionScope->resolvedTypes = $this->resolvedTypes; } + $functionScope->foreachSources = $this->foreachSources; return $functionScope; } @@ -2015,6 +2028,7 @@ public function popInFunctionCall(): self ); $parentScope->resolvedTypes = $this->resolvedTypes; + $parentScope->foreachSources = $this->foreachSources; return $parentScope; } @@ -3004,6 +3018,15 @@ public function enterForeach(self $originalScope, Expr $iteratee, string $valueN $nativeValueType, TrinaryLogic::createYes(), ); + + // Track the foreach source for bidirectional narrowing + $scope->foreachSources = $this->foreachSources; + $scope->foreachSources[$valueName] = new ForeachSourceTracking( + $valueName, + $iteratee, + $iterateeType, + ); + if ($valueByRef && $iterateeType->isArray()->yes() && $iterateeType->isConstantArray()->no()) { $scope = $scope->assignExpression( new IntertwinedVariableByReferenceWithExpr($valueName, $iteratee, new SetOffsetValueTypeExpr( @@ -3099,6 +3122,7 @@ public function enterExpressionAssign(Expr $expr): self $scope->resolvedTypes = $this->resolvedTypes; $scope->truthyScopes = $this->truthyScopes; $scope->falseyScopes = $this->falseyScopes; + $scope->foreachSources = $this->foreachSources; return $scope; } @@ -3130,6 +3154,7 @@ public function exitExpressionAssign(Expr $expr): self $scope->resolvedTypes = $this->resolvedTypes; $scope->truthyScopes = $this->truthyScopes; $scope->falseyScopes = $this->falseyScopes; + $scope->foreachSources = $this->foreachSources; return $scope; } @@ -3176,6 +3201,7 @@ public function setAllowedUndefinedExpression(Expr $expr): self $scope->resolvedTypes = $this->resolvedTypes; $scope->truthyScopes = $this->truthyScopes; $scope->falseyScopes = $this->falseyScopes; + $scope->foreachSources = $this->foreachSources; return $scope; } @@ -3207,6 +3233,7 @@ public function unsetAllowedUndefinedExpression(Expr $expr): self $scope->resolvedTypes = $this->resolvedTypes; $scope->truthyScopes = $this->truthyScopes; $scope->falseyScopes = $this->falseyScopes; + $scope->foreachSources = $this->foreachSources; return $scope; } @@ -3798,7 +3825,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } } - return $scope->scopeFactory->create( + $newScope = $scope->scopeFactory->create( $scope->context, $scope->isDeclareStrictTypes(), $scope->getFunction(), @@ -3816,6 +3843,11 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $scope->parentScope, $scope->nativeTypesPromoted, ); + + // Preserve foreachSources when filtering by specified types + $newScope->foreachSources = $scope->foreachSources; + + return $newScope; } /** @@ -3876,6 +3908,7 @@ public function exitFirstLevelStatements(): self $scope->resolvedTypes = $this->resolvedTypes; $scope->truthyScopes = $this->truthyScopes; $scope->falseyScopes = $this->falseyScopes; + $scope->foreachSources = $this->foreachSources; $this->scopeOutOfFirstLevelStatement = $scope; return $scope; @@ -3949,7 +3982,7 @@ public function mergeWith(?self $otherScope): self unset($theirNativeExpressionTypes[$exprString]); } - return $this->scopeFactory->create( + $scope = $this->scopeFactory->create( $this->context, $this->isDeclareStrictTypes(), $this->getFunction(), @@ -3967,6 +4000,11 @@ public function mergeWith(?self $otherScope): self $this->parentScope, $this->nativeTypesPromoted, ); + + // Preserve foreachSources when merging scopes + $scope->foreachSources = $this->foreachSources; + + return $scope; } /** diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 79eb168577..9ef29fda79 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -150,7 +150,7 @@ public function specifyTypesInCondition( } else { $type = new ObjectType($className); } - return $this->create($exprNode, $type, $context, $scope)->setRootExpr($expr); + return $this->create($exprNode, $type, $context, $scope, true)->setRootExpr($expr); } $classType = $scope->getType($expr->class); @@ -179,16 +179,38 @@ public function specifyTypesInCondition( $type, new ObjectWithoutClassType(), ); - return $this->create($exprNode, $type, $context, $scope)->setRootExpr($expr); + return $this->create($exprNode, $type, $context, $scope, true)->setRootExpr($expr); } elseif ($context->false() && !$uncertainty) { $exprType = $scope->getType($expr->expr); if (!$type->isSuperTypeOf($exprType)->yes()) { - return $this->create($exprNode, $type, $context, $scope)->setRootExpr($expr); + return $this->create($exprNode, $type, $context, $scope, true)->setRootExpr($expr); } } } + + // Handle instanceof on array elements: $array[0] instanceof Foo + if ($exprNode instanceof ArrayDimFetch) { + $dimFetch = $exprNode; + $arrayExpr = $dimFetch->var; + $dim = $dimFetch->dim; + + // Only narrow when offset is constant (conservative approach) + if ($this->isConstantOffset($dim)) { + $arrayType = $scope->getType($arrayExpr); + + // Narrow the array type based on the instanceof check + $narrowedArrayType = $this->narrowArrayFromElementCheck( + $arrayType, + $type, + $context, + ); + + return $this->create($arrayExpr, $narrowedArrayType, $context, $scope, true)->setRootExpr($expr); + } + } + if ($context->true()) { - return $this->create($exprNode, new ObjectWithoutClassType(), $context, $scope)->setRootExpr($exprNode); + return $this->create($exprNode, new ObjectWithoutClassType(), $context, $scope, true)->setRootExpr($exprNode); } } elseif ($expr instanceof Node\Expr\BinaryOp\Identical) { return $this->resolveIdentical($expr, $scope, $context); @@ -1777,6 +1799,7 @@ public function create( Type $type, TypeSpecifierContext $context, Scope $scope, + bool $propagateForeachNarrowing = false, ): SpecifiedTypes { if ($expr instanceof Instanceof_ || $expr instanceof Expr\List_) { @@ -1815,9 +1838,93 @@ public function create( } } + if ($propagateForeachNarrowing) { + return $this->addForeachNarrowingPropagation($types, $scope); + } + return $types; } + /** + * Propagate type narrowing from foreach value variables to their source arrays + * + * WARNING: This method implements AGGRESSIVE type narrowing that may create + * unsound inferences. When a foreach value variable is narrowed via instanceof, + * the entire array's item type is narrowed to the same type. This assumes + * all elements in the array share the narrowed type, which may not be true. + * + * Example: + * ```php + * foreach ($animals as $animal) { + * if ($animal instanceof Dog) { + * // $animals is narrowed to list + * // BUT: Array may still contain Cat instances! + * // This is UNSAFE and could hide type errors + * } + * } + * ``` + * + * Users should be aware of this limitation when relying on array narrowing + * in foreach loops. Consider using explicit type assertions or more precise + * type guards when working with mixed-type arrays. + * + * @param SpecifiedTypes $types The types already specified in this condition + * @param Scope $scope The current scope + * @return SpecifiedTypes Updated types with foreach propagation applied + */ + private function addForeachNarrowingPropagation(SpecifiedTypes $types, Scope $scope): SpecifiedTypes + { + // Only MutatingScope has foreachSources tracking + if (!$scope instanceof MutatingScope) { + return $types; + } + + $foreachSources = $scope->getForeachSources(); + if ($foreachSources === []) { + return $types; + } + + $additionalTypes = []; + + // Process sureTypes (types that ARE true in if branch) + foreach ($types->getSureTypes() as [$exprNode, $narrowedType]) { + // Only process simple variable expressions for foreach source tracking + if (!$exprNode instanceof Expr\Variable || !is_string($exprNode->name)) { + continue; + } + + $varName = $exprNode->name; + if (!isset($foreachSources[$varName])) { + continue; + } + + $source = $foreachSources[$varName]; + $sourceArrayExpr = $source->arrayExpr; + $originalArrayType = $source->originalArrayType; + + // Narrow the array's item type using the method from Phase 2 + $narrowedArrayType = $originalArrayType->narrowItemType($narrowedType); + + // Only add if narrowing actually changed the type + if ($narrowedArrayType->equals($originalArrayType)) { + continue; + } + + $additionalTypes[] = new SpecifiedTypes( + [$this->exprPrinter->printExpr($sourceArrayExpr) => [$sourceArrayExpr, $narrowedArrayType]], + [], + ); + } + + // Union all the additional types with the original result + $result = $types; + foreach ($additionalTypes as $additional) { + $result = $result->unionWith($additional); + } + + return $result; + } + private function createForExpr( Expr $expr, Type $type, @@ -2668,4 +2775,28 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope return (new SpecifiedTypes([], []))->setRootExpr($expr); } + private function isConstantOffset(?Expr $dim): bool + { + if ($dim === null) { + return false; + } + + return $dim instanceof Node\Scalar\Int_ || $dim instanceof Node\Scalar\String_; + } + + private function narrowArrayFromElementCheck( + Type $arrayType, + Type $instanceofType, + TypeSpecifierContext $context, + ): Type + { + if ($context->true()) { + // True branch: narrow array item type based on instanceof check + return $arrayType->narrowItemType($instanceofType); + } + + // False branch: conservative approach, don't narrow + return $arrayType; + } + } diff --git a/src/Type/Accessory/AccessoryArrayListType.php b/src/Type/Accessory/AccessoryArrayListType.php index d6624108f2..ff5bffb28d 100644 --- a/src/Type/Accessory/AccessoryArrayListType.php +++ b/src/Type/Accessory/AccessoryArrayListType.php @@ -514,4 +514,9 @@ public function hasTemplateOrLateResolvableType(): bool return false; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/Accessory/AccessoryLiteralStringType.php b/src/Type/Accessory/AccessoryLiteralStringType.php index abf0b2cbc4..1f11ebde22 100644 --- a/src/Type/Accessory/AccessoryLiteralStringType.php +++ b/src/Type/Accessory/AccessoryLiteralStringType.php @@ -169,6 +169,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + public function unsetOffset(Type $offsetType): Type { return new ErrorType(); diff --git a/src/Type/Accessory/AccessoryLowercaseStringType.php b/src/Type/Accessory/AccessoryLowercaseStringType.php index 13ce997f52..d45eeccb29 100644 --- a/src/Type/Accessory/AccessoryLowercaseStringType.php +++ b/src/Type/Accessory/AccessoryLowercaseStringType.php @@ -166,6 +166,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + public function unsetOffset(Type $offsetType): Type { return new ErrorType(); diff --git a/src/Type/Accessory/AccessoryNonEmptyStringType.php b/src/Type/Accessory/AccessoryNonEmptyStringType.php index a635ea8618..a831f09e5e 100644 --- a/src/Type/Accessory/AccessoryNonEmptyStringType.php +++ b/src/Type/Accessory/AccessoryNonEmptyStringType.php @@ -171,6 +171,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + public function unsetOffset(Type $offsetType): Type { return new ErrorType(); diff --git a/src/Type/Accessory/AccessoryNonFalsyStringType.php b/src/Type/Accessory/AccessoryNonFalsyStringType.php index dc5548789a..8ed822e0f3 100644 --- a/src/Type/Accessory/AccessoryNonFalsyStringType.php +++ b/src/Type/Accessory/AccessoryNonFalsyStringType.php @@ -173,6 +173,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + public function unsetOffset(Type $offsetType): Type { return new ErrorType(); diff --git a/src/Type/Accessory/AccessoryNumericStringType.php b/src/Type/Accessory/AccessoryNumericStringType.php index 625c82675d..16e3283618 100644 --- a/src/Type/Accessory/AccessoryNumericStringType.php +++ b/src/Type/Accessory/AccessoryNumericStringType.php @@ -170,6 +170,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + public function unsetOffset(Type $offsetType): Type { return new ErrorType(); diff --git a/src/Type/Accessory/AccessoryUppercaseStringType.php b/src/Type/Accessory/AccessoryUppercaseStringType.php index f4f63666fe..545432fbc6 100644 --- a/src/Type/Accessory/AccessoryUppercaseStringType.php +++ b/src/Type/Accessory/AccessoryUppercaseStringType.php @@ -166,6 +166,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + public function unsetOffset(Type $offsetType): Type { return new ErrorType(); diff --git a/src/Type/Accessory/HasMethodType.php b/src/Type/Accessory/HasMethodType.php index 66d8365595..72e022bdb2 100644 --- a/src/Type/Accessory/HasMethodType.php +++ b/src/Type/Accessory/HasMethodType.php @@ -202,4 +202,9 @@ public function hasTemplateOrLateResolvableType(): bool return false; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/Accessory/HasOffsetType.php b/src/Type/Accessory/HasOffsetType.php index 004c4b8cb8..53516393ee 100644 --- a/src/Type/Accessory/HasOffsetType.php +++ b/src/Type/Accessory/HasOffsetType.php @@ -157,6 +157,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + public function unsetOffset(Type $offsetType): Type { if ($this->offsetType->isSuperTypeOf($offsetType)->yes()) { diff --git a/src/Type/Accessory/HasOffsetValueType.php b/src/Type/Accessory/HasOffsetValueType.php index 60a4478a06..bdb3d4f5a7 100644 --- a/src/Type/Accessory/HasOffsetValueType.php +++ b/src/Type/Accessory/HasOffsetValueType.php @@ -194,6 +194,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return new self($this->offsetType, $valueType); } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + public function unsetOffset(Type $offsetType): Type { if ($this->offsetType->isSuperTypeOf($offsetType)->yes()) { diff --git a/src/Type/Accessory/HasPropertyType.php b/src/Type/Accessory/HasPropertyType.php index a364e32731..ae82eee91d 100644 --- a/src/Type/Accessory/HasPropertyType.php +++ b/src/Type/Accessory/HasPropertyType.php @@ -185,4 +185,9 @@ public function hasTemplateOrLateResolvableType(): bool return false; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/Accessory/NonEmptyArrayType.php b/src/Type/Accessory/NonEmptyArrayType.php index d06699b90d..55661be14e 100644 --- a/src/Type/Accessory/NonEmptyArrayType.php +++ b/src/Type/Accessory/NonEmptyArrayType.php @@ -499,4 +499,9 @@ public function hasTemplateOrLateResolvableType(): bool return false; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/Accessory/OversizedArrayType.php b/src/Type/Accessory/OversizedArrayType.php index d624455997..ea7e855a2c 100644 --- a/src/Type/Accessory/OversizedArrayType.php +++ b/src/Type/Accessory/OversizedArrayType.php @@ -147,6 +147,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + public function unsetOffset(Type $offsetType): Type { return new ErrorType(); diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index f6550a5845..d36a68a4a5 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -416,6 +416,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T ); } + public function narrowItemType(Type $narrowedItemType): Type + { + return new self($this->keyType, $narrowedItemType); + } + public function unsetOffset(Type $offsetType): Type { $offsetType = $offsetType->toArrayKey(); diff --git a/src/Type/BooleanType.php b/src/Type/BooleanType.php index 1782dafa77..4f30cc59b1 100644 --- a/src/Type/BooleanType.php +++ b/src/Type/BooleanType.php @@ -198,4 +198,9 @@ public function hasTemplateOrLateResolvableType(): bool return false; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index cf9998fbe2..77a9f2e7a2 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -816,4 +816,9 @@ public function hasTemplateOrLateResolvableType(): bool return $this->getReturnType()->hasTemplateOrLateResolvableType(); } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/ConditionalType.php b/src/Type/ConditionalType.php index f154fb5368..b2d5bedc08 100644 --- a/src/Type/ConditionalType.php +++ b/src/Type/ConditionalType.php @@ -217,4 +217,9 @@ private function getSubjectWithTargetRemovedType(): Type return $this->subjectWithTargetRemovedType ??= TypeCombinator::remove($this->subject, $this->target); } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/ConditionalTypeForParameter.php b/src/Type/ConditionalTypeForParameter.php index 0fd8cf4475..db245524ed 100644 --- a/src/Type/ConditionalTypeForParameter.php +++ b/src/Type/ConditionalTypeForParameter.php @@ -174,4 +174,9 @@ public function toPhpDocNode(): TypeNode ); } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 6cff7ef3af..83703d0d78 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -713,6 +713,17 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $builder->getArray(); } + public function narrowItemType(Type $narrowedItemType): Type + { + $builder = ConstantArrayTypeBuilder::createFromConstantArray($this); + + foreach ($this->keyTypes as $keyType) { + $builder->setOffsetValueType($keyType, $narrowedItemType); + } + + return $builder->getArray(); + } + public function unsetOffset(Type $offsetType): Type { $offsetType = $offsetType->toArrayKey(); diff --git a/src/Type/FloatType.php b/src/Type/FloatType.php index 5df57fe795..d892e0a72b 100644 --- a/src/Type/FloatType.php +++ b/src/Type/FloatType.php @@ -301,4 +301,9 @@ public function hasTemplateOrLateResolvableType(): bool return false; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/IntegerRangeType.php b/src/Type/IntegerRangeType.php index 50eac67a04..b7e5692581 100644 --- a/src/Type/IntegerRangeType.php +++ b/src/Type/IntegerRangeType.php @@ -754,4 +754,9 @@ public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType return parent::looseCompare($type, $phpVersion); } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/IntegerType.php b/src/Type/IntegerType.php index 62dafd4ade..eaf88ef795 100644 --- a/src/Type/IntegerType.php +++ b/src/Type/IntegerType.php @@ -204,4 +204,9 @@ public function hasTemplateOrLateResolvableType(): bool return false; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 60090bdfaa..8b828c810e 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -955,6 +955,29 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this->intersectTypes(static fn (Type $type): Type => $type->setExistingOffsetValueType($offsetType, $valueType)); } + public function narrowItemType(Type $narrowedItemType): Type + { + $types = []; + $hasArrayType = false; + + foreach ($this->types as $type) { + if ($type instanceof AccessoryArrayListType) { + $types[] = $type; + } elseif ($type instanceof ArrayType) { + $hasArrayType = true; + $types[] = $type->narrowItemType($narrowedItemType); + } else { + $types[] = $type; + } + } + + if (!$hasArrayType) { + return $this; + } + + return new IntersectionType($types); + } + public function unsetOffset(Type $offsetType): Type { return $this->intersectTypes(static fn (Type $type): Type => $type->unsetOffset($offsetType)); diff --git a/src/Type/IterableType.php b/src/Type/IterableType.php index 46e004ae23..3fcc40c44a 100644 --- a/src/Type/IterableType.php +++ b/src/Type/IterableType.php @@ -537,4 +537,9 @@ public function hasTemplateOrLateResolvableType(): bool return $this->keyType->hasTemplateOrLateResolvableType() || $this->itemType->hasTemplateOrLateResolvableType(); } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/KeyOfType.php b/src/Type/KeyOfType.php index 10a4cb2ea5..cb7721c21d 100644 --- a/src/Type/KeyOfType.php +++ b/src/Type/KeyOfType.php @@ -91,4 +91,9 @@ public function toPhpDocNode(): TypeNode return new GenericTypeNode(new IdentifierTypeNode('key-of'), [$this->type->toPhpDocNode()]); } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/MixedType.php b/src/Type/MixedType.php index 80a0bc2f0b..7238a7da4a 100644 --- a/src/Type/MixedType.php +++ b/src/Type/MixedType.php @@ -175,6 +175,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return new self($this->isExplicitMixed); } + public function narrowItemType(Type $narrowedItemType): Type + { + return new self($this->isExplicitMixed); + } + public function unsetOffset(Type $offsetType): Type { if ($this->subtractedType !== null) { diff --git a/src/Type/NeverType.php b/src/Type/NeverType.php index c762202f0b..a729d7c5a6 100644 --- a/src/Type/NeverType.php +++ b/src/Type/NeverType.php @@ -299,6 +299,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return new ErrorType(); } + public function narrowItemType(Type $narrowedItemType): Type + { + return new ErrorType(); + } + public function unsetOffset(Type $offsetType): Type { return new NeverType(); diff --git a/src/Type/NewObjectType.php b/src/Type/NewObjectType.php index 93d14c6936..9486e3f5c0 100644 --- a/src/Type/NewObjectType.php +++ b/src/Type/NewObjectType.php @@ -91,4 +91,9 @@ public function toPhpDocNode(): TypeNode return new GenericTypeNode(new IdentifierTypeNode('new'), [$this->type->toPhpDocNode()]); } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/NonexistentParentClassType.php b/src/Type/NonexistentParentClassType.php index 74fb77a130..834a0a598b 100644 --- a/src/Type/NonexistentParentClassType.php +++ b/src/Type/NonexistentParentClassType.php @@ -238,4 +238,9 @@ public function hasTemplateOrLateResolvableType(): bool return false; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/NullType.php b/src/Type/NullType.php index 5c7730ee9f..ed4f977ec8 100644 --- a/src/Type/NullType.php +++ b/src/Type/NullType.php @@ -210,6 +210,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + public function unsetOffset(Type $offsetType): Type { return $this; diff --git a/src/Type/ObjectShapeType.php b/src/Type/ObjectShapeType.php index 57d48a0d0d..0fbcdbb83c 100644 --- a/src/Type/ObjectShapeType.php +++ b/src/Type/ObjectShapeType.php @@ -574,4 +574,9 @@ public function hasTemplateOrLateResolvableType(): bool return false; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index 3e755bab60..a501b1fcd5 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -1472,6 +1472,15 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this; } + public function narrowItemType(Type $narrowedItemType): Type + { + if ($this->isOffsetAccessible()->no()) { + return new ErrorType(); + } + + return $this; + } + public function unsetOffset(Type $offsetType): Type { if ($this->isOffsetAccessible()->no()) { diff --git a/src/Type/ObjectWithoutClassType.php b/src/Type/ObjectWithoutClassType.php index c1417f24d6..90a35ddd1e 100644 --- a/src/Type/ObjectWithoutClassType.php +++ b/src/Type/ObjectWithoutClassType.php @@ -219,4 +219,9 @@ public function hasTemplateOrLateResolvableType(): bool return false; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/OffsetAccessType.php b/src/Type/OffsetAccessType.php index 5e4ef1aec3..34548ecbae 100644 --- a/src/Type/OffsetAccessType.php +++ b/src/Type/OffsetAccessType.php @@ -114,4 +114,9 @@ public function toPhpDocNode(): TypeNode ); } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/ResourceType.php b/src/Type/ResourceType.php index 9f28095dd8..8eaa209f80 100644 --- a/src/Type/ResourceType.php +++ b/src/Type/ResourceType.php @@ -130,4 +130,9 @@ public function hasTemplateOrLateResolvableType(): bool return false; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/StaticType.php b/src/Type/StaticType.php index 2876bfb15e..aef56e5a88 100644 --- a/src/Type/StaticType.php +++ b/src/Type/StaticType.php @@ -462,6 +462,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this->getStaticObjectType()->setExistingOffsetValueType($offsetType, $valueType); } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this->getStaticObjectType()->narrowItemType($narrowedItemType); + } + public function unsetOffset(Type $offsetType): Type { return $this->getStaticObjectType()->unsetOffset($offsetType); diff --git a/src/Type/StrictMixedType.php b/src/Type/StrictMixedType.php index 953cb19054..7d495be93d 100644 --- a/src/Type/StrictMixedType.php +++ b/src/Type/StrictMixedType.php @@ -366,6 +366,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return new ErrorType(); } + public function narrowItemType(Type $narrowedItemType): Type + { + return new ErrorType(); + } + public function unsetOffset(Type $offsetType): Type { return new ErrorType(); diff --git a/src/Type/StringType.php b/src/Type/StringType.php index 730869022f..26508d82df 100644 --- a/src/Type/StringType.php +++ b/src/Type/StringType.php @@ -105,6 +105,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + public function unsetOffset(Type $offsetType): Type { return new ErrorType(); diff --git a/src/Type/Traits/LateResolvableTypeTrait.php b/src/Type/Traits/LateResolvableTypeTrait.php index 4a5dd1e0b9..c68745abf0 100644 --- a/src/Type/Traits/LateResolvableTypeTrait.php +++ b/src/Type/Traits/LateResolvableTypeTrait.php @@ -274,6 +274,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this->resolve()->setExistingOffsetValueType($offsetType, $valueType); } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this->resolve()->narrowItemType($narrowedItemType); + } + public function unsetOffset(Type $offsetType): Type { return $this->resolve()->unsetOffset($offsetType); diff --git a/src/Type/Traits/MaybeOffsetAccessibleTypeTrait.php b/src/Type/Traits/MaybeOffsetAccessibleTypeTrait.php index c0f0f44ea2..783353e0be 100644 --- a/src/Type/Traits/MaybeOffsetAccessibleTypeTrait.php +++ b/src/Type/Traits/MaybeOffsetAccessibleTypeTrait.php @@ -39,6 +39,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + public function unsetOffset(Type $offsetType): Type { return $this; diff --git a/src/Type/Traits/NonOffsetAccessibleTypeTrait.php b/src/Type/Traits/NonOffsetAccessibleTypeTrait.php index d74dc8d0d1..6c7f3d2b31 100644 --- a/src/Type/Traits/NonOffsetAccessibleTypeTrait.php +++ b/src/Type/Traits/NonOffsetAccessibleTypeTrait.php @@ -34,6 +34,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return new ErrorType(); } + public function narrowItemType(Type $narrowedItemType): Type + { + return new ErrorType(); + } + public function unsetOffset(Type $offsetType): Type { return new ErrorType(); diff --git a/src/Type/Type.php b/src/Type/Type.php index 58d37a52a4..5ab3125f75 100644 --- a/src/Type/Type.php +++ b/src/Type/Type.php @@ -151,6 +151,12 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type; + /** + * Narrows the item type of iterable types (arrays, lists). + * Used for bidirectional type narrowing in foreach loops. + */ + public function narrowItemType(Type $narrowedItemType): Type; + public function unsetOffset(Type $offsetType): Type; public function getKeysArrayFiltered(Type $filterValueType, TrinaryLogic $strict): Type; diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index c5c9f65415..edb7ba6934 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -793,6 +793,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $this->unionTypes(static fn (Type $type): Type => $type->setExistingOffsetValueType($offsetType, $valueType)); } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->narrowItemType($narrowedItemType)); + } + public function unsetOffset(Type $offsetType): Type { return $this->unionTypes(static fn (Type $type): Type => $type->unsetOffset($offsetType)); diff --git a/src/Type/ValueOfType.php b/src/Type/ValueOfType.php index 0e31dec7eb..3159a8aea9 100644 --- a/src/Type/ValueOfType.php +++ b/src/Type/ValueOfType.php @@ -108,4 +108,9 @@ public function toPhpDocNode(): TypeNode return new GenericTypeNode(new IdentifierTypeNode('value-of'), [$this->type->toPhpDocNode()]); } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/src/Type/VoidType.php b/src/Type/VoidType.php index ca864245e6..88d76ea6e1 100644 --- a/src/Type/VoidType.php +++ b/src/Type/VoidType.php @@ -274,4 +274,9 @@ public function hasTemplateOrLateResolvableType(): bool return false; } + public function narrowItemType(Type $narrowedItemType): Type + { + return $this; + } + } diff --git a/tests/PHPStan/Analyser/ForeachSourceTrackingTest.php b/tests/PHPStan/Analyser/ForeachSourceTrackingTest.php new file mode 100644 index 0000000000..6852771769 --- /dev/null +++ b/tests/PHPStan/Analyser/ForeachSourceTrackingTest.php @@ -0,0 +1,49 @@ +reflectionProvider = self::createReflectionProvider(); + $this->scopeFactory = self::createScopeFactory($this->reflectionProvider, self::getContainer()->getByType(TypeSpecifier::class)); + } + + public function testGetForeachSourcesReturnsArray(): void + { + $scope = $this->scopeFactory->create(ScopeContext::create('test.php')); + + $foreachSources = $scope->getForeachSources(); + $this->assertCount(0, $foreachSources); + } + + public function testEnterForeachCreatesTracking(): void + { + $originalScope = $this->scopeFactory->create(ScopeContext::create('test.php')); + + $arrayExpr = new Variable('array'); + $scope = $originalScope->enterForeach($originalScope, $arrayExpr, 'value', null, false); + + $foreachSources = $scope->getForeachSources(); + $this->assertArrayHasKey('value', $foreachSources); + + $tracking = $foreachSources['value']; + $this->assertSame('value', $tracking->valueVarName); + $this->assertSame($arrayExpr, $tracking->arrayExpr); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/issue-13959.php b/tests/PHPStan/Analyser/nsrt/issue-13959.php new file mode 100644 index 0000000000..8257cf21e5 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/issue-13959.php @@ -0,0 +1,86 @@ + $ids */ + $ids = []; + + foreach ($ids as $id) { + if ($id instanceof TagId) { + // Before fix: Type was still list + // After fix: Type is narrowed to list + assertType('non-empty-list', $ids); + + // The narrowed type allows us to call TagId-specific methods + // (if TagId had any specific methods) + } + } + + // Type should be restored after the loop + assertType('list', $ids); +} + +/** + * Test with more specific scenario from the issue + */ +class User {} +class Admin extends User { + public function adminMethod(): void {} +} + +/** @param list $users */ +function processUsers(array $users): void +{ + foreach ($users as $user) { + if ($user instanceof Admin) { + // Before fix: $users is list - cannot call adminMethod() + // After fix: $users is list - can call adminMethod() + assertType('non-empty-list', $users); + + // This should work now: + $user->adminMethod(); + } + } +} + +/** + * Test with direct array access (also part of the issue) + */ +/** @param array $users */ +function testDirectAccess(array $users): void +{ + if ($users[0] instanceof Admin) { + // Before fix: Type was array + // After fix: Type is narrowed with hasOffsetValue metadata + assertType('non-empty-array&hasOffsetValue(0, Issue13959\Admin)', $users); + } +} + +/** + * Test with list marker preservation + */ +/** @param list $users */ +function testListMarker(array $users): void +{ + foreach ($users as $user) { + if ($user instanceof Admin) { + // The list marker should be preserved + assertType('non-empty-list', $users); + } + } +} diff --git a/tests/PHPStan/Analyser/nsrt/list-union-narrowing.php b/tests/PHPStan/Analyser/nsrt/list-union-narrowing.php new file mode 100644 index 0000000000..f8f9505f97 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/list-union-narrowing.php @@ -0,0 +1,332 @@ + $ids */ +function testForeachBasic(array $ids): void +{ + foreach ($ids as $id) { + if ($id instanceof GlobalTagId) { + // Type narrows to the specific subclass + assertType('non-empty-list', $ids); + } + } +} + +// Test 2: Direct array access narrowing with integer key +/** @param array $animals */ +function testDirectAccess(array $animals): void +{ + if ($animals[0] instanceof Dog) { + // Note: Direct access uses hasOffsetValue metadata + assertType('non-empty-array&hasOffsetValue(0, ListUnionNarrowing\Dog)', $animals); + } +} + +// Test 3: List marker preservation +/** @param list $animals */ +function testListMarkerPreservation(array $animals): void +{ + foreach ($animals as $animal) { + if ($animal instanceof Dog) { + assertType('non-empty-list', $animals); // NOT array + } + } +} + +// Test 4: Union narrowing +/** @param list $ids */ +function testUnionNarrowing(array $ids): void +{ + foreach ($ids as $id) { + if ($id instanceof GlobalTagId) { + // Narrows to the specific type + assertType('non-empty-list', $ids); + } + } +} + +// Test 5: Type restoration after conditional +/** @param list $animals */ +function testTypeRestoration(array $animals): void +{ + foreach ($animals as $animal) { + if ($animal instanceof Dog) { + assertType('non-empty-list', $animals); + } + assertType('non-empty-list', $animals); // Restored + } +} + +// Test 6: String key array access +/** @param array $data */ +function testStringKey(array $data): void +{ + if ($data['key'] instanceof Dog) { + assertType('non-empty-array&hasOffsetValue(\'key\', ListUnionNarrowing\Dog)', $data); + } +} + +// Test 7: No narrowing on variable offset (conservative) +/** @param array $animals */ +function testVariableOffset(array $animals, int $i): void +{ + if ($animals[$i] instanceof Dog) { + // Should NOT narrow (conservative) + assertType('array', $animals); + } +} + +// Test 8: Nested foreach (flat only - no nesting support) +/** @param list $animals */ +function testNestedForeach(array $animals): void +{ + foreach ($animals as $animal) { + if ($animal instanceof Dog) { + assertType('non-empty-list', $animals); + } + } +} + +// Test 9: Multiple foreach iterations +/** @param list $animals */ +function testMultipleIterations(array $animals): void +{ + foreach ($animals as $animal) { + if ($animal instanceof Dog) { + assertType('non-empty-list', $animals); + } else { + assertType('non-empty-list', $animals); + } + } +} + +// Test 10: Sequential ifs +/** @param list $animals */ +function testSequentialIfs(array $animals): void +{ + foreach ($animals as $animal) { + if ($animal instanceof Dog) { + assertType('non-empty-list', $animals); + } + if ($animal instanceof Cat) { + assertType('non-empty-list', $animals); + } + } +} + +// Test 11: Not instanceof narrowing +/** @param list $animals */ +function testNotInstanceOf(array $animals): void +{ + foreach ($animals as $animal) { + if (!($animal instanceof Dog)) { + // Should narrow to exclude Dog + assertType('non-empty-list', $animals); + } else { + assertType('non-empty-list', $animals); + } + } +} + +// Test 12: Direct access with different offsets +/** @param array $animals */ +function testDifferentOffsets(array $animals): void +{ + if ($animals[0] instanceof Dog) { + assertType('non-empty-array&hasOffsetValue(0, ListUnionNarrowing\Dog)', $animals); + } + if ($animals[1] instanceof Cat) { + assertType('non-empty-array&hasOffsetValue(0, ListUnionNarrowing\Animal)&hasOffsetValue(1, ListUnionNarrowing\Cat)', $animals); + } +} + +// Test 13: Nested array access +/** @param array> $nested */ +function testNestedArrayAccess(array $nested): void +{ + // This tracks hasOffsetValue metadata for nested access + if ($nested[0][0] instanceof Dog) { + assertType('non-empty-array>&hasOffsetValue(0, non-empty-array&hasOffsetValue(0, ListUnionNarrowing\Dog))', $nested); + } +} + +// Test 14: Mixed union types +/** @param list $mixed */ +function testMixedUnion(array $mixed): void +{ + foreach ($mixed as $item) { + if ($item instanceof Dog) { + // Narrows to just Dog + assertType('non-empty-list', $mixed); + } + } +} + +// Test 15: Constant negative offset +/** @param array $animals */ +function testNegativeOffset(array $animals): void +{ + if ($animals[-1] instanceof Dog) { + assertType('non-empty-array&hasOffsetValue(-1, ListUnionNarrowing\Dog)', $animals); + } +} + +// Test 16: Constant large offset +/** @param array $animals */ +function testLargeOffset(array $animals): void +{ + if ($animals[999] instanceof Dog) { + assertType('non-empty-array&hasOffsetValue(999, ListUnionNarrowing\Dog)', $animals); + } +} + +// Test 17: Foreach with key-value +/** @param list $animals */ +function testForeachKeyValue(array $animals): void +{ + foreach ($animals as $key => $animal) { + // Note: key-value foreach may have different narrowing behavior + // due to how the scope is managed + if ($animal instanceof Dog) { + assertType('non-empty-list', $animals); + } + } +} + +// Test 18: Intersection types +interface Interface1 {} +interface Interface2 {} +class Multi implements Interface1, Interface2 {} + +/** @param list $items */ +function testIntersection(array $items): void +{ + foreach ($items as $item) { + if ($item instanceof Multi) { + assertType('non-empty-list', $items); + } + } +} + +// Test 19: Null handling +/** @param list $animals */ +function testNullHandling(array $animals): void +{ + foreach ($animals as $animal) { + if ($animal instanceof Dog) { + // instanceof removes null automatically + assertType('non-empty-list', $animals); + } + } +} + +// Test 20: Boolean and instanceof combination +/** @param list $animals */ +function testBooleanCombination(array $animals): void +{ + foreach ($animals as $animal) { + if ($animal instanceof Dog && $animal instanceof Dog) { + assertType('non-empty-list', $animals); + } + } +} + +// Test 21: By-reference foreach narrowing +/** @param list $animals */ +function testByReferenceForeach(array $animals): void +{ + foreach ($animals as &$animal) { + if ($animal instanceof Dog) { + // Narrowing does NOT apply with by-reference (conservative approach) + assertType('non-empty-list', $animals); + } + } + assertType('list', $animals); +} + +// Test 22: Empty array behavior +/** @param list $animals */ +function testEmptyArray(array $animals): void +{ + foreach ($animals as $animal) { + if ($animal instanceof Dog) { + // Even if array was initially empty, the instanceof + // proves it has at least one Dog element now + assertType('non-empty-list', $animals); + } + } +} + +// Test 23: Variable reassignment in loop +/** @param list $animals */ +function testVariableReassignment(array $animals): void +{ + foreach ($animals as $animal) { + if ($animal instanceof Dog) { + assertType('non-empty-list', $animals); + + // Reassign the variable - tracking should break + $animals = [new Cat()]; + + // After reassignment, type reflects new value as array literal + assertType('array{ListUnionNarrowing\Cat}', $animals); + } + } +} + +// Test 24: Multiple foreach loops with different arrays +/** @param list $animals */ +/** @param list $dogs */ +function testMultipleForeach(array $animals, array $dogs): void +{ + foreach ($animals as $animal) { + foreach ($dogs as $dog) { + if ($dog instanceof Dog) { + // Inner loop array should narrow + assertType('non-empty-list', $dogs); + // Outer loop array should NOT be affected (but is non-empty due to being in loop) + assertType('non-empty-array', $animals); + } + } + } +} + +// Test 25: Narrowing with ternary operator +/** @param list $animals */ +function testTernaryOperator(array $animals): void +{ + foreach ($animals as $animal) { + $type = $animal instanceof Dog ? 'dog' : 'other'; + + // Narrowing does NOT persist after ternary (type is widened) + if ($animal instanceof Dog) { + assertType('non-empty-list', $animals); + } + } +} + +// Test 26: Early continue in loop +/** @param list $animals */ +function testEarlyContinue(array $animals): void +{ + foreach ($animals as $animal) { + if ($animal instanceof Dog) { + assertType('non-empty-list', $animals); + continue; // Type should restore after continue + } + + // After continue, type is non-empty (we're in the loop body) + assertType('non-empty-list', $animals); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/test-foreach-tracking.php b/tests/PHPStan/Analyser/nsrt/test-foreach-tracking.php new file mode 100644 index 0000000000..1752ebb28c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/test-foreach-tracking.php @@ -0,0 +1,35 @@ + $items */ +function testSimple(array $items): void +{ + foreach ($items as $item) { + // At this point, $item should be tracked as coming from $items + assertType('TestForeachTracking\Base', $item); + + if ($item instanceof Derived) { + // This should narrow both $item AND $items + assertType('TestForeachTracking\Derived', $item); + assertType('non-empty-list', $items); + } + + assertType('TestForeachTracking\Base', $item); + } +} + +/** @param array $items */ +function testDirectAccess(array $items): void +{ + if ($items[0] instanceof Derived) { + // This should narrow the array + // Note: Direct access uses hasOffsetValue metadata rather than full type narrowing + assertType('non-empty-array&hasOffsetValue(0, TestForeachTracking\Derived)', $items); + } +}