Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build/phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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\:
Expand Down
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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

-
Expand Down
22 changes: 22 additions & 0 deletions src/Analyser/ForeachSourceTracking.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PhpParser\Node\Expr;
use PHPStan\Type\Type;

/**
* @internal
*/
final class ForeachSourceTracking
{

public function __construct(
public readonly string $valueVarName,
public readonly Expr $arrayExpr,
public readonly Type $originalArrayType,
)
{
}

}
42 changes: 40 additions & 2 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ class MutatingScope implements Scope, NodeCallbackInvoker
/** @var array<string, self> */
private array $falseyScopes = [];

/** @var array<string, ForeachSourceTracking> */
private array $foreachSources = [];

private ?self $fiberScope = null;

/** @var non-empty-string|null */
Expand Down Expand Up @@ -750,6 +753,15 @@ public function getMaybeDefinedVariables(): array
return $variables;
}

/**
* @internal
* @return array<string, ForeachSourceTracking>
*/
public function getForeachSources(): array
{
return $this->foreachSources;
}

private function isGlobalVariable(string $variableName): bool
{
return in_array($variableName, self::SUPERGLOBAL_VARIABLES, true);
Expand Down Expand Up @@ -1986,6 +1998,7 @@ public function pushInFunctionCall($reflection, ?ParameterReflection $parameter,
if ($rememberTypes) {
$functionScope->resolvedTypes = $this->resolvedTypes;
}
$functionScope->foreachSources = $this->foreachSources;

return $functionScope;
}
Expand Down Expand Up @@ -2015,6 +2028,7 @@ public function popInFunctionCall(): self
);

$parentScope->resolvedTypes = $this->resolvedTypes;
$parentScope->foreachSources = $this->foreachSources;

return $parentScope;
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -3798,7 +3825,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self
}
}

return $scope->scopeFactory->create(
$newScope = $scope->scopeFactory->create(
$scope->context,
$scope->isDeclareStrictTypes(),
$scope->getFunction(),
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand All @@ -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;
}

/**
Expand Down
139 changes: 135 additions & 4 deletions src/Analyser/TypeSpecifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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_) {
Expand Down Expand Up @@ -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<Dog>
* // 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,
Expand Down Expand Up @@ -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;
}

}
5 changes: 5 additions & 0 deletions src/Type/Accessory/AccessoryArrayListType.php
Original file line number Diff line number Diff line change
Expand Up @@ -514,4 +514,9 @@ public function hasTemplateOrLateResolvableType(): bool
return false;
}

public function narrowItemType(Type $narrowedItemType): Type
{
return $this;
}

}
5 changes: 5 additions & 0 deletions src/Type/Accessory/AccessoryLiteralStringType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions src/Type/Accessory/AccessoryLowercaseStringType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading