diff --git a/src/Analyser/DirectInternalScopeFactory.php b/src/Analyser/DirectInternalScopeFactory.php index 66c81a44cf..b97755def9 100644 --- a/src/Analyser/DirectInternalScopeFactory.php +++ b/src/Analyser/DirectInternalScopeFactory.php @@ -68,6 +68,7 @@ public function create( bool $nativeTypesPromoted = false, ?TemplateArgumentFrame $templateArgumentFrame = null, ?TemplateArgumentConstraints $templateArgumentConstraints = null, + array $resultProvenance = [], ): MutatingScope { $className = MutatingScope::class; @@ -109,6 +110,7 @@ public function create( $nativeTypesPromoted, $templateArgumentFrame, $templateArgumentConstraints, + $resultProvenance, ); } diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 7e7759f1c1..da9e1f3104 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -113,6 +113,15 @@ final class AssignHandler implements ExprHandler private const DERIVED_CONDITIONAL_EXPRESSIONS_LIMIT = 16; + /** + * Pure information-carrying functions whose call result keeps provenance + * when assigned to a variable: comparing the variable later narrows + * through the call's own comparison machinery, exactly like comparing the + * call directly (`$type = gettype($x); if ($type === 'string')` narrows + * $x the way `if (gettype($x) === 'string')` does). + */ + private const RESULT_PROVENANCE_FUNCTIONS = ['count', 'sizeof', 'gettype', 'get_class', 'get_debug_type']; + public function __construct( private TemplateArgumentObserver $templateArgumentObserver, private VarAnnotationProcessor $varAnnotationProcessor, @@ -1280,6 +1289,11 @@ public function applyWrite( $scope = $scope->addConditionalExpressions((string) $exprString, $holders); } + $provenanceCall = $this->resolveResultProvenanceCall($assignedExpr, $var->name); + if ($provenanceCall !== null) { + $scope = $scope->recordResultProvenance('$' . $var->name, $provenanceCall); + } + if ($assignedExpr instanceof Expr\Array_) { $scope = $this->processArrayByRefItems($nodeScopeResolver, $scope, $storage, $var->name, $assignedExpr, new Variable($var->name)); } @@ -1901,6 +1915,46 @@ private function unwrapAssign(Expr $expr): Expr return $expr; } + /** + * The assigned expression, when it is a whitelisted pure call over a plain + * variable whose result the target variable now provably holds - the shape + * whose provenance the scope records. The single-variable-argument + * requirement keeps invalidation exact: a write to the argument (or the + * target) drops the record by key containment alone. + */ + private function resolveResultProvenanceCall(Expr $assignedExpr, string $targetVariableName): ?FuncCall + { + if ( + !$assignedExpr instanceof FuncCall + || !$assignedExpr->name instanceof Name + || $assignedExpr->isFirstClassCallable() + || !in_array($assignedExpr->name->toLowerString(), self::RESULT_PROVENANCE_FUNCTIONS, true) + ) { + return null; + } + + $args = $assignedExpr->getArgs(); + if (count($args) !== 1 || $args[0]->unpack || $args[0]->name !== null) { + return null; + } + + $argValue = $args[0]->value; + if ( + !$argValue instanceof Variable + || !is_string($argValue->name) + // the call read the target's pre-assignment value - after the + // assignment the record would describe the variable through itself + || $argValue->name === $targetVariableName + // closure binds rebind $this without touching provenance + || $argValue->name === 'this' + || in_array($argValue->name, Scope::SUPERGLOBAL_VARIABLES, true) + ) { + return null; + } + + return $assignedExpr; + } + /** * @param array $conditionalExpressions * @param ImpurePoint[] $rhsImpurePoints diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 270ea9897b..c072cc3a89 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -46,6 +46,7 @@ use PHPStan\Type\UnionType; use function count; use function in_array; +use function is_string; /** * New-world narrowing for `===` (and, via a negated context, `!==`): composed @@ -239,6 +240,20 @@ private function specifyAgainstScalarLiteral( return null; } + // a provenance-recorded subject variable narrows through its defining + // call's families, exactly like the direct call comparison - the + // variable itself still pins to the constant alongside + $provenanceTypes = $this->specifyThroughResultProvenance($subject, $constantExpr, $constantType, $context, $evaluationScope); + if ($provenanceTypes !== null) { + return $provenanceTypes->unionWith($this->defaultNarrowingHelper->createSubjectTypes( + $evaluationScope, + $subject, + $subjectResult, + $constantType, + $context, + )); + } + // $a::class === Foo::class narrows $a to a final Foo when true; // other contexts and plain-string sides only pin the fetch if ( @@ -816,6 +831,40 @@ private function specifyEqualAgainstConstantSide( return false; } + /** + * When the subject variable is provenance-recorded as holding the result + * of a pure call (`$v = count($x)`), the comparison against the constant + * also runs the call's own family narrowing - `$v == 3` narrows $x like + * `count($x) == 3` would. The recorded call comes from an earlier + * statement and has no stored results in this walk, so the families read + * the argument's current type from the evaluation scope's tracked state. + */ + private function specifyThroughResultProvenance( + Expr $subject, + Expr $constantExpr, + Type $constantType, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + ): ?SpecifiedTypes + { + $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; + if (!$unwrappedSubject instanceof Expr\Variable || !is_string($unwrappedSubject->name)) { + return null; + } + + $call = $evaluationScope->getResultProvenanceCall('$' . $unwrappedSubject->name); + if ($call === null) { + return null; + } + + $familyTypes = $this->specifyFuncCallFamilies($call, null, $call, $constantExpr, $constantType, $context, $evaluationScope, null, true); + if (!$familyTypes instanceof SpecifiedTypes) { + return null; + } + + return $familyTypes; + } + /** * The function-family compositions, shared by the literal and the * TYPE-based constant sides: a family answer, null to fall back to the @@ -826,13 +875,14 @@ private function specifyEqualAgainstConstantSide( */ private function specifyFuncCallFamilies( Expr $subject, - ExpressionResult $subjectResult, + ?ExpressionResult $subjectResult, Expr\FuncCall $call, Expr $constantExpr, Type $constantType, TypeSpecifierContext $context, MutatingScope $evaluationScope, ?ExpressionResult $argResult, + bool $argTypesFromScopeState = false, ): SpecifiedTypes|false|null { if (!($call->name instanceof Name) || $call->isFirstClassCallable() || !isset($call->getArgs()[0])) { @@ -845,6 +895,10 @@ private function specifyFuncCallFamilies( $call->name->toLowerString() === 'preg_match' ) { if ($context->true() && (new ConstantIntegerType(1))->isSuperTypeOf($constantType)->yes()) { + if ($subjectResult === null) { + return null; + } + return $subjectResult->getSpecifiedTypesForScope($evaluationScope, $context); } @@ -859,10 +913,11 @@ private function specifyFuncCallFamilies( $constantStrings = $constantType->getConstantStrings(); if (count($constantStrings) === 1 && $constantStrings[0]->getValue() === '') { $argExpr = $call->getArgs()[0]->value; - if ($argResult === null) { + $argType = $this->resolveFamilyArgType($call, $argResult, $argTypesFromScopeState, $evaluationScope); + if ($argType === null) { return null; } - if ($argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted)->isString()->yes()) { + if ($argType->isString()->yes()) { return $this->defaultNarrowingHelper->createForSubject( $argExpr, new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]), @@ -882,10 +937,10 @@ private function specifyFuncCallFamilies( $constantStrings = $constantType->getConstantStrings(); if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { $argExpr = $call->getArgs()[0]->value; - if ($argResult === null) { + $argType = $this->resolveFamilyArgType($call, $argResult, $argTypesFromScopeState, $evaluationScope); + if ($argType === null) { return null; } - $argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); $objectType = new ObjectType($constantStrings[0]->getValue()); $classStringType = new GenericClassStringType($objectType); @@ -916,10 +971,10 @@ private function specifyFuncCallFamilies( ) { if ($context->truthy() && $constantType->isNonEmptyString()->yes()) { $argExpr = $call->getArgs()[0]->value; - if ($argResult === null) { + $argType = $this->resolveFamilyArgType($call, $argResult, $argTypesFromScopeState, $evaluationScope); + if ($argType === null) { return null; } - $argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); if ($argType->isString()->yes()) { $types = new SpecifiedTypes(); @@ -961,10 +1016,10 @@ private function specifyFuncCallFamilies( return $this->defaultNarrowingHelper->createForSubject($argExpr, new NeverType(), $context, $evaluationScope); } - if ($argResult === null) { + $argType = $this->resolveFamilyArgType($call, $argResult, $argTypesFromScopeState, $evaluationScope); + if ($argType === null) { return null; } - $argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); if ((new ConstantIntegerType(0))->isSuperTypeOf($constantType)->yes()) { $newArgType = $context->truthy() && !$argType->isArray()->yes() @@ -1022,10 +1077,11 @@ private function specifyFuncCallFamilies( } if ($context->truthy() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($constantType)->yes()) { - if ($argResult === null) { + $argType = $this->resolveFamilyArgType($call, $argResult, $argTypesFromScopeState, $evaluationScope); + if ($argType === null) { return null; } - if ($argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted)->isString()->yes()) { + if ($argType->isString()->yes()) { $accessory = IntegerRangeType::fromInterval(2, null)->isSuperTypeOf($constantType)->yes() ? new AccessoryNonFalsyStringType() : new AccessoryNonEmptyStringType(); @@ -1102,6 +1158,25 @@ private function specifyFuncCallFamilies( return false; } + /** + * The first argument's current type for the family compositions: from the + * captured operand result or - for a provenance-recorded call from an + * earlier statement, which has no captured results in this comparison - + * from the evaluation scope's tracked state. Null means unknown and the + * family falls back to the old-world path. + */ + private function resolveFamilyArgType(Expr\FuncCall $call, ?ExpressionResult $argResult, bool $argTypesFromScopeState, MutatingScope $evaluationScope): ?Type + { + if ($argTypesFromScopeState) { + return $evaluationScope->getStateType($call->getArgs()[0]->value); + } + if ($argResult === null) { + return null; + } + + return $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + } + /** * The first argument's stored result of a (possibly remembered) call * operand, captured by the seams at create time - the composed function diff --git a/src/Analyser/InternalScopeFactory.php b/src/Analyser/InternalScopeFactory.php index ee2c58961c..553b4fdad3 100644 --- a/src/Analyser/InternalScopeFactory.php +++ b/src/Analyser/InternalScopeFactory.php @@ -21,6 +21,7 @@ interface InternalScopeFactory * @param array $currentlyAssignedExpressions * @param array $currentlyAllowedUndefinedExpressions * @param list $inFunctionCallsStack + * @param array $resultProvenance */ public function create( ScopeContext $context, @@ -41,6 +42,7 @@ public function create( bool $nativeTypesPromoted = false, ?TemplateArgumentFrame $templateArgumentFrame = null, ?TemplateArgumentConstraints $templateArgumentConstraints = null, + array $resultProvenance = [], ): MutatingScope; public function toNodeCallbackScopeFactory(): self; diff --git a/src/Analyser/LazyInternalScopeFactory.php b/src/Analyser/LazyInternalScopeFactory.php index aa627a186f..bac3b28c49 100644 --- a/src/Analyser/LazyInternalScopeFactory.php +++ b/src/Analyser/LazyInternalScopeFactory.php @@ -89,6 +89,7 @@ public function create( bool $nativeTypesPromoted = false, ?TemplateArgumentFrame $templateArgumentFrame = null, ?TemplateArgumentConstraints $templateArgumentConstraints = null, + array $resultProvenance = [], ): MutatingScope { $className = MutatingScope::class; @@ -142,6 +143,7 @@ public function create( $nativeTypesPromoted, $templateArgumentFrame, $templateArgumentConstraints, + $resultProvenance, ); } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 483d58981d..6abbe62ee7 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -137,6 +137,7 @@ use function preg_match; use function spl_object_id; use function sprintf; +use function str_contains; use function str_starts_with; use function strlen; use function strtolower; @@ -180,6 +181,7 @@ class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter * @param array $nativeExpressionTypes * @param list $inFunctionCallsStack * @param ExtensionsCollection $expressionTypeResolverExtensions + * @param array $resultProvenance */ public function __construct( private Container $container, @@ -215,6 +217,7 @@ public function __construct( public bool $nativeTypesPromoted = false, protected ?TemplateArgumentFrame $templateArgumentFrame = null, protected ?TemplateArgumentConstraints $templateArgumentConstraints = null, + protected array $resultProvenance = [], ) { if ($namespace === '') { @@ -249,6 +252,7 @@ public function toNodeCallbackScope(): self $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); if ($nodeCallbackScope instanceof NodeCallbackScope) { $nodeCallbackScope->seedWalkScope($this); @@ -405,6 +409,7 @@ public function rememberConstructorScope(): self $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); } @@ -592,6 +597,7 @@ public function afterClearstatcacheCall(): self $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); } @@ -692,6 +698,7 @@ public function afterOpenSslCall(string $openSslFunctionName): self $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); } @@ -734,6 +741,7 @@ public function invalidateVolatileExpressions(): self $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); } @@ -771,6 +779,7 @@ public function invalidateExistenceCheckExpressions(array $functionNames, ?strin $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); } @@ -1045,6 +1054,7 @@ public function withAnonymousFunctionReflection(ClosureType $anonymousFunctionRe $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); } @@ -1141,6 +1151,7 @@ public function duplicateWith( $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); } @@ -1739,6 +1750,7 @@ private function promoteNativeTypes(): self true, templateArgumentFrame: $this->templateArgumentFrame, templateArgumentConstraints: $this->templateArgumentConstraints, + resultProvenance: $this->resultProvenance, ); } @@ -1854,6 +1866,7 @@ public function pushInFunctionCall($reflection, ?ParameterReflection $parameter, $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); if ($rememberTypes) { @@ -1887,6 +1900,7 @@ public function popInFunctionCall(): self $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); $parentScope->resolvedTypes = $this->resolvedTypes; @@ -2412,6 +2426,7 @@ public function enterClosureBind(?Type $thisType, ?Type $nativeThisType, array $ $this->anonymousFunctionReflection, templateArgumentFrame: $this->templateArgumentFrame, templateArgumentConstraints: $this->templateArgumentConstraints, + resultProvenance: $this->resultProvenance, ); } @@ -2443,6 +2458,7 @@ public function restoreOriginalScopeAfterClosureBind(self $originalScope): self $this->anonymousFunctionReflection, templateArgumentFrame: $this->templateArgumentFrame, templateArgumentConstraints: $this->templateArgumentConstraints, + resultProvenance: $this->resultProvenance, ); } @@ -2491,6 +2507,7 @@ public function restoreThis(self $restoreThisScope): self $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); } @@ -2514,6 +2531,7 @@ public function enterClosureCall(Type $thisType, Type $nativeThisType): self $this->anonymousFunctionReflection, templateArgumentFrame: $this->templateArgumentFrame, templateArgumentConstraints: $this->templateArgumentConstraints, + resultProvenance: $this->resultProvenance, ); } @@ -2547,6 +2565,7 @@ public function withClosureBindScopeClasses(array $scopeClasses): self $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); } @@ -3120,6 +3139,7 @@ public function enterExpressionAssign(Expr $expr, bool $isPlainWrite = true): se $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); $scope->resolvedTypes = $this->resolvedTypes; @@ -3151,6 +3171,7 @@ public function exitExpressionAssign(Expr $expr): self $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); $scope->resolvedTypes = $this->resolvedTypes; @@ -3212,6 +3233,7 @@ public function setAllowedUndefinedExpression(Expr $expr): self $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); $scope->resolvedTypes = $this->resolvedTypes; @@ -3243,6 +3265,7 @@ public function unsetAllowedUndefinedExpression(Expr $expr): self $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); $scope->resolvedTypes = $this->resolvedTypes; @@ -3654,6 +3677,7 @@ private function openSpecificationScope(): self $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); } @@ -3812,6 +3836,7 @@ public function invalidateExpression(Expr $expressionToInvalidate, bool $require { $exprStringToInvalidate = $this->getNodeKey($expressionToInvalidate); + $filteredResultProvenance = self::filterResultProvenance($this->resultProvenance, $exprStringToInvalidate); $result = ScopeOps::invalidateExpressionEntries( $this, $this->exprPrinter, @@ -3825,11 +3850,18 @@ public function invalidateExpression(Expr $expressionToInvalidate, bool $require $keepPropertyFetches, ); if ($result === null) { - return $this; + if (count($filteredResultProvenance) === count($this->resultProvenance)) { + return $this; + } + + $scope = $this->openSpecificationScope(); + $scope->resultProvenance = $filteredResultProvenance; + + return $scope; } - /** @var static */ - return ScopeOps::scopeWith( + /** @var static $scope */ + $scope = ScopeOps::scopeWith( $this, $result[0], $result[1], @@ -3840,6 +3872,9 @@ public function invalidateExpression(Expr $expressionToInvalidate, bool $require $this->inFirstLevelStatement, $this->afterExtractCall, ); + $scope->resultProvenance = $filteredResultProvenance; + + return $scope; } /** @internal called by ScopeOps */ @@ -4266,6 +4301,7 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $scope->nativeTypesPromoted, $scope->templateArgumentFrame, $scope->templateArgumentConstraints, + $scope->resultProvenance, ); } @@ -4349,6 +4385,101 @@ public function addConditionalExpressions(string $exprString, array $conditional ); } + /** + * The recorded pure defining call of the given expression key ("$v" holds + * the result of this very call), or null when nothing is recorded. + * + * @internal + */ + public function getResultProvenanceCall(string $exprString): ?FuncCall + { + if (!isset($this->resultProvenance[$exprString])) { + return null; + } + + return $this->resultProvenance[$exprString]->getCall(); + } + + /** + * Records that the given expression key currently holds the result of the + * given pure call - the caller (AssignHandler) has just performed the + * assignment and vouches for the call's purity and its arguments being + * invalidation-trackable. + * + * @internal + */ + public function recordResultProvenance(string $exprString, FuncCall $call): self + { + $scope = $this->openSpecificationScope(); + $resultProvenance = $this->resultProvenance; + $resultProvenance[$exprString] = new ResultProvenance($call, $this->getNodeKey($call)); + $scope->resultProvenance = $resultProvenance; + + return $scope; + } + + /** + * Merge counterpart for result provenance: only entries recording the very + * same defining call on both sides survive. + * + * @param array $ours + * @param array $theirs + * @return array + */ + private static function intersectResultProvenance(array $ours, array $theirs): array + { + if ($ours === [] || $theirs === []) { + return []; + } + + $intersected = []; + foreach ($ours as $exprString => $provenance) { + if (!isset($theirs[$exprString])) { + continue; + } + if ($theirs[$exprString]->getCallExprString() !== $provenance->getCallExprString()) { + continue; + } + + $intersected[$exprString] = $provenance; + } + + return $intersected; + } + + /** + * Drops provenance entries whose target or defining call mentions the + * invalidated expression - conservatively by printed-key containment, a + * cheap over-approximation of ScopeOps::shouldInvalidateExpression() + * (sound because dropping an entry only loses narrowing). Containment is + * exhaustive here: entries only ever target plain variables and record + * calls over plain variable arguments, whose printed keys spell out every + * subexpression. + * + * @param array $resultProvenance + * @return array + */ + private static function filterResultProvenance(array $resultProvenance, string $exprStringToInvalidate): array + { + if ($resultProvenance === []) { + return $resultProvenance; + } + + $filtered = []; + foreach ($resultProvenance as $exprString => $provenance) { + if ( + str_contains($exprString, $exprStringToInvalidate) + || str_contains($provenance->getCallExprString(), $exprStringToInvalidate) + ) { + continue; + } + + $filtered[$exprString] = $provenance; + } + + return $filtered; + } + public function exitFirstLevelStatements(): self { if (!$this->inFirstLevelStatement) { @@ -4440,8 +4571,8 @@ private function mergeWithVariableState(?self $otherScope, bool $preserveVacuous $otherScope->nativeExpressionTypes, ); - /** @var static */ - return ScopeOps::scopeWith( + /** @var static $mergedScope */ + $mergedScope = ScopeOps::scopeWith( $this, $mergedExpressionTypes, $mergedNativeTypes, @@ -4452,6 +4583,11 @@ private function mergeWithVariableState(?self $otherScope, bool $preserveVacuous $this->inFirstLevelStatement, $this->afterExtractCall && $otherScope->afterExtractCall, ); + // scopeWith() copied our side's provenance - a merge keeps only entries + // identical in both branches + $mergedScope->resultProvenance = self::intersectResultProvenance($this->resultProvenance, $otherScope->resultProvenance); + + return $mergedScope; } /** @@ -4733,6 +4869,7 @@ public function processFinallyScope(self $finallyScope, self $originalFinallySco $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + self::intersectResultProvenance($this->resultProvenance, $finallyScope->resultProvenance), ); } @@ -4782,6 +4919,7 @@ public function processClosureScope( return $this; } + $resultProvenance = $this->resultProvenance; foreach ($byRefUses as $use) { if (!is_string($use->var->name)) { throw new ShouldNotHappenException(); @@ -4789,6 +4927,7 @@ public function processClosureScope( $variableName = $use->var->name; $variableExprString = '$' . $variableName; + $resultProvenance = self::filterResultProvenance($resultProvenance, $variableExprString); if (!$closureScope->hasVariableType($variableName)->yes()) { $holder = ExpressionTypeHolder::createYes($use->var, new NullType()); @@ -4831,6 +4970,7 @@ public function processClosureScope( $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $resultProvenance, ); } @@ -4882,6 +5022,7 @@ public function processAlwaysIterableForeachScopeWithoutPollute(self $finalScope $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + self::intersectResultProvenance($this->resultProvenance, $finalScope->resultProvenance), ); } @@ -4947,6 +5088,7 @@ private function generalizeWithVariableState(self $otherScope, ?array $writableV $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + self::intersectResultProvenance($this->resultProvenance, $otherScope->resultProvenance), ); } @@ -5647,6 +5789,10 @@ public function debug(): array } } + foreach ($this->resultProvenance as $exprString => $provenance) { + $descriptions[sprintf('result provenance of %s', $exprString)] = $provenance->getCallExprString(); + } + return $descriptions; } diff --git a/src/Analyser/NodeCallbackScope.php b/src/Analyser/NodeCallbackScope.php index 9e0d4070b8..06b6e98cd2 100644 --- a/src/Analyser/NodeCallbackScope.php +++ b/src/Analyser/NodeCallbackScope.php @@ -77,6 +77,7 @@ public function toWalkScope(): MutatingScope $this->nativeTypesPromoted, $this->templateArgumentFrame, $this->templateArgumentConstraints, + $this->resultProvenance, ); } diff --git a/src/Analyser/ResultProvenance.php b/src/Analyser/ResultProvenance.php new file mode 100644 index 0000000000..dd44c4ac79 --- /dev/null +++ b/src/Analyser/ResultProvenance.php @@ -0,0 +1,37 @@ +call; + } + + public function getCallExprString(): string + { + return $this->callExprString; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-13972.php b/tests/PHPStan/Analyser/nsrt/bug-13972.php new file mode 100644 index 0000000000..55f21ae130 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13972.php @@ -0,0 +1,61 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug13972; + +use function gettype; +use function PHPStan\Testing\assertNativeType; +use function PHPStan\Testing\assertType; + +class HelloWorld +{ + + public function getAssignment(string $flagKey, string|bool $defaultValue): string|bool + { + $type = gettype($defaultValue); + + return match ($type) { + 'string' => $this->getString($defaultValue), + 'boolean' => $this->getBool($defaultValue), + }; + } + + public function getAssignmentMatchAsserts(string $flagKey, string|bool $defaultValue): void + { + $type = gettype($defaultValue); + + match ($type) { + 'string' => assertType('string', $defaultValue), + 'boolean' => assertType('bool', $defaultValue), + }; + } + + public function getAssignmentIf(string $flagKey, string|bool $defaultValue): string|bool + { + $type = gettype($defaultValue); + + if ($type === 'string') { + assertType('string', $defaultValue); + assertNativeType('string', $defaultValue); + + return $this->getString($defaultValue); + } + + assertType('bool', $defaultValue); + assertNativeType('bool', $defaultValue); + + return $this->getBool($defaultValue); + } + + public function getBool(bool $default): bool + { + return true; + } + + public function getString(string $default): string + { + return 'toto'; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14464.php b/tests/PHPStan/Analyser/nsrt/bug-14464.php new file mode 100644 index 0000000000..c39709e016 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14464.php @@ -0,0 +1,55 @@ +columnName($colParts[0]); + if (strtolower($colParts[1]) !== 'as') { + throw new LogicException(sprintf('"%s" is not a valid column name or alias', $columnName)); + } + $this->columnName($colParts[2]); + } elseif ($numParts == 2) { + assertType('array{non-empty-string, non-empty-string}', $colParts); + // columnAbc aliasName + $this->columnName($colParts[0]); + $this->columnName($colParts[1]); + } elseif ($numParts == 1) { + assertType('array{non-empty-string}', $colParts); + if ($colParts[0] !== '*') { + // columnAbc + $this->columnName($colParts[0]); + } + } else { + throw new LogicException(sprintf('"%s" is not a valid column or alias', $columnName)); + } + assertType('list{0: non-empty-string, 1?: non-empty-string, 2?: non-empty-string}', $colParts); + } + + public function columnName(string $columnName): string + { + return 'abc'; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-523.php b/tests/PHPStan/Analyser/nsrt/bug-523.php new file mode 100644 index 0000000000..bd66776e32 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-523.php @@ -0,0 +1,29 @@ += 8.0 + +declare(strict_types = 1); + +namespace ResultProvenance; + +use PHPStan\TrinaryLogic; +use function array_pop; +use function count; +use function get_class; +use function gettype; +use function PHPStan\Testing\assertType; +use function PHPStan\Testing\assertVariableCertainty; + +class ProvBase {} +class ProvChild extends ProvBase {} + +class Foo +{ + + /** @param list $parts */ + public function narrowsThroughVariable(array $parts): void + { + $n = count($parts); + if ($n === 2) { + assertType('array{string, string}', $parts); + } else { + assertType('list', $parts); + } + } + + /** @param list $parts */ + public function argWrittenAfterAssign(array $parts): void + { + $n = count($parts); + $parts[] = 'x'; + if ($n === 2) { + // $parts changed since the count() - no shape reconstruction + assertType('non-empty-list', $parts); + } + } + + /** @param list $parts */ + public function targetReassignedAfterAssign(array $parts): void + { + $n = count($parts); + $n = $this->someInt(); + if ($n === 2) { + // $n no longer holds the count() result + assertType('list', $parts); + } + } + + public function argReassignedGettype(string|bool $v): void + { + $type = gettype($v); + $v = 'hello'; + if ($type === 'boolean') { + // $v was overwritten - must not intersect with bool + assertType("'hello'", $v); + } + } + + /** @param list $parts */ + public function unsetArgAfterAssign(array $parts): void + { + $n = count($parts); + unset($parts); + if ($n === 2) { + assertVariableCertainty(TrinaryLogic::createNo(), $parts); + } + } + + /** @param list $parts */ + public function poppedArgAfterAssign(array $parts): void + { + $n = count($parts); + array_pop($parts); + if ($n === 2) { + // one element was removed since the count() - no 2-tuple + assertType('list', $parts); + } + } + + /** + * @param list $a + * @param list $b + */ + public function mergeOfDifferentCalls(array $a, array $b, bool $flag): void + { + if ($flag) { + $n = count($a); + } else { + $n = count($b); + } + if ($n === 2) { + // neither call survives the merge - which one $n came from is unknown + assertType('list', $a); + assertType('list', $b); + } + } + + /** @param list $a */ + public function mergeOfSameCall(array $a, bool $flag): void + { + if ($flag) { + $n = count($a); + } else { + $n = count($a); + } + if ($n === 2) { + // the same defining call on both sides survives the merge + assertType('array{string, string}', $a); + } + } + + /** @param list $parts */ + public function byRefClosureAfterAssign(array $parts): void + { + $n = count($parts); + $fn = function () use (&$parts): void { + $parts = ['a', 'b', 'c']; + }; + $fn(); + if ($n === 2) { + // the by-ref closure may have replaced $parts + assertType('non-empty-list', $parts); + } + } + + public function switchThroughVariable(ProvBase $object): void + { + $class = get_class($object); + switch ($class) { + case ProvChild::class: + assertType('ResultProvenance\ProvChild', $object); + break; + default: + assertType('ResultProvenance\ProvBase', $object); + } + } + + /** @param list> $matrix */ + public function loopReassignsEachIteration(array $matrix): void + { + foreach ($matrix as $row) { + $n = count($row); + if ($n === 2) { + assertType('array{string, string}', $row); + } + $row = ['x']; + } + } + + public function someInt(): int + { + return 5; + } + +} diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 83fd77e1f5..50b576a2b7 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4495,4 +4495,13 @@ public function testUnconstrainedQueryResult(): void $this->analyse([__DIR__ . '/data/unconstrained-query-result.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testBug13972(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-13972.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-13972.php b/tests/PHPStan/Rules/Methods/data/bug-13972.php new file mode 100644 index 0000000000..9cd5da9ad4 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-13972.php @@ -0,0 +1,24 @@ += 8.0 + +namespace Bug13972Methods; + +class HelloWorld +{ + public function getAssignment(string $flagKey, string|bool $defaultValue): string|bool + { + $type = gettype($defaultValue); + + return match ($type) { + 'string' => $this->getString($defaultValue), + 'boolean' => $this->getBool($defaultValue), + }; + } + + public function getBool(bool $default): bool { + return true; + } + + public function getString(string $default): string { + return "toto"; + } +}