diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index 9da565d6afb..d835ab26d3b 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -26,3 +26,4 @@ parameters: finiteTypesInHaystack: true switchConditionAlwaysFalse: true checkImportedClassNameCase: true + unusedVariable: true diff --git a/conf/config.level4.neon b/conf/config.level4.neon index 4206d36d3c1..60115a635f5 100644 --- a/conf/config.level4.neon +++ b/conf/config.level4.neon @@ -18,6 +18,8 @@ conditionalTags: phpstan.rules.rule: %featureToggles.finiteTypesInHaystack% PHPStan\Rules\Comparison\SwitchConditionRule: phpstan.rules.rule: %featureToggles.switchConditionAlwaysFalse% + PHPStan\Rules\DeadCode\UnusedVariableRule: + phpstan.rules.rule: %featureToggles.unusedVariable% parameters: checkAdvancedIsset: true @@ -49,3 +51,6 @@ services: class: PHPStan\Rules\Comparison\SwitchConditionRule arguments: treatPhpDocTypesAsCertain: %treatPhpDocTypesAsCertain% + + - + class: PHPStan\Rules\DeadCode\UnusedVariableRule diff --git a/conf/config.neon b/conf/config.neon index 31869418817..7c54dfe99b0 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -57,6 +57,7 @@ parameters: finiteTypesInHaystack: false switchConditionAlwaysFalse: false checkImportedClassNameCase: false + unusedVariable: true fileExtensions: - php checkAdvancedIsset: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 5741d05cd34..c89cb9841ba 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -55,6 +55,7 @@ parametersSchema: finiteTypesInHaystack: bool() switchConditionAlwaysFalse: bool() checkImportedClassNameCase: bool() + unusedVariable: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/src/Analyser/AssignTargetWalkMode.php b/src/Analyser/AssignTargetWalkMode.php index 36d677a6f5e..5ce18f2cf23 100644 --- a/src/Analyser/AssignTargetWalkMode.php +++ b/src/Analyser/AssignTargetWalkMode.php @@ -2,6 +2,8 @@ namespace PHPStan\Analyser; +use PHPStan\Node\Variable\VariableWrite; + /** * How AssignHandler::prepareTarget() walks the assignment target. * @@ -13,37 +15,51 @@ * isset descriptor). The read happens inside the one target walk instead of * callers re-processing the target with a noop callback. * + * The mode also says whether the write is a source-level write site of a + * local variable (recorded for the unused-variable check) - a by-ref + * write-back or a call's scope effect is not. + * * @internal */ final class AssignTargetWalkMode { + /** + * @param VariableWrite::KIND_*|null $writeSiteKind + */ private function __construct( private bool $enterExpressionAssign, private bool $producesTargetReadResult, private bool $issetSemanticsForRead, + private ?int $writeSiteKind, ) { } - public static function assign(): self + /** + * @param VariableWrite::KIND_* $writeSiteKind + */ + public static function assign(int $writeSiteKind = VariableWrite::KIND_ASSIGN): self { - return new self(true, false, false); + return new self(true, false, false, $writeSiteKind); } - public static function virtualAssign(): self + /** + * @param VariableWrite::KIND_*|null $writeSiteKind + */ + public static function virtualAssign(?int $writeSiteKind = null): self { - return new self(false, false, false); + return new self(false, false, false, $writeSiteKind); } public static function readModifyWrite(): self { - return new self(false, true, false); + return new self(false, true, false, VariableWrite::KIND_READ_MODIFY_WRITE); } public static function coalesceReadModifyWrite(): self { - return new self(true, true, true); + return new self(true, true, true, VariableWrite::KIND_READ_MODIFY_WRITE); } public function enterExpressionAssign(): bool @@ -61,4 +77,12 @@ public function issetSemanticsForRead(): bool return $this->issetSemanticsForRead; } + /** + * @return VariableWrite::KIND_*|null + */ + public function getWriteSiteKind(): ?int + { + return $this->writeSiteKind; + } + } diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index a5df088329d..c51ec47665c 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -55,6 +55,7 @@ use PHPStan\Node\IssetExpr; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Node\PropertyAssignNode; +use PHPStan\Node\Variable\VariableWrite; use PHPStan\Node\VariableAssignNode; use PHPStan\Node\VirtualNode; use PHPStan\Php\PhpVersion; @@ -147,10 +148,22 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $valueScope = $valueBeforeScope; $valueImpurePoints = []; if ($expr instanceof AssignRef) { + // both sides alias one slot from now on - neither is tracked for + // unused writes + $aliasedVar = $expr->var; + while ($aliasedVar instanceof ArrayDimFetch) { + $aliasedVar = $aliasedVar->var; + } + if ($aliasedVar instanceof Variable && is_string($aliasedVar->name)) { + $nodeScopeResolver->markVariableUntracked($aliasedVar->name); + } $referencedExpr = $expr->expr; while ($referencedExpr instanceof ArrayDimFetch) { $referencedExpr = $referencedExpr->var; } + if ($referencedExpr instanceof Variable && is_string($referencedExpr->name)) { + $nodeScopeResolver->markVariableUntracked($referencedExpr->name); + } if ($referencedExpr instanceof PropertyFetch || $referencedExpr instanceof StaticPropertyFetch) { $valueImpurePoints[] = new ImpurePoint( @@ -602,6 +615,7 @@ private function doPrepareTarget( targetReadResult: $targetReadResult, targetChainResults: $targetChainResults, variableNameResult: $variableNameResult, + writeSiteKind: $mode->getWriteSiteKind(), ); } @@ -801,6 +815,7 @@ private function doPrepareTarget( offsetSetTargetResult: $offsetSetTargetResult, targetReadResult: $targetReadResult, targetChainResults: $targetChainResults, + writeSiteKind: $mode->getWriteSiteKind(), ); } @@ -866,6 +881,7 @@ private function doPrepareTarget( propertyName: $propertyName, targetReadResult: $targetReadResult, targetChainResults: $targetChainResults, + writeSiteKind: $mode->getWriteSiteKind(), ); } @@ -926,6 +942,7 @@ private function doPrepareTarget( propertyHolderType: $propertyHolderType, targetReadResult: $targetReadResult, targetChainResults: $targetChainResults, + writeSiteKind: $mode->getWriteSiteKind(), ); } @@ -942,6 +959,7 @@ private function doPrepareTarget( $throwPoints, $impurePoints, $isAlwaysTerminating, + writeSiteKind: $mode->getWriteSiteKind(), ); } @@ -992,6 +1010,7 @@ private function doPrepareTarget( assignedPropertyExpr: $assignedPropertyExpr, existingOffsetTypes: $offsetTypes, existingOffsetNativeTypes: $offsetNativeTypes, + writeSiteKind: $mode->getWriteSiteKind(), ); } @@ -1026,6 +1045,7 @@ private function doPrepareTarget( $isAlwaysTerminating, targetReadResult: $targetReadResult, targetChainResults: $targetChainResults, + writeSiteKind: $mode->getWriteSiteKind(), ); } @@ -1084,7 +1104,6 @@ public function applyWrite( $storedAssignedExprResult = $assignedExpr === $target->getAssignedExpr() ? $assignedValueResult ?? $storage->findExpressionResult($assignedExpr) : $storage->findExpressionResult($assignedExpr); - $assignedValueResult = $storedAssignedExprResult; $type = $this->readAssignedValueType($nodeScopeResolver, $storedAssignedExprResult, $assignedExpr, $scopeBeforeAssignEval); $conditionalExpressions = []; @@ -1206,7 +1225,15 @@ public function applyWrite( } $nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage); - $scope = $scope->assignVariable($var->name, $type, $this->readAssignedValueType($nodeScopeResolver, $storedAssignedExprResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain()), TrinaryLogic::createYes()); + $write = $target->getWriteSiteKind() !== null ? $nodeScopeResolver->recordVariableWrite($var, $target->getWriteSiteKind()) : null; + $scope = $scope->assignVariable( + $var->name, + $type, + $this->readAssignedValueType($nodeScopeResolver, $storedAssignedExprResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain()), + TrinaryLogic::createYes(), + write: $write, + supersededMarkerExprs: $write !== null ? $nodeScopeResolver->getVariableWriteMarkersToKill($var->name) : [], + ); foreach ($conditionalExpressions as $exprString => $holders) { $scope = $scope->addConditionalExpressions((string) $exprString, $holders); } @@ -1304,7 +1331,19 @@ public function applyWrite( if ($varType->isArray()->yes() || !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->yes()) { if ($var instanceof Variable && is_string($var->name)) { $nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, new TypeExpr($valueToWrite)), $scopeBeforeAssignEval, $storage); - $scope = $scope->assignVariable($var->name, $valueToWrite, $nativeValueToWrite, TrinaryLogic::createYes()); + // an offset write on an object (ArrayAccess, mixed) mutates a shared + // handle - only a value container (array, string) makes it a write site + $write = $target->getWriteSiteKind() !== null && ($varType->isArray()->yes() || $varType->isString()->yes()) + ? $nodeScopeResolver->recordVariableWrite($var, VariableWrite::KIND_ARRAY_DIM_WRITE) + : null; + $scope = $scope->assignVariable( + $var->name, + $valueToWrite, + $nativeValueToWrite, + TrinaryLogic::createYes(), + write: $write, + supersededMarkerExprs: $write !== null ? $nodeScopeResolver->getVariableWriteMarkersToKill($var->name) : [], + ); } else { if ($var instanceof PropertyFetch || $var instanceof StaticPropertyFetch) { $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedPropertyExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); @@ -1517,6 +1556,9 @@ public function applyWrite( if ($arrayItem === null) { continue; } + if ($arrayItem->byRef && $arrayItem->value instanceof Variable && is_string($arrayItem->value->name)) { + $nodeScopeResolver->markVariableUntracked($arrayItem->value->name); + } $itemScope = $scope; if ($enterExpressionAssign) { @@ -1556,7 +1598,7 @@ public function applyWrite( $getOffsetValueTypeExpr, $nodeCallback, $context, - $enterExpressionAssign ? AssignTargetWalkMode::assign() : AssignTargetWalkMode::virtualAssign(), + $enterExpressionAssign ? AssignTargetWalkMode::assign(VariableWrite::KIND_LIST_ITEM) : AssignTargetWalkMode::virtualAssign($target->getWriteSiteKind() !== null ? VariableWrite::KIND_LIST_ITEM : null), ); $result = $this->applyWrite( $nodeScopeResolver, @@ -2117,6 +2159,9 @@ private function processArrayByRefItems(NodeScopeResolver $nodeScopeResolver, Mu } $refVarName = $arrayItem->value->name; + // `$root = [&$ref]` aliases the two slots from now on + $nodeScopeResolver->markVariableUntracked($rootVarName); + $nodeScopeResolver->markVariableUntracked($refVarName); $dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr); // a plain variable read is scope state - no need to price a synthetic // Variable node on demand (mirrors VariableHandler's typeCallback) diff --git a/src/Analyser/ExprHandler/EvalHandler.php b/src/Analyser/ExprHandler/EvalHandler.php index 8d91fe55131..cdf4cdcabf0 100644 --- a/src/Analyser/ExprHandler/EvalHandler.php +++ b/src/Analyser/ExprHandler/EvalHandler.php @@ -44,6 +44,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $beforeScope = $scope; $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + // the evaluated code may read any variable + $nodeScopeResolver->markAllReachingVariablesRead($exprResult->getScope()); $scope = $exprResult->getScope()->invalidateVolatileExpressions(); return $this->expressionResultFactory->create( diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index 9afc63c3eb7..02918c97bc0 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -218,6 +218,9 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu $invalidateExpressions = []; try { + // a throwaway write-tracking frame: this walk must not register the + // body's writes into the enclosing function-like's frame + $this->nodeScopeResolver->pushVariableWritesFrame($expr->params); $walkStorage = new ExpressionResultStorage(); $closureScope->pushExpressionResultStorage($walkStorage); $closureStatementResult = $this->nodeScopeResolver->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $walkStorage, static function (Node $node, Scope $scope) use ($closureScope, &$closureReturnStatements, &$closureYieldStatements, &$closureExecutionEnds, &$closureImpurePoints, &$invalidateExpressions): void { @@ -259,6 +262,7 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu }, StatementContext::createTopLevel())->toPublic(); } finally { $closureScope->popExpressionResultStorage(); + $this->nodeScopeResolver->popVariableWritesFrame(); self::$resolveClosureTypeDepth--; } diff --git a/src/Analyser/ExprHandler/Helper/FuncCallScopeEffectsHelper.php b/src/Analyser/ExprHandler/Helper/FuncCallScopeEffectsHelper.php index 07d5dd85d48..5e7f8b522bc 100644 --- a/src/Analyser/ExprHandler/Helper/FuncCallScopeEffectsHelper.php +++ b/src/Analyser/ExprHandler/Helper/FuncCallScopeEffectsHelper.php @@ -282,6 +282,21 @@ public function applyCallScopeEffects(NodeScopeResolver $nodeScopeResolver, Stmt )->getScope(); } + if ($functionReflection !== null) { + if ($functionReflection->getName() === 'compact') { + $compactedNames = $this->findCompactVariableNames($normalizedExpr, $argsResult, $scope); + if ($compactedNames === null) { + $nodeScopeResolver->markAllReachingVariablesRead($scope); + } else { + foreach ($compactedNames as $compactedName) { + $nodeScopeResolver->markVariableRead($compactedName, $scope); + } + } + } elseif (in_array($functionReflection->getName(), ['get_defined_vars', 'extract'], true)) { + $nodeScopeResolver->markAllReachingVariablesRead($scope); + } + } + if ( $functionReflection !== null && $functionReflection->getName() === 'extract' @@ -506,4 +521,67 @@ static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arra return $arrayType; } + /** + * The variable names a compact() call reads, null when they cannot be + * enumerated (mirrors CompactFunctionReturnTypeExtension). + * + * @return list|null + */ + private function findCompactVariableNames(FuncCall $funcCall, ArgsResult $argsResult, MutatingScope $scope): ?array + { + $names = []; + foreach ($funcCall->getArgs() as $arg) { + if ($arg->unpack) { + return null; + } + $argNames = $this->findConstantStringValues($argsResult->requireArgResult($arg->value)->getTypeOnScope($scope, false)); + if ($argNames === null) { + return null; + } + foreach ($argNames as $argName) { + $names[] = $argName; + } + } + + return $names; + } + + /** + * @return list|null + */ + private function findConstantStringValues(Type $type): ?array + { + $constantStrings = $type->getConstantStrings(); + if (count($constantStrings) > 0) { + $values = []; + foreach ($constantStrings as $constantString) { + $values[] = $constantString->getValue(); + } + + return $values; + } + + $constantArrays = $type->getConstantArrays(); + if (count($constantArrays) === 0) { + return null; + } + $values = []; + foreach ($constantArrays as $constantArray) { + if ($constantArray->isUnsealed()->yes()) { + return null; + } + foreach ($constantArray->getValueTypes() as $valueType) { + $valueNames = $this->findConstantStringValues($valueType); + if ($valueNames === null) { + return null; + } + foreach ($valueNames as $valueName) { + $values[] = $valueName; + } + } + } + + return $values; + } + } diff --git a/src/Analyser/ExprHandler/IncludeHandler.php b/src/Analyser/ExprHandler/IncludeHandler.php index f56de2f3b48..5ccfd14545b 100644 --- a/src/Analyser/ExprHandler/IncludeHandler.php +++ b/src/Analyser/ExprHandler/IncludeHandler.php @@ -46,6 +46,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $beforeScope = $scope; $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $identifier = in_array($expr->type, [Include_::TYPE_INCLUDE, Include_::TYPE_INCLUDE_ONCE], true) ? 'include' : 'require'; + // the included file may read any variable + $nodeScopeResolver->markAllReachingVariablesRead($exprResult->getScope()); $scope = $exprResult->getScope()->afterExtractCall()->invalidateVolatileExpressions(); return $this->expressionResultFactory->create( diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index c0fe5a517bf..01988de7d12 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -340,7 +340,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $filteringCondData = []; $armCondScope = $matchScope; $condNodes = []; - $armCondResultScope = $matchScope; $bodyScope = null; $condArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($expr->cond, $storage); foreach ($arm->conds as $j => $armCond) { diff --git a/src/Analyser/ExprHandler/PostDecHandler.php b/src/Analyser/ExprHandler/PostDecHandler.php index 2dac637e934..399dabe08d4 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -18,6 +18,7 @@ use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\Variable\VariableWrite; use PHPStan\Type\Type; /** @@ -74,6 +75,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $virtualExpr, $nodeCallback, $virtualExprResult, + VariableWrite::KIND_INC_DEC, )->getScope(), beforeScope: $scope, expr: $expr, diff --git a/src/Analyser/ExprHandler/PostIncHandler.php b/src/Analyser/ExprHandler/PostIncHandler.php index 934539ade66..d0110cd2bb5 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -18,6 +18,7 @@ use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\Variable\VariableWrite; use PHPStan\Type\Type; /** @@ -74,6 +75,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $virtualExpr, $nodeCallback, $virtualExprResult, + VariableWrite::KIND_INC_DEC, )->getScope(), beforeScope: $scope, expr: $expr, diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index 1097d7e2a82..23c989f8d61 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\Variable\VariableWrite; /** * @implements ExprHandler @@ -76,6 +77,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $nodeCallback, $incDecValueResult, + VariableWrite::KIND_INC_DEC, )->getScope(), beforeScope: $scope, expr: $expr, diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index d674f9e56bb..13320977b09 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\Variable\VariableWrite; /** * @implements ExprHandler @@ -76,6 +77,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $nodeCallback, $incDecValueResult, + VariableWrite::KIND_INC_DEC, )->getScope(), beforeScope: $scope, expr: $expr, diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 33352dad581..6b5a8ce724d 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -150,7 +150,18 @@ public function composeResult(NodeScopeResolver $nodeScopeResolver, Variable $ex if (in_array($expr->name, Scope::SUPERGLOBAL_VARIABLES, true)) { $impurePoints[] = new ImpurePoint($scope, $expr, 'superglobal', 'access to superglobal variable', true); } + // the one place a source-level variable read is priced - record it + // for the unused-variable check + $nodeScopeResolver->markVariableRead($expr->name, $beforeScope); } elseif ($nameResult !== null) { + $nameConstantStrings = $nameResult->getType()->getConstantStrings(); + if (count($nameConstantStrings) > 0) { + foreach ($nameConstantStrings as $nameConstantString) { + $nodeScopeResolver->markVariableRead($nameConstantString->getValue(), $beforeScope); + } + } else { + $nodeScopeResolver->markAllReachingVariablesRead($beforeScope); + } $hasYield = $nameResult->hasYield(); $throwPoints = $nameResult->getThrowPoints(); $impurePoints = $nameResult->getImpurePoints(); diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 96f4301f6df..7ac9f258d23 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -39,8 +39,10 @@ use PHPStan\Node\Expr\PossiblyImpureCallExpr; use PHPStan\Node\Expr\PropertyInitializationExpr; use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr; +use PHPStan\Node\Expr\VariableWrittenExpr; use PHPStan\Node\IssetExpr; use PHPStan\Node\Printer\ExprPrinter; +use PHPStan\Node\Variable\VariableWrite; use PHPStan\Node\VirtualNode; use PHPStan\Parser\Parser; use PHPStan\Php\PhpVersion; @@ -2759,7 +2761,11 @@ public function enterMatch(Expr\Match_ $expr, Type $condType, Type $condNativeTy return $this->assignExpression($condExpr, $type, $nativeType); } - public function enterForeach(self $originalScope, Expr $iteratee, Type $iterateeType, Type $nativeIterateeType, string $valueName, ?string $keyName, bool $valueByRef): self + /** + * @param list $valueSupersededMarkerExprs + * @param list $keySupersededMarkerExprs + */ + public function enterForeach(self $originalScope, Expr $iteratee, Type $iterateeType, Type $nativeIterateeType, string $valueName, ?string $keyName, bool $valueByRef, ?VariableWrite $valueWrite = null, array $valueSupersededMarkerExprs = [], ?VariableWrite $keyWrite = null, array $keySupersededMarkerExprs = []): self { $valueType = $originalScope->getIterableValueType($iterateeType); $nativeValueType = $originalScope->getIterableValueType($nativeIterateeType); @@ -2768,6 +2774,8 @@ public function enterForeach(self $originalScope, Expr $iteratee, Type $iteratee $valueType, $nativeValueType, TrinaryLogic::createYes(), + write: $valueWrite, + supersededMarkerExprs: $valueSupersededMarkerExprs, ); // Track the original foreach value so narrowings applied to the value // variable (e.g. is_string($type)) can later be projected back onto the @@ -2793,7 +2801,7 @@ public function enterForeach(self $originalScope, Expr $iteratee, Type $iteratee ); } if ($keyName !== null) { - $scope = $scope->enterForeachKey($originalScope, $iteratee, $iterateeType, $nativeIterateeType, $keyName); + $scope = $scope->enterForeachKey($originalScope, $iteratee, $iterateeType, $nativeIterateeType, $keyName, $keyWrite, $keySupersededMarkerExprs); if ($valueByRef && $iterateeType->isArray()->yes() && $iterateeType->isConstantArray()->no()) { $scope = $scope->assignExpression( @@ -2807,7 +2815,10 @@ public function enterForeach(self $originalScope, Expr $iteratee, Type $iteratee return $scope; } - public function enterForeachKey(self $originalScope, Expr $iteratee, Type $iterateeType, Type $nativeIterateeType, string $keyName): self + /** + * @param list $supersededMarkerExprs + */ + public function enterForeachKey(self $originalScope, Expr $iteratee, Type $iterateeType, Type $nativeIterateeType, string $keyName, ?VariableWrite $write = null, array $supersededMarkerExprs = []): self { $keyType = $originalScope->getIterableKeyType($iterateeType); $nativeKeyType = $originalScope->getIterableKeyType($nativeIterateeType); @@ -2817,6 +2828,8 @@ public function enterForeachKey(self $originalScope, Expr $iteratee, Type $itera $keyType, $nativeKeyType, TrinaryLogic::createYes(), + write: $write, + supersededMarkerExprs: $supersededMarkerExprs, ); $originalForeachKeyExpr = new OriginalForeachKeyExpr($keyName); @@ -2832,7 +2845,10 @@ public function enterForeachKey(self $originalScope, Expr $iteratee, Type $itera return $scope; } - public function enterCatchType(Type $catchType, ?string $variableName): self + /** + * @param list $supersededMarkerExprs + */ + public function enterCatchType(Type $catchType, ?string $variableName, ?VariableWrite $write = null, array $supersededMarkerExprs = []): self { if ($variableName === null) { return $this; @@ -2843,6 +2859,8 @@ public function enterCatchType(Type $catchType, ?string $variableName): self TypeCombinator::intersect($catchType, new ObjectType(Throwable::class)), TypeCombinator::intersect($catchType, new ObjectType(Throwable::class)), TrinaryLogic::createYes(), + write: $write, + supersededMarkerExprs: $supersededMarkerExprs, ); } @@ -3003,9 +3021,15 @@ public function isUndefinedExpressionAllowed(Expr $expr): bool } /** + * A write site ($write) plants its VariableWrittenExpr marker and kills the + * markers of the variable's earlier write sites ($supersededMarkerExprs) - + * the scope is told what to kill, it never consults engine state. Writes + * that are not source-level sites leave the markers alone. + * * @param list $intertwinedPropagatedFrom + * @param list $supersededMarkerExprs */ - public function assignVariable(string $variableName, Type $type, Type $nativeType, TrinaryLogic $certainty, array $intertwinedPropagatedFrom = []): self + public function assignVariable(string $variableName, Type $type, Type $nativeType, TrinaryLogic $certainty, array $intertwinedPropagatedFrom = [], ?VariableWrite $write = null, array $supersededMarkerExprs = []): self { $node = new Variable($variableName); $scope = $this->assignExpression($node, $type, $nativeType); @@ -3016,6 +3040,15 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp $scope->expressionTypes[$exprString] = new ExpressionTypeHolder($node, $type, $certainty); $scope->nativeExpressionTypes[$exprString] = new ExpressionTypeHolder($node, $nativeType, $certainty); } + if ($write !== null) { + // markers live in the phpDoc-typed map only: reads are recorded on walk + // scopes, never on a promoted one, and keeping them out of the native + // map halves their share of every merge, generalization and invalidation + foreach ($supersededMarkerExprs as $supersededMarkerExpr) { + unset($scope->expressionTypes[$this->getNodeKey($supersededMarkerExpr)]); + } + $scope->expressionTypes[$this->getNodeKey($write->getMarkerExpr())] = ExpressionTypeHolder::createYes($write->getMarkerExpr(), new MixedType()); + } foreach ($scope->expressionTypes as $exprString => $expressionType) { if (!$expressionType->getExpr() instanceof IntertwinedVariableByReferenceWithExpr) { @@ -4566,6 +4599,19 @@ private function generalizeVariableTypeHolders( ); } + // a write marker planted only by the newer pass (its branch was dead in the + // previous pass) must survive the generalization, or the next pass's reads + // never see that write reaching the loop head + foreach ($otherVariableTypeHolders as $variableExprString => $otherVariableTypeHolder) { + if (isset($variableTypeHolders[$variableExprString])) { + continue; + } + if (!$otherVariableTypeHolder->getExpr() instanceof VariableWrittenExpr) { + continue; + } + $newVariableTypeHolders[$variableExprString] = ExpressionTypeHolder::createMaybe($otherVariableTypeHolder->getExpr(), $otherVariableTypeHolder->getType()); + } + return $newVariableTypeHolders; } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 7a00057e9c4..fc6c9c847a6 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -57,6 +57,7 @@ use PHPStan\Node\StaticMethodCallableNode; use PHPStan\Node\StaticMethodCallExpressionNode; use PHPStan\Node\UnreachableStatementNode; +use PHPStan\Node\Variable\VariableWrite; use PHPStan\Node\VarTagChangedExpressionTypeNode; use PHPStan\Parser\ArrowFunctionArgVisitor; use PHPStan\Parser\ClosureArgVisitor; @@ -146,6 +147,18 @@ class NodeScopeResolver */ private bool $consumeStoredExpressionResults = false; + /** + * Local-variable write tracking (unused-variable check): one immutable + * VariableWritesFrame per function-like body being walked, innermost last; + * the top frame is swapped after every transition. Arrow functions share + * the enclosing frame; top-level code has none. + * + * @var list + */ + private array $variableWritesFrames = []; + + private int $variableWriteIdCounter = 0; + private ?NonNullabilityHelper $nonNullabilityHelper = null; /** @@ -292,10 +305,18 @@ public function processNodes( // interrupted walk's gatherer frames (see processStmtNodes()) $gatherers = $this->nodeGatherers; $this->nodeGatherers = []; + $variableWritesFrames = $this->variableWritesFrames; + $this->variableWritesFrames = []; + // write ids restart per walk so a file's markers (visible in debugScope()) + // do not depend on what was analysed before it + $variableWriteIdCounter = $this->variableWriteIdCounter; + $this->variableWriteIdCounter = 0; try { $this->processNodesWithStorage($nodes, $scope, $expressionResultStorage, $nodeCallback); } finally { $this->nodeGatherers = $gatherers; + $this->variableWritesFrames = $variableWritesFrames; + $this->variableWriteIdCounter = $variableWriteIdCounter; $scope->popExpressionResultStorage(); } } @@ -1454,6 +1475,173 @@ private function hasContextSensitiveConstruct(Node $node): bool * * @param callable(Node $node, Scope $scope): void $nodeCallback */ + /** + * Opens the write-tracking frame of a function-like body. By-ref parameters + * and by-ref closure uses alias slots outside the body, so their writes are + * never reported. + * + * @param Node\Param[] $params + * @param Node\ClosureUse[] $byRefUses + */ + public function pushVariableWritesFrame(array $params, array $byRefUses = []): void + { + $frame = VariableWritesFrame::create(); + foreach ($params as $param) { + if (!$param->byRef || !$param->var instanceof Variable || !is_string($param->var->name)) { + continue; + } + $frame = $frame->withUntracked($param->var->name); + } + foreach ($byRefUses as $use) { + if (!is_string($use->var->name)) { + continue; + } + $frame = $frame->withUntracked($use->var->name); + } + $this->variableWritesFrames[] = $frame; + } + + public function popVariableWritesFrame(): VariableWritesFrame + { + $frame = array_pop($this->variableWritesFrames); + if ($frame === null) { + throw new ShouldNotHappenException(); + } + + return $frame; + } + + private function getVariableWritesFrame(): ?VariableWritesFrame + { + $count = count($this->variableWritesFrames); + if ($count === 0) { + return null; + } + + return $this->variableWritesFrames[$count - 1]; + } + + private function replaceVariableWritesFrame(VariableWritesFrame $frame): void + { + array_pop($this->variableWritesFrames); + $this->variableWritesFrames[] = $frame; + } + + /** + * Synthetic nodes priced on demand re-read real variables outside their + * own walk - those reads (and writes) never count. A consume-stored walk is + * different: it re-enters an already-walked subtree and answers the walked + * nodes from the storage, so a handler running there processes a node for + * the first time (the arguments of a nullsafe call's plain twin) and its + * reads are genuine. + */ + private function isProcessingOnDemand(): bool + { + return $this->returnStoredExpressionResults; + } + + /** + * Registers a source-level write site of a local variable; null outside a + * function-like body, on demand, or for a variable that is not tracked. + * + * @param VariableWrite::KIND_* $kind + */ + public function recordVariableWrite(Variable $variable, int $kind): ?VariableWrite + { + $frame = $this->getVariableWritesFrame(); + if ($frame === null || $this->isProcessingOnDemand()) { + return null; + } + $newFrame = $frame->withWrite($variable, $kind, ++$this->variableWriteIdCounter); + if ($newFrame !== $frame) { + $this->replaceVariableWritesFrame($newFrame); + } + + return $newFrame->getWrite($variable); + } + + /** + * The markers a new write of the variable kills - see + * MutatingScope::assignVariable(). + * + * @return list + */ + public function getVariableWriteMarkersToKill(string $variableName): array + { + $frame = $this->getVariableWritesFrame(); + if ($frame === null) { + return []; + } + + return $frame->getMarkerExprsForName($variableName); + } + + /** + * A source-level read of the variable on $scope: every write whose marker + * still reaches $scope has now been read. + */ + public function markVariableRead(string $variableName, MutatingScope $scope): void + { + $frame = $this->getVariableWritesFrame(); + if ($frame === null || $this->isProcessingOnDemand()) { + return; + } + $newFrame = $frame->withReadsFor($variableName, $scope); + if ($newFrame === $frame) { + return; + } + $this->replaceVariableWritesFrame($newFrame); + } + + /** + * A read of every variable (get_defined_vars(), include, eval, $$name). + */ + public function markAllReachingVariablesRead(MutatingScope $scope): void + { + $frame = $this->getVariableWritesFrame(); + if ($frame === null || $this->isProcessingOnDemand()) { + return; + } + $newFrame = $frame->withAllReachingRead($scope); + if ($newFrame === $frame) { + return; + } + $this->replaceVariableWritesFrame($newFrame); + } + + /** + * The variable's writes escape the body (global, static, reference alias): + * none of them is ever reported. + */ + public function markVariableUntracked(string $variableName): void + { + $frame = $this->getVariableWritesFrame(); + if ($frame === null) { + return; + } + $newFrame = $frame->withUntracked($variableName); + if ($newFrame === $frame) { + return; + } + $this->replaceVariableWritesFrame($newFrame); + } + + /** + * The body's control flow defeats reaching-write tracking (goto). + */ + public function markVariableWritesOpaque(): void + { + $frame = $this->getVariableWritesFrame(); + if ($frame === null) { + return; + } + $newFrame = $frame->withOpaque(); + if ($newFrame === $frame) { + return; + } + $this->replaceVariableWritesFrame($newFrame); + } + /** * Opens an engine-feeding gatherer frame for the duration of a body walk. * The caller closes it in a finally block via popNodeGatherer(). @@ -1592,6 +1780,11 @@ private function processClosureNodeInternal( foreach ($expr->uses as $use) { if ($use->byRef) { $byRefUses[] = $use; + if (is_string($use->var->name)) { + // the closure may write the aliased slot later - the outer + // function's writes to it are never dead + $this->markVariableUntracked($use->var->name); + } $useScope = $useScope->enterExpressionAssign($use->var); $inAssignRightSideVariableName = $context->getInAssignRightSideVariableName(); @@ -1691,6 +1884,10 @@ private function processClosureNodeInternal( $gatheredReturnStatementsWithScope[] = [$node, $scope]; }; + // one frame across the by-ref convergence passes and the final walk: + // a write site is identified by its node, so every pass maps onto the + // same writes and the read set only grows + $this->pushVariableWritesFrame($expr->params, $byRefUses); if (count($byRefUses) === 0) { $this->pushNodeGatherer($closureStmtsGatherer); try { @@ -1708,6 +1905,7 @@ private function processClosureNodeInternal( $executionEnds, array_merge($publicStatementResult->getImpurePoints(), $closureImpurePoints), ), $closureReturnStatementsNodeScope, $storage); + $this->callNodeCallback($nodeCallback, $this->popVariableWritesFrame()->createNode($expr), $closureReturnStatementsNodeScope, $storage); return new ProcessClosureResult( $scope, @@ -1808,6 +2006,7 @@ private function processClosureNodeInternal( $executionEnds, array_merge($publicStatementResult->getImpurePoints(), $closureImpurePoints), ), $closureReturnStatementsNodeScope, $storage); + $this->callNodeCallback($nodeCallback, $this->popVariableWritesFrame()->createNode($expr), $closureReturnStatementsNodeScope, $storage); return new ProcessClosureResult( $scope, @@ -3230,8 +3429,9 @@ private function getParameterOutExtensionsType(CallLike $callLike, $calleeReflec /** * @param callable(Node $node, Scope $scope): void $nodeCallback + * @param VariableWrite::KIND_*|null $writeSiteKind */ - public function processVirtualAssign(MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, Expr $var, Expr $assignedExpr, callable $nodeCallback, ?ExpressionResult $assignedExprResult = null): ExpressionResult + public function processVirtualAssign(MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, Expr $var, Expr $assignedExpr, callable $nodeCallback, ?ExpressionResult $assignedExprResult = null, ?int $writeSiteKind = null): ExpressionResult { // work off an available result for the assigned expr: passed by the // caller, or fabricated from a type-carrying virtual node - threaded @@ -3257,7 +3457,7 @@ public function processVirtualAssign(MutatingScope $scope, ExpressionResultStora $assignedExpr, $virtualAssignNodeCallback, ExpressionContext::createDeep(), - AssignTargetWalkMode::virtualAssign(), + AssignTargetWalkMode::virtualAssign($writeSiteKind), ); return $assignHandler->applyWrite( diff --git a/src/Analyser/PreparedAssignTarget.php b/src/Analyser/PreparedAssignTarget.php index f02ae9e2b16..5449979139c 100644 --- a/src/Analyser/PreparedAssignTarget.php +++ b/src/Analyser/PreparedAssignTarget.php @@ -5,6 +5,7 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\ArrayDimFetch; use PHPStan\Node\Expr\ExistingArrayDimFetch; +use PHPStan\Node\Variable\VariableWrite; use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; @@ -39,6 +40,7 @@ final class PreparedAssignTarget * @param non-empty-list|null $existingOffsetTypes * @param non-empty-list|null $existingOffsetNativeTypes * @param ExpressionResult[] $targetChainResults + * @param VariableWrite::KIND_*|null $writeSiteKind */ public function __construct( private string $kind, @@ -67,6 +69,7 @@ public function __construct( private ?ExpressionResult $targetReadResult = null, private array $targetChainResults = [], private ?ExpressionResult $variableNameResult = null, + private ?int $writeSiteKind = null, ) { } @@ -290,4 +293,15 @@ public function getVariableNameResult(): ?ExpressionResult return $this->variableNameResult; } + /** + * The kind of local-variable write site this write records, null when it + * is not a source-level write (by-ref write-backs, scope effects of calls). + * + * @return VariableWrite::KIND_*|null + */ + public function getWriteSiteKind(): ?int + { + return $this->writeSiteKind; + } + } diff --git a/src/Analyser/PropertyHooksProcessor.php b/src/Analyser/PropertyHooksProcessor.php index 3de667461a6..03f890003e0 100644 --- a/src/Analyser/PropertyHooksProcessor.php +++ b/src/Analyser/PropertyHooksProcessor.php @@ -140,11 +140,13 @@ public function processPropertyHooks( $gatheredReturnStatements[] = new ReturnStatement($scope, $node); }); + $nodeScopeResolver->pushVariableWritesFrame($hook->params); try { $statementResult = $nodeScopeResolver->processStmtNodesInternal(new PropertyHookStatementNode($hook), $stmts, $hookScope, $storage, $nodeCallback, StatementContext::createTopLevel())->toPublic(); } finally { $nodeScopeResolver->popNodeGatherer(); } + $variableWritesFrame = $nodeScopeResolver->popVariableWritesFrame(); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyHookReturnStatementsNode( $hook, @@ -156,6 +158,7 @@ public function processPropertyHooks( $hookReflection, $propertyReflection, ), $hookScope, $storage); + $nodeScopeResolver->callNodeCallback($nodeCallback, $variableWritesFrame->createNode($hook), $hookScope, $storage); } } diff --git a/src/Analyser/ScopeOps.php b/src/Analyser/ScopeOps.php index 44e6496fdc3..56518709b7e 100644 --- a/src/Analyser/ScopeOps.php +++ b/src/Analyser/ScopeOps.php @@ -52,7 +52,7 @@ final class ScopeOps // parameter original-value markers (__phpstanOriginalForeachKey etc.) hide a // synthesized Variable child on purpose - reassigning the variable must // invalidate them through containment - so they can never be listed here. - private const COMPOSITIONAL_VIRTUAL_KEY_PREFIXES = ['__phpstanForeachValueByRef(', '__phpstanIntertwinedVariableByReference(', '__phpstanPossiblyImpure(', '__phpstanPropertyInitialization(', '__phpstanRemembered(']; + private const COMPOSITIONAL_VIRTUAL_KEY_PREFIXES = ['__phpstanForeachValueByRef(', '__phpstanIntertwinedVariableByReference(', '__phpstanPossiblyImpure(', '__phpstanPropertyInitialization(', '__phpstanRemembered(', '__phpstanVariableWritten(']; /** * Mirrors MutatingScope::getNodeKey(). @@ -523,6 +523,9 @@ public static function createConditionalExpressions( if (array_key_exists($exprString, $ourExpressionTypes)) { continue; } + if ($mergedExprTypeHolder->getExpr() instanceof VirtualNode) { + continue; + } foreach ($typeGuards as $guardExprString => $guardHolder) { $conditionalExpression = new ConditionalExpressionHolder([$guardExprString => $guardHolder], new ExpressionTypeHolder($mergedExprTypeHolder->getExpr(), new ErrorType(), TrinaryLogic::createNo())); diff --git a/src/Analyser/StmtHandler/ClassMethodHandler.php b/src/Analyser/StmtHandler/ClassMethodHandler.php index 1a333a701f9..85dcfefd470 100644 --- a/src/Analyser/StmtHandler/ClassMethodHandler.php +++ b/src/Analyser/StmtHandler/ClassMethodHandler.php @@ -216,11 +216,13 @@ public function processStmt( $gatheredReturnStatements[] = new ReturnStatement($scope, $node); }); + $nodeScopeResolver->pushVariableWritesFrame($stmt->params); try { $statementResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $methodScope, $bodyStorage, $nodeCallback, StatementContext::createTopLevel())->toPublic(); } finally { $nodeScopeResolver->popNodeGatherer(); } + $variableWritesFrame = $nodeScopeResolver->popVariableWritesFrame(); $methodReflection = $methodScope->getFunction(); if (!$methodReflection instanceof PhpMethodFromParserNodeReflection) { @@ -237,6 +239,7 @@ public function processStmt( $classReflection, $methodReflection, ), $methodScope, $bodyStorage); + $nodeScopeResolver->callNodeCallback($nodeCallback, $variableWritesFrame->createNode($stmt), $methodScope, $bodyStorage); } finally { $scope->popExpressionResultStorage(); } diff --git a/src/Analyser/StmtHandler/ForeachHandler.php b/src/Analyser/StmtHandler/ForeachHandler.php index d80e4dccf1a..29031916daf 100644 --- a/src/Analyser/StmtHandler/ForeachHandler.php +++ b/src/Analyser/StmtHandler/ForeachHandler.php @@ -43,6 +43,7 @@ use PHPStan\Node\Expr\OriginalForeachKeyExpr; use PHPStan\Node\Expr\OriginalForeachValueExpr; use PHPStan\Node\InForeachNode; +use PHPStan\Node\Variable\VariableWrite; use PHPStan\Node\VariableAssignNode; use PHPStan\TrinaryLogic; use PHPStan\Type\BooleanType; @@ -492,6 +493,12 @@ private function enterForeach(NodeScopeResolver $nodeScopeResolver, MutatingScop && ($stmt->keyVar === null || ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name))) ) { $keyVarName = $stmt->keyVar instanceof Variable ? $stmt->keyVar->name : null; + if ($stmt->byRef) { + // the value variable aliases the iteratee's elements - its writes escape + $nodeScopeResolver->markVariableUntracked($stmt->valueVar->name); + } + $valueWrite = $nodeScopeResolver->recordVariableWrite($stmt->valueVar, VariableWrite::KIND_FOREACH_VALUE); + $keyWrite = $stmt->keyVar instanceof Variable ? $nodeScopeResolver->recordVariableWrite($stmt->keyVar, VariableWrite::KIND_FOREACH_KEY) : null; $scope = $scope->enterForeach( $originalScope, $stmt->expr, @@ -500,6 +507,10 @@ private function enterForeach(NodeScopeResolver $nodeScopeResolver, MutatingScop $stmt->valueVar->name, $keyVarName, $stmt->byRef, + $valueWrite, + $valueWrite !== null ? $nodeScopeResolver->getVariableWriteMarkersToKill($stmt->valueVar->name) : [], + $keyWrite, + $keyWrite !== null && $keyVarName !== null ? $nodeScopeResolver->getVariableWriteMarkersToKill($keyVarName) : [], ); $vars = [$stmt->valueVar->name]; if ($keyVarName !== null) { @@ -516,12 +527,14 @@ private function enterForeach(NodeScopeResolver $nodeScopeResolver, MutatingScop $originalScope->getIterableValueType($nativeIterateeType), ), $nodeCallback, + writeSiteKind: VariableWrite::KIND_FOREACH_VALUE, )->getScope(); $vars = $nodeScopeResolver->getAssignedVariables($stmt->valueVar); if ( $stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name) ) { - $scope = $scope->enterForeachKey($originalScope, $stmt->expr, $iterateeType, $nativeIterateeType, $stmt->keyVar->name); + $keyWrite = $nodeScopeResolver->recordVariableWrite($stmt->keyVar, VariableWrite::KIND_FOREACH_KEY); + $scope = $scope->enterForeachKey($originalScope, $stmt->expr, $iterateeType, $nativeIterateeType, $stmt->keyVar->name, $keyWrite, $keyWrite !== null ? $nodeScopeResolver->getVariableWriteMarkersToKill($stmt->keyVar->name) : []); $vars[] = $stmt->keyVar->name; } elseif ($stmt->keyVar !== null) { $scope = $nodeScopeResolver->processVirtualAssign( @@ -534,6 +547,7 @@ private function enterForeach(NodeScopeResolver $nodeScopeResolver, MutatingScop $originalScope->getIterableKeyType($nativeIterateeType), ), $nodeCallback, + writeSiteKind: VariableWrite::KIND_FOREACH_KEY, )->getScope(); $vars = array_merge($vars, $nodeScopeResolver->getAssignedVariables($stmt->keyVar)); } @@ -658,6 +672,8 @@ private function tryProcessUnrolledConstantArrayForeach( $valueVarName = $stmt->valueVar->name; $keyVarName = $stmt->keyVar instanceof Variable ? $stmt->keyVar->name : null; + $valueWrite = $nodeScopeResolver->recordVariableWrite($stmt->valueVar, VariableWrite::KIND_FOREACH_VALUE); + $keyWrite = $stmt->keyVar instanceof Variable ? $nodeScopeResolver->recordVariableWrite($stmt->keyVar, VariableWrite::KIND_FOREACH_KEY) : null; $allBodyScopes = []; $allChainScopes = []; @@ -694,6 +710,8 @@ private function tryProcessUnrolledConstantArrayForeach( $valueType, $nativeValueType, TrinaryLogic::createYes(), + write: $valueWrite, + supersededMarkerExprs: $valueWrite !== null ? $nodeScopeResolver->getVariableWriteMarkersToKill($valueVarName) : [], ); $iterScope = $iterScope->assignExpression( new OriginalForeachValueExpr($valueVarName), @@ -706,6 +724,8 @@ private function tryProcessUnrolledConstantArrayForeach( $keyType, $nativeKeyType, TrinaryLogic::createYes(), + write: $keyWrite, + supersededMarkerExprs: $keyWrite !== null ? $nodeScopeResolver->getVariableWriteMarkersToKill($keyVarName) : [], ); $iterScope = $iterScope->assignExpression( new OriginalForeachKeyExpr($keyVarName), diff --git a/src/Analyser/StmtHandler/FunctionHandler.php b/src/Analyser/StmtHandler/FunctionHandler.php index b5684d84c60..039cbd8e84b 100644 --- a/src/Analyser/StmtHandler/FunctionHandler.php +++ b/src/Analyser/StmtHandler/FunctionHandler.php @@ -135,11 +135,13 @@ public function processStmt( $gatheredReturnStatements[] = new ReturnStatement($scope, $node); }); + $nodeScopeResolver->pushVariableWritesFrame($stmt->params); try { $statementResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $functionScope, $bodyStorage, $nodeCallback, StatementContext::createTopLevel())->toPublic(); } finally { $nodeScopeResolver->popNodeGatherer(); } + $variableWritesFrame = $nodeScopeResolver->popVariableWritesFrame(); $nodeScopeResolver->callNodeCallback($nodeCallback, new FunctionReturnStatementsNode( $stmt, @@ -150,6 +152,7 @@ public function processStmt( array_merge($statementResult->getImpurePoints(), $functionImpurePoints), $functionReflection, ), $functionScope, $bodyStorage); + $nodeScopeResolver->callNodeCallback($nodeCallback, $variableWritesFrame->createNode($stmt), $functionScope, $bodyStorage); } finally { $scope->popExpressionResultStorage(); } diff --git a/src/Analyser/StmtHandler/GlobalHandler.php b/src/Analyser/StmtHandler/GlobalHandler.php index 96a45ae59d8..2740a3b7f64 100644 --- a/src/Analyser/StmtHandler/GlobalHandler.php +++ b/src/Analyser/StmtHandler/GlobalHandler.php @@ -86,6 +86,7 @@ public function processStmt( } $varType = $this->getGlobalVariableType($var->name); + $nodeScopeResolver->markVariableUntracked($var->name); $scope = $scope->assignVariable($var->name, $varType, $varType, TrinaryLogic::createYes()); $vars[] = $var->name; } diff --git a/src/Analyser/StmtHandler/GotoHandler.php b/src/Analyser/StmtHandler/GotoHandler.php index 43c4a3029a1..3a619f1d3f7 100644 --- a/src/Analyser/StmtHandler/GotoHandler.php +++ b/src/Analyser/StmtHandler/GotoHandler.php @@ -34,6 +34,9 @@ public function processStmt( StatementContext $context, ): InternalStatementResult { + // a jump defeats reaching-write tracking for the whole body + $nodeScopeResolver->markVariableWritesOpaque(); + return new InternalStatementResult($scope, hasYield: false, isAlwaysTerminating: true, exitPoints: [ new InternalStatementExitPoint($stmt, $scope), ], throwPoints: [], impurePoints: []); diff --git a/src/Analyser/StmtHandler/StaticVariableHandler.php b/src/Analyser/StmtHandler/StaticVariableHandler.php index e35edc409c0..3a5956b4489 100644 --- a/src/Analyser/StmtHandler/StaticVariableHandler.php +++ b/src/Analyser/StmtHandler/StaticVariableHandler.php @@ -73,6 +73,7 @@ public function processStmt( $impurePoints = array_merge($impurePoints, $varResult->getImpurePoints()); $scope = $scope->exitExpressionAssign($var->var); + $nodeScopeResolver->markVariableUntracked($var->var->name); $scope = $scope->assignVariable($var->var->name, new MixedType(), new MixedType(), TrinaryLogic::createYes()); $vars[] = $var->var->name; } diff --git a/src/Analyser/StmtHandler/TryCatchHandler.php b/src/Analyser/StmtHandler/TryCatchHandler.php index ac4612abc84..07db9144e5b 100644 --- a/src/Analyser/StmtHandler/TryCatchHandler.php +++ b/src/Analyser/StmtHandler/TryCatchHandler.php @@ -16,6 +16,7 @@ use PHPStan\Node\CatchWithUnthrownExceptionNode; use PHPStan\Node\Expr\TypeExpr; use PHPStan\Node\FinallyExitPointsNode; +use PHPStan\Node\Variable\VariableWrite; use PHPStan\Node\VariableAssignNode; use PHPStan\ShouldNotHappenException; use PHPStan\Type\NeverType; @@ -201,6 +202,7 @@ public function processStmt( } $variableName = null; + $catchWrite = null; if ($catchNode->var !== null) { if (!is_string($catchNode->var->name)) { throw new ShouldNotHappenException(); @@ -208,9 +210,10 @@ public function processStmt( $variableName = $catchNode->var->name; $nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($catchNode->var, new TypeExpr($catchType)), $scope, $storage); + $catchWrite = $nodeScopeResolver->recordVariableWrite($catchNode->var, VariableWrite::KIND_CATCH); } - $catchScopeResult = $nodeScopeResolver->processStmtNodesInternal($catchNode, $catchNode->stmts, $catchScope->enterCatchType($catchType, $variableName), $storage, $nodeCallback, $context); + $catchScopeResult = $nodeScopeResolver->processStmtNodesInternal($catchNode, $catchNode->stmts, $catchScope->enterCatchType($catchType, $variableName, $catchWrite, $catchWrite !== null && $variableName !== null ? $nodeScopeResolver->getVariableWriteMarkersToKill($variableName) : []), $storage, $nodeCallback, $context); $catchScopeForFinally = $catchScopeResult->getScope(); $finalScope = $catchScopeResult->isAlwaysTerminating() ? $finalScope : $catchScopeResult->getScope()->mergeWith($finalScope); diff --git a/src/Analyser/VariableWritesFrame.php b/src/Analyser/VariableWritesFrame.php new file mode 100644 index 00000000000..35cd471b1c5 --- /dev/null +++ b/src/Analyser/VariableWritesFrame.php @@ -0,0 +1,196 @@ + $writes id => write + * @param array $idsByNode spl_object_id(target node) => id + * @param array> $idsByName + * @param array $readIds + * @param array $untrackedNames + */ + private function __construct( + private array $writes, + private array $idsByNode, + private array $idsByName, + private array $readIds, + private array $untrackedNames, + private bool $opaque, + ) + { + } + + public static function create(): self + { + return new self([], [], [], [], [], false); + } + + /** + * @param VariableWrite::KIND_* $kind + */ + public function withWrite(Expr\Variable $variable, int $kind, int $id): self + { + if (!is_string($variable->name)) { + return $this; + } + $name = $variable->name; + if ( + $name === 'this' + || in_array($name, Scope::SUPERGLOBAL_VARIABLES, true) + || isset($this->untrackedNames[$name]) + ) { + return $this; + } + $nodeId = spl_object_id($variable); + if (isset($this->idsByNode[$nodeId])) { + return $this; + } + + $writes = $this->writes; + $writes[$id] = new VariableWrite($name, $variable, $id, $kind); + $idsByNode = $this->idsByNode; + $idsByNode[$nodeId] = $id; + $idsByName = $this->idsByName; + $idsByName[$name][] = $id; + + return new self($writes, $idsByNode, $idsByName, $this->readIds, $this->untrackedNames, $this->opaque); + } + + public function getWrite(Expr\Variable $variable): ?VariableWrite + { + $id = $this->idsByNode[spl_object_id($variable)] ?? null; + if ($id === null) { + return null; + } + + return $this->writes[$id]; + } + + /** + * Markers of every write of the variable registered so far - the set a new + * write of the same variable kills. + * + * @return list + */ + public function getMarkerExprsForName(string $name): array + { + $exprs = []; + foreach ($this->idsByName[$name] ?? [] as $id) { + $exprs[] = $this->writes[$id]->getMarkerExpr(); + } + + return $exprs; + } + + /** + * Records a read of the variable: every unread write whose marker still + * reaches $scope has now been read. + */ + public function withReadsFor(string $name, MutatingScope $scope): self + { + $ids = $this->idsByName[$name] ?? null; + if ($ids === null) { + return $this; + } + + return $this->withReadsOf($ids, $scope); + } + + /** + * Records a read of every variable (get_defined_vars(), include, eval, $$name). + */ + public function withAllReachingRead(MutatingScope $scope): self + { + $ids = []; + foreach ($this->idsByName as $nameIds) { + foreach ($nameIds as $id) { + $ids[] = $id; + } + } + + return $this->withReadsOf($ids, $scope); + } + + /** + * @param list $ids + */ + private function withReadsOf(array $ids, MutatingScope $scope): self + { + $readIds = null; + foreach ($ids as $id) { + if (isset($this->readIds[$id])) { + continue; + } + if ($scope->hasExpressionType($this->writes[$id]->getMarkerExpr())->no()) { + continue; + } + if ($readIds === null) { + $readIds = $this->readIds; + } + $readIds[$id] = true; + } + if ($readIds === null) { + return $this; + } + + return new self($this->writes, $this->idsByNode, $this->idsByName, $readIds, $this->untrackedNames, $this->opaque); + } + + public function withUntracked(string $name): self + { + if (isset($this->untrackedNames[$name])) { + return $this; + } + $untrackedNames = $this->untrackedNames; + $untrackedNames[$name] = true; + + return new self($this->writes, $this->idsByNode, $this->idsByName, $this->readIds, $untrackedNames, $this->opaque); + } + + public function withOpaque(): self + { + if ($this->opaque) { + return $this; + } + + return new self($this->writes, $this->idsByNode, $this->idsByName, $this->readIds, $this->untrackedNames, true); + } + + /** + * @return list + */ + public function getWrites(): array + { + return array_values($this->writes); + } + + public function createNode(Node\FunctionLike $functionLike): VariableWritesNode + { + return new VariableWritesNode($functionLike, $this->getWrites(), $this->readIds, $this->untrackedNames, $this->opaque); + } + +} diff --git a/src/Node/Expr/VariableWrittenExpr.php b/src/Node/Expr/VariableWrittenExpr.php new file mode 100644 index 00000000000..29c73d7b78c --- /dev/null +++ b/src/Node/Expr/VariableWrittenExpr.php @@ -0,0 +1,50 @@ +variableName; + } + + public function getWriteId(): int + { + return $this->writeId; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_VariableWrittenExpr'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/Printer/Printer.php b/src/Node/Printer/Printer.php index 61ffd313960..cc8c816b5cc 100644 --- a/src/Node/Printer/Printer.php +++ b/src/Node/Printer/Printer.php @@ -25,6 +25,7 @@ use PHPStan\Node\Expr\SetOffsetValueTypeExpr; use PHPStan\Node\Expr\TypeExpr; use PHPStan\Node\Expr\UnsetOffsetExpr; +use PHPStan\Node\Expr\VariableWrittenExpr; use PHPStan\Node\FunctionCallableNode; use PHPStan\Node\InstantiationCallableNode; use PHPStan\Node\IssetExpr; @@ -148,6 +149,11 @@ protected function pPHPStan_Node_PropertyInitializationExpr(PropertyInitializati return sprintf('__phpstanPropertyInitialization(%s)', $expr->getPropertyName()); } + protected function pPHPStan_Node_VariableWrittenExpr(VariableWrittenExpr $expr): string // phpcs:ignore + { + return sprintf('__phpstanVariableWritten($%s, %d)', $expr->getVariableName(), $expr->getWriteId()); + } + protected function pPHPStan_Node_CloneReinitializationExpr(CloneReinitializationExpr $expr): string // phpcs:ignore { return sprintf('__phpstanCloneReinitialization(%s)', $expr->getPropertyName()); diff --git a/src/Node/Variable/VariableWrite.php b/src/Node/Variable/VariableWrite.php new file mode 100644 index 00000000000..ce56b3c8861 --- /dev/null +++ b/src/Node/Variable/VariableWrite.php @@ -0,0 +1,75 @@ +markerExpr = new VariableWrittenExpr($variableName, $id); + } + + public function getVariableName(): string + { + return $this->variableName; + } + + /** + * The target node of the write - the source of the reported line. + */ + public function getVariable(): Expr\Variable + { + return $this->variable; + } + + public function getId(): int + { + return $this->id; + } + + /** + * @return self::KIND_* + */ + public function getKind(): int + { + return $this->kind; + } + + /** + * The scope marker that says "this write still reaches here". + */ + public function getMarkerExpr(): VariableWrittenExpr + { + return $this->markerExpr; + } + +} diff --git a/src/Node/VariableWritesNode.php b/src/Node/VariableWritesNode.php new file mode 100644 index 00000000000..bd7ff3b6ddf --- /dev/null +++ b/src/Node/VariableWritesNode.php @@ -0,0 +1,84 @@ + $writes + * @param array $readWriteIds + * @param array $untrackedVariableNames + */ + public function __construct( + Node\FunctionLike $functionLike, + private array $writes, + private array $readWriteIds, + private array $untrackedVariableNames, + private bool $opaque, + ) + { + parent::__construct($functionLike->getAttributes()); + } + + /** + * @return list + */ + public function getWrites(): array + { + return $this->writes; + } + + /** + * Whether some path from the write reaches a read of the written value. + */ + public function isRead(VariableWrite $write): bool + { + return isset($this->readWriteIds[$write->getId()]); + } + + /** + * Variables whose writes escape the body (by-ref parameters and uses, + * global/static variables, reference aliases) - every write counts as used. + */ + public function isUntracked(string $variableName): bool + { + return isset($this->untrackedVariableNames[$variableName]); + } + + /** + * The body contains a construct (goto) that defeats reaching-write tracking. + */ + public function isOpaque(): bool + { + return $this->opaque; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_VariableWritesNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index d3d1773f802..bb31fbaf615 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -100,7 +100,6 @@ public static function selectFromArgs( } $reorderedArgs = $args; - $parameters = null; $singleParametersAcceptor = null; if (count($parametersAcceptors) === 1) { if (!array_is_list($args)) { diff --git a/src/Rules/Arrays/ArrayDestructuringRule.php b/src/Rules/Arrays/ArrayDestructuringRule.php index eac1a12956e..e0d9327315a 100644 --- a/src/Rules/Arrays/ArrayDestructuringRule.php +++ b/src/Rules/Arrays/ArrayDestructuringRule.php @@ -84,7 +84,6 @@ private function getErrors(Scope $scope, Node\Expr\List_ $var, Expr $expr): arra continue; } - $keyExpr = null; if ($item->key === null) { $keyType = new ConstantIntegerType($i); $keyExpr = new Node\Scalar\Int_($i); diff --git a/src/Rules/DeadCode/UnusedPrivatePropertyRule.php b/src/Rules/DeadCode/UnusedPrivatePropertyRule.php index b96c6f58bee..66ccc951b04 100644 --- a/src/Rules/DeadCode/UnusedPrivatePropertyRule.php +++ b/src/Rules/DeadCode/UnusedPrivatePropertyRule.php @@ -19,6 +19,7 @@ use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\ObjectType; use function array_key_exists; +use function array_keys; use function array_map; use function count; use function is_string; @@ -161,7 +162,7 @@ public function processNode(Node $node, Scope $scope): array $strings = $propertyNameType->getConstantStrings(); if (count($strings) === 0) { // handle subtractions of a dynamic property fetch - foreach ($properties as $propertyName => $data) { + foreach (array_keys($properties) as $propertyName) { if ((new ConstantStringType($propertyName))->isSuperTypeOf($propertyNameType)->no()) { continue; } diff --git a/src/Rules/DeadCode/UnusedVariableRule.php b/src/Rules/DeadCode/UnusedVariableRule.php new file mode 100644 index 00000000000..2a96c67f5ce --- /dev/null +++ b/src/Rules/DeadCode/UnusedVariableRule.php @@ -0,0 +1,64 @@ + + */ +final class UnusedVariableRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return VariableWritesNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->isOpaque()) { + return []; + } + + $errors = []; + foreach ($node->getWrites() as $write) { + $name = $write->getVariableName(); + if ($node->isUntracked($name)) { + continue; + } + if ($node->isRead($write)) { + continue; + } + if (str_starts_with($name, '_')) { + continue; + } + if ( + $write->getKind() === VariableWrite::KIND_CATCH + && !$this->phpVersion->supportsNoncapturingCatches() + ) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf('Value assigned to variable $%s is never read.', $name)) + ->identifier('variable.unused') + ->line($write->getVariable()->getStartLine()) + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index 0ba5e1a7a57..e8652b88235 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -265,7 +265,7 @@ public function check( if (!$hasNamedArguments) { $invokedParametersCount = count($arguments); - foreach ($arguments as [$argumentValue, $argumentValueType, $unpack, $argumentName]) { + foreach ($arguments as [2 => $unpack]) { if ($unpack) { $invokedParametersCount = max($functionParametersMinCount, $functionParametersMaxCount); break; diff --git a/src/Rules/Namespaces/ExistingNamesInGroupUseRule.php b/src/Rules/Namespaces/ExistingNamesInGroupUseRule.php index 6885c42817a..088006c1795 100644 --- a/src/Rules/Namespaces/ExistingNamesInGroupUseRule.php +++ b/src/Rules/Namespaces/ExistingNamesInGroupUseRule.php @@ -45,8 +45,6 @@ public function processNode(Node $node, Scope $scope): array { $errors = []; foreach ($node->uses as $use) { - $error = null; - /** @var Node\Name $name */ $name = Node\Name::concat($node->prefix, $use->name, ['startLine' => $use->getStartLine()]); if ( diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 24058f7e3ea..a258ed88f67 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -21,7 +21,7 @@ final class TurboExtensionEnabler * version is the short SHA of the last commit touching turbo-ext/src/, * enforced by the phar.yml turbo-version job. */ - public const EXPECTED_EXTENSION_VERSION = '4df6ab1'; + public const EXPECTED_EXTENSION_VERSION = 'dd4232d'; private static bool $typeCombinatorCacheEnabled = false; diff --git a/src/Type/CallableTypeHelper.php b/src/Type/CallableTypeHelper.php index 709ed1fb610..530ebbd3265 100644 --- a/src/Type/CallableTypeHelper.php +++ b/src/Type/CallableTypeHelper.php @@ -5,6 +5,7 @@ use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\TrinaryLogic; use function array_key_exists; +use function array_keys; use function array_merge; use function count; use function sprintf; @@ -33,7 +34,7 @@ public static function isParametersAcceptorSuperTypeOf( && $lastParameter->isVariadic() && $theirParameterCount < $ourParameterCount ) { - foreach ($ourParameters as $i => $ourParameter) { + foreach (array_keys($ourParameters) as $i) { if (array_key_exists($i, $theirParameters)) { continue; } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 5f95b0d6ce0..fcb35f63c85 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2866,7 +2866,7 @@ public function isKeysSupersetOf(self $otherArray): bool $otherHasExtras = $otherArray->isUnsealed()->yes(); $otherHasRequiredKeys = false; - foreach ($otherArray->keyTypes as $j => $keyType) { + foreach (array_keys($otherArray->keyTypes) as $j) { if ($otherArray->isOptionalKey($j)) { continue; } @@ -2878,7 +2878,7 @@ public function isKeysSupersetOf(self $otherArray): bool // already accepts []. i.e., all of $this's known keys are optional. Otherwise // merge would add [] as a new instance. if (!$otherHasRequiredKeys && !$otherHasExtras && count($otherArray->keyTypes) === 0) { - foreach ($this->keyTypes as $i => $keyType) { + foreach (array_keys($this->keyTypes) as $i) { if (!$this->isOptionalKey($i)) { return false; } @@ -3028,7 +3028,6 @@ public function mergeWith(self $otherArray): self $keyTypes = []; $valueTypes = []; $optionalKeys = []; - $nextAutoIndexes = [0]; $otherKeyIndexMap = $otherArray->getKeyIndexMap(); $processed = []; diff --git a/src/Type/Generic/TemplateTypeTrait.php b/src/Type/Generic/TemplateTypeTrait.php index d010d26ad5d..e68c9e097f2 100644 --- a/src/Type/Generic/TemplateTypeTrait.php +++ b/src/Type/Generic/TemplateTypeTrait.php @@ -71,11 +71,9 @@ public function describe(VerbosityLevel $level): string { $basicDescription = function () use ($level): string { // @phpstan-ignore booleanAnd.alwaysFalse, instanceof.alwaysFalse, booleanAnd.alwaysFalse, instanceof.alwaysFalse, instanceof.alwaysTrue - if ($this->bound instanceof MixedType && $this->bound->getSubtractedType() === null && !$this->bound instanceof TemplateMixedType) { - $boundDescription = ''; - } else { - $boundDescription = sprintf(' of %s', $this->bound->describe($level)); - } + $boundDescription = $this->bound instanceof MixedType && $this->bound->getSubtractedType() === null && !$this->bound instanceof TemplateMixedType + ? '' + : sprintf(' of %s', $this->bound->describe($level)); $defaultDescription = ''; if ($this->default !== null) { $recursionGuard = RecursionGuard::runOnObjectIdentity($this->default, fn () => $this->default->describe($level)); diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index b82ce7bb971..0889971e490 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -181,7 +181,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, ]; } } else { - foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { + foreach ($offsetTypes as $key => [$hasOffsetValue]) { // more precise values-types will be calculated elsewhere. // just remember the offset key. $offsetTypes[$key] = [ diff --git a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php index 834175222a2..642894bf872 100644 --- a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php @@ -170,7 +170,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, ]; } } else { - foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { + foreach ($offsetTypes as $key => [$hasOffsetValue]) { // more precise values-types will be calculated elsewhere. // just remember the offset key. $offsetTypes[$key] = [ diff --git a/src/Type/Php/DateIntervalConstructorThrowTypeExtension.php b/src/Type/Php/DateIntervalConstructorThrowTypeExtension.php index 971f593bfc8..da3cc191234 100644 --- a/src/Type/Php/DateIntervalConstructorThrowTypeExtension.php +++ b/src/Type/Php/DateIntervalConstructorThrowTypeExtension.php @@ -13,6 +13,7 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use Throwable; use function count; #[AutowiredService] @@ -40,7 +41,7 @@ public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflect foreach ($constantStrings as $constantString) { try { new DateInterval($constantString->getValue()); - } catch (\Exception $e) { // phpcs:ignore + } catch (Throwable) { return $this->exceptionType(); } diff --git a/src/Type/Php/DateTimeConstructorThrowTypeExtension.php b/src/Type/Php/DateTimeConstructorThrowTypeExtension.php index 2facd039453..45cdb606a3a 100644 --- a/src/Type/Php/DateTimeConstructorThrowTypeExtension.php +++ b/src/Type/Php/DateTimeConstructorThrowTypeExtension.php @@ -14,6 +14,7 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use Throwable; use function count; use function in_array; @@ -42,7 +43,7 @@ public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflect foreach ($constantStrings as $constantString) { try { new DateTime($constantString->getValue()); - } catch (\Exception $e) { // phpcs:ignore + } catch (Throwable) { return $this->exceptionType(); } diff --git a/src/Type/Php/DateTimeModifyMethodThrowTypeExtension.php b/src/Type/Php/DateTimeModifyMethodThrowTypeExtension.php index 02c0099c4ee..f3b91b8b358 100644 --- a/src/Type/Php/DateTimeModifyMethodThrowTypeExtension.php +++ b/src/Type/Php/DateTimeModifyMethodThrowTypeExtension.php @@ -14,6 +14,7 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use Throwable; use function count; use function in_array; @@ -47,7 +48,7 @@ public function getThrowTypeFromMethodCall(MethodReflection $methodReflection, M try { $dateTime = new DateTime(); $dateTime->modify($constantString->getValue()); - } catch (\Exception $e) { // phpcs:ignore + } catch (Throwable) { return $this->exceptionType(); } diff --git a/src/Type/Php/DateTimeZoneConstructorThrowTypeExtension.php b/src/Type/Php/DateTimeZoneConstructorThrowTypeExtension.php index 0c4c0bd9dd9..d58e183886a 100644 --- a/src/Type/Php/DateTimeZoneConstructorThrowTypeExtension.php +++ b/src/Type/Php/DateTimeZoneConstructorThrowTypeExtension.php @@ -13,6 +13,7 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use Throwable; use function count; #[AutowiredService] @@ -40,7 +41,7 @@ public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflect foreach ($constantStrings as $constantString) { try { new DateTimeZone($constantString->getValue()); - } catch (\Exception $e) { // phpcs:ignore + } catch (Throwable) { return $this->exceptionType(); } diff --git a/src/Type/Php/SimpleXMLElementConstructorThrowTypeExtension.php b/src/Type/Php/SimpleXMLElementConstructorThrowTypeExtension.php index 78faf2d1d33..018f9be27ca 100644 --- a/src/Type/Php/SimpleXMLElementConstructorThrowTypeExtension.php +++ b/src/Type/Php/SimpleXMLElementConstructorThrowTypeExtension.php @@ -11,6 +11,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use SimpleXMLElement; +use Throwable; use function count; use function extension_loaded; use function libxml_use_internal_errors; @@ -41,7 +42,7 @@ public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflect foreach ($constantStrings as $constantString) { try { new SimpleXMLElement($constantString->getValue()); - } catch (\Exception $e) { // phpcs:ignore + } catch (Throwable) { return $methodReflection->getThrowType(); } diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 6ecc8dd8182..f357c2c8d73 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -2048,7 +2048,7 @@ public static function doIntersect(Type ...$types): Type if ($constArrayIsI) { $types[$i] = $newArrayType; - array_splice($types, $j--, 1); + array_splice($types, $j, 1); } else { $types[$j] = $newArrayType; array_splice($types, $i--, 1); diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 788b3ded0d6..23520271f44 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -24,12 +24,16 @@ class AnalyserIntegrationTest extends PHPStanTestCase public function testUndefinedVariableFromAssignErrorHasLine(): void { $errors = $this->runAnalyse(__DIR__ . '/data/undefined-variable-assign.php'); - $this->assertCount(2, $errors); + $this->assertCount(3, $errors); $error = $errors[0]; $this->assertSame('Undefined variable: $bar', $error->getMessage()); $this->assertSame(3, $error->getLine()); $error = $errors[1]; + $this->assertSame('Value assigned to variable $foo is never read.', $error->getMessage()); + $this->assertSame(3, $error->getLine()); + + $error = $errors[2]; $this->assertSame('Variable $foo might not be defined.', $error->getMessage()); $this->assertSame(6, $error->getLine()); } @@ -57,7 +61,11 @@ public function testMissingFunctionErrorAboutMisconfiguredAutoloader(): void public function testAnonymousClassWithInheritedConstructor(): void { $errors = $this->runAnalyse(__DIR__ . '/data/anonymous-class-with-inherited-constructor.php'); - $this->assertNoErrors($errors); + $this->assertCount(2, $errors); + $this->assertSame('Value assigned to variable $a is never read.', $errors[0]->getMessage()); + $this->assertSame(17, $errors[0]->getLine()); + $this->assertSame('Value assigned to variable $a is never read.', $errors[1]->getMessage()); + $this->assertSame(33, $errors[1]->getLine()); } public function testNestedFunctionCallsDoNotCauseExcessiveFunctionNesting(): void @@ -167,14 +175,20 @@ public function testExtendsPdoStatementCrash(): void public function testBug14548(): void { $errors = $this->runAnalyse(__DIR__ . '/data/bug-14548.php'); - $this->assertNoErrors($errors); + $this->assertCount(1, $errors); + $this->assertSame('Value assigned to variable $priorityName is never read.', $errors[0]->getMessage()); + $this->assertSame(18, $errors[0]->getLine()); } public function testBug12803(): void { // crash $errors = $this->runAnalyse(__DIR__ . '/data/bug-12803.php'); - $this->assertNoErrors($errors); + $this->assertCount(2, $errors); + $this->assertSame('Value assigned to variable $a is never read.', $errors[0]->getMessage()); + $this->assertSame(14, $errors[0]->getLine()); + $this->assertSame('Value assigned to variable $b is never read.', $errors[1]->getMessage()); + $this->assertSame(15, $errors[1]->getLine()); } public function testArrayDestructuringArrayDimFetch(): void @@ -225,16 +239,22 @@ public function testBug14604(): void { // crash $errors = $this->runAnalyse(__DIR__ . '/data/bug-14604.php'); - $this->assertNoErrors($errors); + $this->assertCount(1, $errors); + $this->assertSame('Value assigned to variable $locations is never read.', $errors[0]->getMessage()); + $this->assertSame(17, $errors[0]->getLine()); } public function testBug13424(): void { // crash $errors = $this->runAnalyse(__DIR__ . '/data/bug-13424.php'); - $this->assertCount(1, $errors); - $this->assertSame('Instantiated class Bug13424\Hello not found.', $errors[0]->getMessage()); - $this->assertSame(14, $errors[0]->getLine()); + $this->assertCount(3, $errors); + $this->assertSame('Value assigned to variable $hello is never read.', $errors[0]->getMessage()); + $this->assertSame(10, $errors[0]->getLine()); + $this->assertSame('Instantiated class Bug13424\Hello not found.', $errors[1]->getMessage()); + $this->assertSame(14, $errors[1]->getLine()); + $this->assertSame('Value assigned to variable $hello is never read.', $errors[2]->getMessage()); + $this->assertSame(14, $errors[2]->getLine()); } public function testTwoSameClassesInSingleFile(): void @@ -290,7 +310,9 @@ public function testBug3468(): void { // false positive $errors = $this->runAnalyse(__DIR__ . '/data/bug-3468.php'); - $this->assertNoErrors($errors); + $this->assertCount(1, $errors); + $this->assertSame('Value assigned to variable $element is never read.', $errors[0]->getMessage()); + $this->assertSame(18, $errors[0]->getLine()); } public function testBug3379(): void @@ -363,7 +385,11 @@ public function testBug3769(): void // false positive require_once __DIR__ . '/../Rules/Generics/data/bug-3769.php'; $errors = $this->runAnalyse(__DIR__ . '/../Rules/Generics/data/bug-3769.php'); - $this->assertNoErrors($errors); + $this->assertCount(11, $errors); + foreach ([13, 29, 30, 31, 40, 75, 76, 77, 78, 108, 111] as $i => $line) { + $this->assertSame('Value assigned to variable $a is never read.', $errors[$i]->getMessage()); + $this->assertSame($line, $errors[$i]->getLine()); + } } public function testBug6301(): void @@ -400,8 +426,10 @@ public function testBug4713(): void { // crash $errors = $this->runAnalyse(__DIR__ . '/data/bug-4713.php'); - $this->assertCount(1, $errors); + $this->assertCount(2, $errors); $this->assertSame('Method Bug4713\Service::createInstance() should return Bug4713\Service but returns object.', $errors[0]->getMessage()); + $this->assertSame('Value assigned to variable $service is never read.', $errors[1]->getMessage()); + $this->assertSame(14, $errors[1]->getLine()); $reflectionProvider = self::createReflectionProvider(); $class = $reflectionProvider->getClass(Service::class); @@ -589,7 +617,9 @@ public function testBug6253(): void __DIR__ . '/data/bug-6253-collection-trait.php', ], ); - $this->assertNoErrors($errors); + $this->assertCount(1, $errors); + $this->assertSame('Value assigned to variable $c is never read.', $errors[0]->getMessage()); + $this->assertSame(9, $errors[0]->getLine()); } public function testBug13057(): void @@ -713,11 +743,21 @@ public function testBug6160(): void { // false positive $errors = $this->runAnalyse(__DIR__ . '/data/bug-6160.php'); - $this->assertCount(2, $errors); + $this->assertCount(7, $errors); $this->assertSame('Parameter #1 $flags of static method Bug6160\HelloWorld::split() expects 0|1|2, 94561 given.', $errors[0]->getMessage()); $this->assertSame(19, $errors[0]->getLine()); - $this->assertSame('Parameter #1 $flags of static method Bug6160\HelloWorld::split() expects 0|1|2, \'sdf\' given.', $errors[1]->getMessage()); - $this->assertSame(23, $errors[1]->getLine()); + $this->assertSame('Value assigned to variable $a is never read.', $errors[1]->getMessage()); + $this->assertSame(19, $errors[1]->getLine()); + $this->assertSame('Value assigned to variable $a is never read.', $errors[2]->getMessage()); + $this->assertSame(20, $errors[2]->getLine()); + $this->assertSame('Value assigned to variable $a is never read.', $errors[3]->getMessage()); + $this->assertSame(21, $errors[3]->getLine()); + $this->assertSame('Value assigned to variable $a is never read.', $errors[4]->getMessage()); + $this->assertSame(22, $errors[4]->getLine()); + $this->assertSame('Parameter #1 $flags of static method Bug6160\HelloWorld::split() expects 0|1|2, \'sdf\' given.', $errors[5]->getMessage()); + $this->assertSame(23, $errors[5]->getLine()); + $this->assertSame('Value assigned to variable $a is never read.', $errors[6]->getMessage()); + $this->assertSame(23, $errors[6]->getLine()); } public function testBug6979(): void @@ -985,7 +1025,15 @@ public function testBug7918(): void { // crash $errors = $this->runAnalyse(__DIR__ . '/data/bug-7918.php'); - $this->assertNoErrors($errors); + $this->assertCount(4, $errors); + $this->assertSame('Value assigned to variable $arr2 is never read.', $errors[0]->getMessage()); + $this->assertSame(33, $errors[0]->getLine()); + $this->assertSame('Value assigned to variable $id is never read.', $errors[1]->getMessage()); + $this->assertSame(33, $errors[1]->getLine()); + $this->assertSame('Value assigned to variable $arr2 is never read.', $errors[2]->getMessage()); + $this->assertSame(91, $errors[2]->getLine()); + $this->assertSame('Value assigned to variable $id is never read.', $errors[3]->getMessage()); + $this->assertSame(91, $errors[3]->getLine()); } public function testArrayUnion(): void @@ -1013,7 +1061,9 @@ public function testBug8078(): void { // crash $errors = $this->runAnalyse(__DIR__ . '/data/bug-8078.php'); - $this->assertNoErrors($errors); + $this->assertCount(1, $errors); + $this->assertSame('Value assigned to variable $closure is never read.', $errors[0]->getMessage()); + $this->assertSame(9, $errors[0]->getLine()); } #[RequiresPhp('>= 8.1.0')] @@ -1100,7 +1150,13 @@ public function testBug12934(): void public function testPr2030(): void { $errors = $this->runAnalyse(__DIR__ . '/data/pr-2030.php'); - $this->assertNoErrors($errors); + $this->assertCount(3, $errors); + $this->assertSame('Value assigned to variable $index is never read.', $errors[0]->getMessage()); + $this->assertSame(24, $errors[0]->getLine()); + $this->assertSame('Value assigned to variable $noteTitle is never read.', $errors[1]->getMessage()); + $this->assertSame(25, $errors[1]->getLine()); + $this->assertSame('Value assigned to variable $noteSource is never read.', $errors[2]->getMessage()); + $this->assertSame(26, $errors[2]->getLine()); } #[RequiresPhp('>= 8.0.0')] @@ -1223,7 +1279,9 @@ public function testBug13492(): void { // crash $errors = $this->runAnalyse(__DIR__ . '/data/bug-13492.php'); - $this->assertNoErrors($errors); + $this->assertCount(1, $errors); + $this->assertSame('Value assigned to variable $customer is never read.', $errors[0]->getMessage()); + $this->assertSame(56, $errors[0]->getLine()); } #[RequiresPhp('>= 8.0.0')] @@ -1351,7 +1409,9 @@ public function testBug11026(): void { // crash $errors = $this->runAnalyse(__DIR__ . '/data/bug-11026.php'); - $this->assertNoErrors($errors); + $this->assertCount(1, $errors); + $this->assertSame('Value assigned to variable $a is never read.', $errors[0]->getMessage()); + $this->assertSame(6, $errors[0]->getLine()); } public function testBug10867(): void @@ -1405,7 +1465,11 @@ public function testBug11598(): void { // false negative $errors = $this->runAnalyse(__DIR__ . '/data/bug-11598.php'); - $this->assertNoErrors($errors); + $this->assertCount(2, $errors); + $this->assertSame('Value assigned to variable $foo is never read.', $errors[0]->getMessage()); + $this->assertSame(9, $errors[0]->getLine()); + $this->assertSame('Value assigned to variable $foo is never read.', $errors[1]->getMessage()); + $this->assertSame(14, $errors[1]->getLine()); } public function testBug11640(): void diff --git a/tests/PHPStan/Analyser/AnalyserTraitsIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserTraitsIntegrationTest.php index eb5fbc4d67a..93df30197be 100644 --- a/tests/PHPStan/Analyser/AnalyserTraitsIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserTraitsIntegrationTest.php @@ -41,7 +41,7 @@ public function testMethodDoesNotExist(): void __DIR__ . '/traits/Bar.php', __DIR__ . '/traits/FooTrait.php', ]); - $this->assertCount(1, $errors); + $this->assertCount(2, $errors); $error = $errors[0]; $this->assertSame('Call to an undefined method AnalyseTraits\Bar::doFoo().', $error->getMessage()); $this->assertSame( @@ -49,6 +49,14 @@ public function testMethodDoesNotExist(): void $error->getFile(), ); $this->assertSame(10, $error->getLine()); + + $error = $errors[1]; + $this->assertSame('Value assigned to variable $r is never read.', $error->getMessage()); + $this->assertSame( + sprintf('%s (in context of class AnalyseTraits\Bar)', $this->fileHelper->normalizePath(__DIR__ . '/traits/FooTrait.php')), + $error->getFile(), + ); + $this->assertSame(15, $error->getLine()); } public function testNestedTraits(): void @@ -58,7 +66,7 @@ public function testNestedTraits(): void __DIR__ . '/traits/NestedFooTrait.php', __DIR__ . '/traits/FooTrait.php', ]); - $this->assertCount(2, $errors); + $this->assertCount(3, $errors); $firstError = $errors[0]; $this->assertSame('Call to an undefined method AnalyseTraits\NestedBar::doFoo().', $firstError->getMessage()); $this->assertSame( @@ -67,7 +75,15 @@ public function testNestedTraits(): void ); $this->assertSame(10, $firstError->getLine()); - $secondError = $errors[1]; + $unusedError = $errors[1]; + $this->assertSame('Value assigned to variable $r is never read.', $unusedError->getMessage()); + $this->assertSame( + sprintf('%s (in context of class AnalyseTraits\NestedBar)', $this->fileHelper->normalizePath(__DIR__ . '/traits/FooTrait.php')), + $unusedError->getFile(), + ); + $this->assertSame(15, $unusedError->getLine()); + + $secondError = $errors[2]; $this->assertSame('Call to an undefined method AnalyseTraits\NestedBar::doNestedFoo().', $secondError->getMessage()); $this->assertSame( sprintf('%s (in context of class AnalyseTraits\NestedBar)', $this->fileHelper->normalizePath(__DIR__ . '/traits/NestedFooTrait.php')), diff --git a/tests/PHPStan/Analyser/NodeCallbackScopeResolverRuleTest.php b/tests/PHPStan/Analyser/NodeCallbackScopeResolverRuleTest.php index 2b6c7708fc5..770a98375d2 100644 --- a/tests/PHPStan/Analyser/NodeCallbackScopeResolverRuleTest.php +++ b/tests/PHPStan/Analyser/NodeCallbackScopeResolverRuleTest.php @@ -55,8 +55,8 @@ static function (Node $node, Scope $scope) { return []; } + $scope->getType($node->getArgs()[0]->value); // on purpose to hit the cache $arg0 = $scope->getType($node->getArgs()[0]->value); - $arg0 = $scope->getType($node->getArgs()[0]->value); // on purpose to hit the cache return [ RuleErrorBuilder::message($arg0->describe(VerbosityLevel::precise()))->identifier('fnsr.rule')->build(), diff --git a/tests/PHPStan/Analyser/TypeSpecifierTest.php b/tests/PHPStan/Analyser/TypeSpecifierTest.php index 846c9fe15af..ca57edd2108 100644 --- a/tests/PHPStan/Analyser/TypeSpecifierTest.php +++ b/tests/PHPStan/Analyser/TypeSpecifierTest.php @@ -1389,7 +1389,7 @@ private function toReadableResult(SpecifiedTypes $specifiedTypes): array { $typesDescription = []; - foreach ($specifiedTypes->getSureTypes() as $exprString => [$exprNode, $exprType]) { + foreach ($specifiedTypes->getSureTypes() as $exprString => [1 => $exprType]) { $typesDescription[$exprString][] = $exprType->describe(VerbosityLevel::precise()); } @@ -1405,7 +1405,7 @@ private function toReadableResult(SpecifiedTypes $specifiedTypes): array $typesDescription[$exprString][] = TypeCombinator::union(...$parts)->describe(VerbosityLevel::precise()); } - foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$exprNode, $exprType]) { + foreach ($specifiedTypes->getSureNotTypes() as $exprString => [1 => $exprType]) { $typesDescription[$exprString][] = '~' . $exprType->describe(VerbosityLevel::precise()); } diff --git a/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterIntegrationTest.php b/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterIntegrationTest.php index 107ff02a9d3..9f30064f48c 100644 --- a/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterIntegrationTest.php +++ b/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterIntegrationTest.php @@ -33,7 +33,7 @@ public function testErrorWithTrait(): void public function testGenerateBaselineAndRunAgainWithIt(): void { $baselineFile = __DIR__ . '/../../../../baseline.neon'; - $output = $this->runPhpStan(__DIR__ . '/data/', __DIR__ . '/empty.neon', 'json', $baselineFile); + $this->runPhpStan(__DIR__ . '/data/', __DIR__ . '/empty.neon', 'json', $baselineFile); $output = $this->runPhpStan(__DIR__ . '/data/', $baselineFile); @unlink($baselineFile); diff --git a/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php b/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php index c9463f9675a..66eef36fbde 100644 --- a/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php +++ b/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php @@ -378,7 +378,7 @@ public function testOutputOrdering(array $errors): void ], ], ], Neon::BLOCK)), - $f = trim($this->getOutputContent()), + trim($this->getOutputContent()), ); } diff --git a/tests/PHPStan/DependencyInjection/BleedingEdgeToggleTest.php b/tests/PHPStan/DependencyInjection/BleedingEdgeToggleTest.php index 81f9485fe46..03d6357703d 100644 --- a/tests/PHPStan/DependencyInjection/BleedingEdgeToggleTest.php +++ b/tests/PHPStan/DependencyInjection/BleedingEdgeToggleTest.php @@ -6,7 +6,6 @@ use PHPStan\ShouldNotHappenException; use PHPUnit\Framework\TestCase; use RuntimeException; -use Throwable; final class BleedingEdgeToggleTest extends TestCase { @@ -56,17 +55,15 @@ public function testRestoresPreviousValueWhenCallbackThrows(): void { BleedingEdgeToggle::setBleedingEdge(false); - $thrown = false; + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('boom'); try { BleedingEdgeToggle::withBleedingEdge(true, static function (): void { throw new RuntimeException('boom'); }); - } catch (Throwable $e) { - $thrown = $e instanceof RuntimeException && $e->getMessage() === 'boom'; + } finally { + $this->assertFalse(BleedingEdgeToggle::isBleedingEdge()); } - - $this->assertTrue($thrown); - $this->assertFalse(BleedingEdgeToggle::isBleedingEdge()); } public function testThrowsAndRestoresWhenCallbackYields(): void diff --git a/tests/PHPStan/Reflection/ReflectionProviderGoldenTest.php b/tests/PHPStan/Reflection/ReflectionProviderGoldenTest.php index c9f990d1cdf..32fd46d18ed 100644 --- a/tests/PHPStan/Reflection/ReflectionProviderGoldenTest.php +++ b/tests/PHPStan/Reflection/ReflectionProviderGoldenTest.php @@ -252,7 +252,7 @@ private static function generateClassDescription(string $className): string $keyword = 'class'; break; default: - $keyword = self::fail(); + self::fail(); } $verbosityLevel = VerbosityLevel::precise(); diff --git a/tests/PHPStan/Reflection/SignatureMap/SignatureMapParserTest.php b/tests/PHPStan/Reflection/SignatureMap/SignatureMapParserTest.php index 98b47e6cb09..9d9e41a0be1 100644 --- a/tests/PHPStan/Reflection/SignatureMap/SignatureMapParserTest.php +++ b/tests/PHPStan/Reflection/SignatureMap/SignatureMapParserTest.php @@ -529,7 +529,7 @@ public function testParseAll(int $phpVersionId): void } else { $reflectionFunction = new ReflectionFunction($reflector->reflectFunction($realFunctionName)); } - } catch (IdentifierNotFound | OutOfBoundsException $e) { + } catch (IdentifierNotFound | OutOfBoundsException) { // pass } diff --git a/tests/PHPStan/Rules/Constants/DynamicClassConstantFetchRuleTest.php b/tests/PHPStan/Rules/Constants/DynamicClassConstantFetchRuleTest.php index 83aac759b44..301185e8405 100644 --- a/tests/PHPStan/Rules/Constants/DynamicClassConstantFetchRuleTest.php +++ b/tests/PHPStan/Rules/Constants/DynamicClassConstantFetchRuleTest.php @@ -33,7 +33,6 @@ protected function getRule(): TRule public function testRule(): void { - $errors = []; if (PHP_VERSION_ID < 80300) { $errors = [ [ diff --git a/tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php b/tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php new file mode 100644 index 00000000000..63ac351cb9e --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php @@ -0,0 +1,238 @@ + + */ +class UnusedVariableRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new UnusedVariableRule(self::getContainer()->getByType(PhpVersion::class)); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/unused-variable.php'], [ + [ + 'Value assigned to variable $a is never read.', + 27, + ], + [ + 'Value assigned to variable $a is never read.', + 32, + ], + [ + 'Value assigned to variable $a is never read.', + 40, + ], + [ + 'Value assigned to variable $x is never read.', + 46, + ], + [ + 'Value assigned to variable $a is never read.', + 70, + ], + [ + 'Value assigned to variable $a is never read.', + 76, + ], + [ + 'Value assigned to variable $a is never read.', + 93, + ], + [ + 'Value assigned to variable $a is never read.', + 95, + ], + [ + 'Value assigned to variable $a is never read.', + 101, + ], + [ + 'Value assigned to variable $a is never read.', + 113, + ], + [ + 'Value assigned to variable $k is never read.', + 119, + ], + [ + 'Value assigned to variable $v is never read.', + 126, + ], + [ + 'Value assigned to variable $v is never read.', + 133, + ], + [ + 'Value assigned to variable $a is never read.', + 148, + ], + [ + 'Value assigned to variable $i is never read.', + 157, + ], + [ + 'Value assigned to variable $x is never read.', + 223, + ], + [ + 'Value assigned to variable $x is never read.', + 251, + ], + [ + 'Value assigned to variable $s is never read.', + 264, + ], + [ + 'Value assigned to variable $a is never read.', + 276, + ], + [ + 'Value assigned to variable $f is never read.', + 283, + ], + [ + 'Value assigned to variable $a is never read.', + 303, + ], + [ + 'Value assigned to variable $x is never read.', + 337, + ], + [ + 'Value assigned to variable $title is never read.', + 422, + ], + [ + 'Value assigned to variable $b is never read.', + 614, + ], + [ + 'Value assigned to variable $a is never read.', + 632, + ], + [ + 'Value assigned to variable $a is never read.', + 637, + ], + [ + 'Value assigned to variable $a is never read.', + 703, + ], + [ + 'Value assigned to variable $a is never read.', + 739, + ], + [ + 'Value assigned to variable $a is never read.', + 744, + ], + [ + 'Value assigned to variable $tags is never read.', + 840, + ], + ]); + } + + #[RequiresPhp('>= 8.0.0')] + public function testPhp8(): void + { + $this->analyse([__DIR__ . '/data/unused-variable-php8.php'], [ + [ + 'Value assigned to variable $e is never read.', + 23, + ], + [ + 'Value assigned to variable $nightsFrom is never read.', + 98, + ], + ]); + } + + public function testBug12789(): void + { + $this->analyse([__DIR__ . '/data/bug-12789.php'], [ + [ + 'Value assigned to variable $RetVal is never read.', + 12, + ], + ]); + } + + public function testBug13472(): void + { + $this->analyse([__DIR__ . '/data/bug-13472.php'], [ + [ + 'Value assigned to variable $v is never read.', + 14, + ], + [ + 'Value assigned to variable $item is never read.', + 41, + ], + ]); + } + + public function testBug14258(): void + { + $this->analyse([__DIR__ . '/data/bug-14258.php'], [ + [ + 'Value assigned to variable $cutsomerId is never read.', + 15, + ], + ]); + } + + public function testBug12012(): void + { + $this->analyse([__DIR__ . '/data/bug-12012.php'], [ + [ + 'Value assigned to variable $s1 is never read.', + 10, + ], + [ + 'Value assigned to variable $s1 is never read.', + 12, + ], + ]); + } + + public function testBug11483(): void + { + $this->analyse([__DIR__ . '/data/bug-11483.php'], [ + [ + 'Value assigned to variable $hello is never read.', + 9, + ], + ]); + } + + public function testBug10202(): void + { + $this->analyse([__DIR__ . '/data/bug-10202.php'], [ + [ + 'Value assigned to variable $x is never read.', + 9, + ], + [ + 'Value assigned to variable $x is never read.', + 12, + ], + [ + 'Value assigned to variable $x is never read.', + 14, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-10202.php b/tests/PHPStan/Rules/DeadCode/data/bug-10202.php new file mode 100644 index 00000000000..6223e5ae0b7 --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/bug-10202.php @@ -0,0 +1,17 @@ +text'; + + $s1 = 'something else'; + } +} diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-12789.php b/tests/PHPStan/Rules/DeadCode/data/bug-12789.php new file mode 100644 index 00000000000..6135363fe45 --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/bug-12789.php @@ -0,0 +1,16 @@ + 4) { + // Typo in next line - should be $retVal rather than $RetVal + $RetVal = $str2 . $str1; + } + return $retVal; + } +} diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-13472.php b/tests/PHPStan/Rules/DeadCode/data/bug-13472.php new file mode 100644 index 00000000000..63e2430a23a --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/bug-13472.php @@ -0,0 +1,47 @@ +dummyConsume($v); + $v = 10; + + return $v; + } + + public function testOverwrittenButUsed2(): int + { + $v = 1; + $v = $v + 1; + + return $v; + } + + /** @param list $possiblyEmptyList */ + public function testOverwrittenButUsed3(array $possiblyEmptyList): int + { + $v = 1; + foreach ($possiblyEmptyList as $item) { + $v = 2; + } + + return $v; + } +} diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-14258.php b/tests/PHPStan/Rules/DeadCode/data/bug-14258.php new file mode 100644 index 00000000000..70631728f5a --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/bug-14258.php @@ -0,0 +1,19 @@ += 8.0 + +namespace UnusedVariableRulePhp8; + +/** @param mixed $v */ +function sink($v): void +{ +} + +/** + * @return mixed + * @phpstan-impure + */ +function source() +{ + return rand(); +} + +function catchUnused(): void +{ + try { + sink(1); + } catch (\Exception $e) { // unused $e + } +} + +function catchUsed(): void +{ + try { + sink(1); + } catch (\Exception $e) { + sink($e); + } +} + +function matchRead(int $i): int +{ + $a = source(); + return match ($i) { + 1 => $a, + default => 0, + }; +} + +function nullsafeRead(?\stdClass $o): void +{ + $x = $o?->foo; + sink($x); +} + +function namedArgs(): void +{ + $v = 1; + sink(v: $v); +} + +class NullsafeReads +{ + + public function __construct(private ?\DateTimeImmutable $maxDate, private ?NullsafeReads $inner, private ?\ArrayObject $bag) + { + } + + public function argumentOfNullsafeCall(): ?\DateTimeImmutable + { + $nightsFrom = 1; + + return $this->maxDate?->modify(sprintf('-%d days', $nightsFrom)); + } + + public function argumentOfNestedNullsafeCall(): void + { + $weekDay = 3; + sink($this->inner?->argumentOfNullsafeCallWith($weekDay)); + } + + public function argumentOfNullsafeCallWith(int $weekDay): int + { + return $weekDay; + } + + public function nullsafeInCondition(): void + { + $time = new \DateTimeImmutable(); + if ($this->maxDate?->getTimestamp() === $time->getTimestamp()) { + sink(1); + } + } + + public function nullsafeOffsetOnPropertyFetch(): void + { + $key = 'k'; + sink($this->bag?->offsetGet($key)); + } + + public function unreadArgumentVariable(): void + { + $nightsFrom = 1; // unused $nightsFrom + $nightsFrom = 2; + sink($this->maxDate?->modify(sprintf('-%d days', $nightsFrom))); + } + +} diff --git a/tests/PHPStan/Rules/DeadCode/data/unused-variable.php b/tests/PHPStan/Rules/DeadCode/data/unused-variable.php new file mode 100644 index 00000000000..9f3a3226f6c --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/unused-variable.php @@ -0,0 +1,858 @@ + $v) { // unused $k + sink($v); + } +} + +function foreachValueUnused(array $arr): void +{ + foreach ($arr as $k => $v) { // unused $v + sink($k); + } +} + +function foreachValueOnlyUnused(array $arr): void +{ + foreach ($arr as $v) { // unused $v + sink(1); + } +} + +function foreachListUsed(array $arr): void +{ + foreach ($arr as [$a, $b]) { + sink($a); + sink($b); + } +} + +function foreachListPartiallyUsed(array $arr): void +{ + foreach ($arr as [$a, $b]) { // unused $a + sink($b); + } +} + +function incrementLast(): void +{ + $i = 0; + sink($i); + $i++; // unused $i +} + +function forLoop(): void +{ + for ($i = 0; $i < 3; $i++) { + sink($i); + } +} + +function forLoopCounterOnlyInCondition(): void +{ + for ($i = 0; $i < 3; $i++) { + sink(1); + } +} + +function whileAssignInCondition(): void +{ + while (($line = source()) !== false) { + sink($line); + } +} + +function doWhile(): void +{ + $i = 5; + do { + sink(1); + } while (--$i > 0); + sink($i); +} + +function doWhileNoReadAfter(): void +{ + $i = 5; + do { + sink(1); + } while (--$i > 0); +} + +function backEdgeRead(): void +{ + $x = 0; + while (cond()) { + sink($x); + $x = source(); + } +} + +function backEdgeReadWithContinue(): void +{ + $x = 0; + while (cond()) { + if (cond()) { + $x = 1; + continue; + } + sink($x); + $x = 2; + } +} + +function loopWriteNeverRead(): void +{ + while (cond()) { + $x = source(); // unused $x + } +} + +function loopWriteReadNextIterationOnly(): void +{ + while (cond()) { + if (cond()) { + $x = 1; + } + if (isset($x)) { + sink($x); + } + } +} + +function arrayBuildReturned(): array +{ + $x = []; + $x[] = 1; + $x['k'] = 2; + return $x; +} + +function arrayBuildUnused(): void +{ + $x = []; + $x[] = 1; + $x['k'] = 2; // unused $x +} + +function stringAppendReturned(): string +{ + $s = 'a'; + $s .= 'b'; + return $s; +} + +function stringAppendUnused(): void +{ + $s = 'a'; + $s .= 'b'; // unused $s +} + +function unsetAfterWrite(): void +{ + $a = 1; // known false negative: unset() walks the variable as a read + unset($a); +} + +function closureBodyDeadWrite(): void +{ + $f = function (): void { + $a = 1; // unused $a + }; + $f(); +} + +function closureAssignedUnused(): void +{ + $f = function (): void { // unused $f + }; +} + +function switchBranchesRead(): void +{ + switch (rand(0, 2)) { + case 0: + $a = true; + break; + default: + $a = false; + } + sink($a); +} + +function switchBranchDeadWrite(): void +{ + switch (rand(0, 2)) { + case 0: + $a = 1; // unused $a + break; + default: + sink(1); + } +} + +function tryFinallyRead(): void +{ + $var = ''; + try { + if (cond()) { + throw new \Exception(); + } + $var = 'hello'; + } finally { + sink($var); + } +} + +function tryCatchRead(): void +{ + try { + $x = source(); + } catch (\Exception $e) { + sink($e); + $x = null; + } + sink($x); +} + +function tryDeadWrite(): void +{ + try { + $x = source(); // unused $x + } catch (\Exception $e) { + sink($e); + } +} + +function issetRead(): void +{ + if (cond()) { + $j = 'hello'; + } + if (isset($j)) { + sink($j); + } +} + +function emptyRead(): void +{ + $j = source(); + if (empty($j)) { + sink(1); + } +} + +function coalesceRead(): void +{ + $j = source(); + sink($j ?? 1); +} + +function coalesceAssign(?int $b, int $c): void +{ + $b ??= $c; + sink($b); +} + +function compactRead(): array +{ + $a = 1; + $b = 2; + return compact('a', 'b'); +} + +function compactDynamic(string $name): array +{ + $a = 1; + return compact($name); +} + +function variableVariableRead(string $name): void +{ + $a = 1; + sink($$name); +} + +function variableVariableConstantRead(): void +{ + $a = 1; + $name = 'a'; + sink(${$name}); +} + +function extractAfterWrite(array $arr): void +{ + $a = 1; + extract($arr); + sink($a); +} + +function getDefinedVarsRead(): array +{ + $a = 1; + return get_defined_vars(); +} + +function includeReadsEverything(): void +{ + $title = 'x'; + include 'template.php'; +} + +function includeThenOverwrite(): void +{ + $title = 'x'; + include 'template.php'; + $title = 'y'; // unused $title +} + +function evalReadsEverything(): void +{ + $title = 'x'; + eval('echo $title;'); +} + +function gotoOpaque(): void +{ + $a = 1; + goto end; + end: + sink(1); +} + +function staticVar(): void +{ + static $token; + $token = source(); +} + +function staticVarRead(): int +{ + static $token; + if (!$token) { + $token = rand(1, 10); + } + return $token; +} + +function globalVar(): void +{ + global $a; + $a = 'hello'; +} + +function byRefParam(array &$p): void +{ + $p = [0]; +} + +function pregMatchByRef(): array +{ + preg_match('/x/', 'x', $m); + return $m; +} + +function pregMatchByRefUnused(): void +{ + preg_match('/x/', 'x', $m); +} + +function sortByRef(array $arr): void +{ + sort($arr); +} + +function arrayPushByRef(): array +{ + $arr = []; + array_push($arr, 1); + return $arr; +} + +function foreachByRef(array $a): array +{ + foreach ($a as &$v) { + $v = 1; + } + return $a; +} + +function assignRef(): void +{ + $b = 1; + $a = &$b; + $a = 2; +} + +function closureUseByValue(): void +{ + $i = 0; + $f = function () use ($i): int { + return $i + 1; + }; + $f(); +} + +function closureUseByRef(): void +{ + $i = 0; + $f = function () use (&$i): void { + $i = 1; + }; + $f(); + sink($i); +} + +function closureUseByRefNoReadAfter(): void +{ + $i = 0; + $f = function () use (&$i): void { + $i = 1; + }; + $f(); +} + +function recursiveClosure(): void +{ + $f = function () use (&$f): void { + $f(); + }; + $f(); +} + +function arrowFunctionCapture(): void +{ + $x = 1; + $f = fn (): int => $x + 1; + $f(); +} + +function dynamicMethodName(object $o): void +{ + $name = 'foo'; + $o->$name(); +} + +function dynamicClassConst(): void +{ + $class = \stdClass::class; + sink($class::FOO); +} + +function dynamicNew(): void +{ + $class = \stdClass::class; + new $class(); +} + +function dynamicStaticProperty(): void +{ + $class = \stdClass::class; + $class::$prop = 1; +} + +function dimWriteOnObject(\ArrayAccess $o): void +{ + $o['k'] = 1; +} + +function dimWriteOnObjectFromNew(): void +{ + $o = new \ArrayObject(); + $o['k'] = 1; +} + +/** @param mixed $m */ +function dimWriteOnMixed($m): void +{ + $m['k'] = 1; +} + +function superglobalDimWrite(): void +{ + $_SESSION['k'] = 1; +} + +function propertyWriteThroughLocal(): void +{ + $o = new \stdClass(); + $o->p = 1; +} + +/** @param mixed $y */ +function varAnnotation($y): void +{ + /** @var \stdClass $y */ + $y->m(); +} + +function varAnnotationAfterWrite(): void +{ + /** @var \stdClass $y */ + $y = source(); + $y->m(); +} + +function chainedAssign(): void +{ + $a = $b = 1; // unused $b + sink($a); +} + +function underscorePrefix(): void +{ + $_ = source(); + $_unused = source(); +} + +function parameterOverwritten($a): int +{ + $a = 1; + return $a; +} + +function parameterOverwrittenUnread($a): void +{ + $a = 1; // unused $a +} + +function nestedFunctionScopes(): void +{ + $a = 1; // unused $a + $f = function (): void { + $a = 2; + sink($a); + }; + $f(); +} + +function ternaryRead(): int +{ + $a = source(); + return cond() ? $a : 0; +} + +function instanceofRead(): bool +{ + $a = source(); + return $a instanceof \stdClass; +} + +function usedAsArrayKey(): array +{ + $k = 'a'; + return [$k => 1]; +} + +function usedInStringInterpolation(): string +{ + $name = 'x'; + return "hello $name"; +} + +function yieldRead(): \Generator +{ + $a = 1; + yield $a; +} + +function throwRead(): void +{ + $e = new \Exception(); + throw $e; +} + +function echoRead(): void +{ + $a = 1; + echo $a; +} + +function castRead(): int +{ + $a = '1'; + return (int) $a; +} + +function cloneRead(): object +{ + $o = new \stdClass(); + return clone $o; +} + +function selfReferentialChain(): void +{ + // the Psalm layer will also report the first write; phase 1 sees it read by the second + $a = 5; + $a = $a + 1; // unused $a +} + +function articleExample(): void +{ + // Psalm reports every write of $b; phase 1 sees each read by the self-chain + $b = $a = 0; + while (cond()) { + if (cond() && cond()) { + $a = 5; + break; + } + if (cond()) { + continue; + } + $a = $a + 1; + $b = $b + 1; + } + sink($a); +} + +class Foo +{ + + /** @var mixed */ + private $prop; + + public function __construct() + { + $x = 1; + $this->prop = $x; + $this->init(); + } + + private function init(): void + { + $a = 1; // unused $a + } + + public function overwritten(): void + { + $a = 1; // unused $a + $a = 2; + sink($a); + } + + public static function staticMethod(): void + { + $x = 1; + self::helper($x); + } + + /** @param mixed $x */ + private static function helper($x): void + { + } + + public function thisPropertyWrite(): void + { + $this->prop = 1; + } + +} + +/** + * @param list $tokens + * @return list + */ +function nestedWritesReadOnNextIteration(array $tokens): array +{ + $comment = null; + $expected = null; + $open = 0; + $ids = []; + foreach ($tokens as [$type, $content]) { + if ($type === 1) { + if ($open > 0) { + $comment .= $content; + } + $open++; + $expected = null; + continue; + } + if ($type === 2) { + $open--; + if ($open === 0) { + $key = array_key_last($ids); + if ($key !== null) { + $ids[$key]['comment'] = $comment; + $comment = null; + } + $expected = [3, 4]; + } else { + $comment .= $content; + } + continue; + } + if ($open > 0) { + $comment .= $content; + continue; + } + if ($expected !== null && !in_array($type, $expected, true)) { + throw new \Exception(); + } + $ids[] = ['comment' => null]; + $expected = [1]; + } + + return $ids; +} + +/** + * @param list<\stdClass> $xs + */ +function branchDeadInFirstIteration(array $xs): array +{ + $winners = []; + $winning = null; + foreach ($xs as $x) { + if ($winning === null) { + $winners[] = $x; + $winning = $x; + } else { + $c = $winning == $x; + if ($c) { + $winners = [$x]; + $winning = $x; + } + } + } + + return $winners; +} + +function recursiveClosureReportsOnce(): void +{ + $check = function (int $n) use (&$check): void { + $tags = []; // unused $tags + if ($n > 0) { + $tags = [$n]; + sink($tags); + } + $check($n - 1); + }; + $check(3); +} + +function closureBodyWritesStayInClosure(): void +{ + $outer = 1; + $f = function () use ($outer): int { + $inner = 2; + return $outer + $inner; + }; + sink($f()); +} diff --git a/tests/PHPStan/Rules/Debug/DebugScopeRuleTest.php b/tests/PHPStan/Rules/Debug/DebugScopeRuleTest.php index f1bcd81285e..802950d1b06 100644 --- a/tests/PHPStan/Rules/Debug/DebugScopeRuleTest.php +++ b/tests/PHPStan/Rules/Debug/DebugScopeRuleTest.php @@ -41,6 +41,7 @@ public function testRuleInPhpStanNamespace(): void '$b (Yes): int', '$debug (Yes): bool', '$c (Maybe): 1', + '__phpstanVariableWritten($c, 1) (Maybe): mixed', 'native $a (Yes): int', 'native $b (Yes): int', 'native $debug (Yes): bool', @@ -59,6 +60,7 @@ public function testPr4663(): void [ implode("\n", [ "\$result (Yes): 'no matches!'", + '__phpstanVariableWritten($result, 1) (Yes): mixed', "native \$result (Yes): 'no matches!'", ]), 11, diff --git a/tests/e2e/anon-class/Granularity.php b/tests/e2e/anon-class/Granularity.php index 9762130417d..7e95c30f67f 100644 --- a/tests/e2e/anon-class/Granularity.php +++ b/tests/e2e/anon-class/Granularity.php @@ -10,7 +10,7 @@ protected static function provideInstances(): array { $myclass = new class() extends Granularity { }; - return []; + return [$myclass]; } } diff --git a/turbo-ext/src/ScopeOps.cpp b/turbo-ext/src/ScopeOps.cpp index a7f19c342ae..2c87b37bcdd 100644 --- a/turbo-ext/src/ScopeOps.cpp +++ b/turbo-ext/src/ScopeOps.cpp @@ -748,6 +748,9 @@ class ScopeOps if (UNEXPECTED(!pt_check_holder(mergedHolder.raw()))) { return zv::Val(); } + if (instanceof_function(holderExpr(mergedHolder)->ce, virtualNodeCe)) { + continue; + } for (auto guardEntry : zv::TableRef(typeGuards.table())) { zv::Val noHolder = createNoErrorHolder(zv::ObjRef(mergedHolder.asObject()).propAt(PT_ETH_PROP_EXPR).raw()); @@ -1619,6 +1622,7 @@ class ScopeOps { "__phpstanPossiblyImpure(", sizeof("__phpstanPossiblyImpure(") - 1 }, { "__phpstanPropertyInitialization(", sizeof("__phpstanPropertyInitialization(") - 1 }, { "__phpstanRemembered(", sizeof("__phpstanRemembered(") - 1 }, + { "__phpstanVariableWritten(", sizeof("__phpstanVariableWritten(") - 1 }, }; const char *pos = ZSTR_VAL(key);