Skip to content

Commit 716f03e

Browse files
ondrejmirtesclaude
andcommitted
Narrow comparisons on a variable through its recorded defining call
When a variable is assigned the result of a pure information-carrying call over a plain variable argument (count(), sizeof(), gettype(), get_class(), get_debug_type()), the scope now records that provenance: comparing the variable against a single-valued constant additionally runs the call's own comparison narrowing, so $numParts = count($colParts); if ($numParts === 3) { ... } reconstructs $colParts's shape exactly like the direct `if (count($colParts) === 3)` form - through if/elseif, match arms and switch cases alike, in both type flavours and both branch directions. The provenance map is threaded through MutatingScope like the other expression tables: merges keep only entries recording the identical call on both sides, and invalidating any expression the entry mentions (the target, or the call's argument - by printed-key containment) drops it. Recording is a cheap name check at assignment; lookup is one isset() per constant comparison on a variable. None of the touched files is turbo-shadowed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGhnJQpUWRRpg6H5LuWJkA
1 parent 408f6e7 commit 716f03e

14 files changed

Lines changed: 671 additions & 16 deletions

‎src/Analyser/DirectInternalScopeFactory.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ public function create(
6868
bool $nativeTypesPromoted = false,
6969
?TemplateArgumentFrame $templateArgumentFrame = null,
7070
?TemplateArgumentConstraints $templateArgumentConstraints = null,
71+
array $resultProvenance = [],
7172
): MutatingScope
7273
{
7374
$className = MutatingScope::class;
@@ -109,6 +110,7 @@ public function create(
109110
$nativeTypesPromoted,
110111
$templateArgumentFrame,
111112
$templateArgumentConstraints,
113+
$resultProvenance,
112114
);
113115
}
114116

‎src/Analyser/ExprHandler/AssignHandler.php‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,15 @@ final class AssignHandler implements ExprHandler
113113

114114
private const DERIVED_CONDITIONAL_EXPRESSIONS_LIMIT = 16;
115115

116+
/**
117+
* Pure information-carrying functions whose call result keeps provenance
118+
* when assigned to a variable: comparing the variable later narrows
119+
* through the call's own comparison machinery, exactly like comparing the
120+
* call directly (`$type = gettype($x); if ($type === 'string')` narrows
121+
* $x the way `if (gettype($x) === 'string')` does).
122+
*/
123+
private const RESULT_PROVENANCE_FUNCTIONS = ['count', 'sizeof', 'gettype', 'get_class', 'get_debug_type'];
124+
116125
public function __construct(
117126
private TemplateArgumentObserver $templateArgumentObserver,
118127
private VarAnnotationProcessor $varAnnotationProcessor,
@@ -1280,6 +1289,11 @@ public function applyWrite(
12801289
$scope = $scope->addConditionalExpressions((string) $exprString, $holders);
12811290
}
12821291

1292+
$provenanceCall = $this->resolveResultProvenanceCall($assignedExpr, $var->name);
1293+
if ($provenanceCall !== null) {
1294+
$scope = $scope->recordResultProvenance('$' . $var->name, $provenanceCall);
1295+
}
1296+
12831297
if ($assignedExpr instanceof Expr\Array_) {
12841298
$scope = $this->processArrayByRefItems($nodeScopeResolver, $scope, $storage, $var->name, $assignedExpr, new Variable($var->name));
12851299
}
@@ -1901,6 +1915,46 @@ private function unwrapAssign(Expr $expr): Expr
19011915
return $expr;
19021916
}
19031917

1918+
/**
1919+
* The assigned expression, when it is a whitelisted pure call over a plain
1920+
* variable whose result the target variable now provably holds - the shape
1921+
* whose provenance the scope records. The single-variable-argument
1922+
* requirement keeps invalidation exact: a write to the argument (or the
1923+
* target) drops the record by key containment alone.
1924+
*/
1925+
private function resolveResultProvenanceCall(Expr $assignedExpr, string $targetVariableName): ?FuncCall
1926+
{
1927+
if (
1928+
!$assignedExpr instanceof FuncCall
1929+
|| !$assignedExpr->name instanceof Name
1930+
|| $assignedExpr->isFirstClassCallable()
1931+
|| !in_array($assignedExpr->name->toLowerString(), self::RESULT_PROVENANCE_FUNCTIONS, true)
1932+
) {
1933+
return null;
1934+
}
1935+
1936+
$args = $assignedExpr->getArgs();
1937+
if (count($args) !== 1 || $args[0]->unpack || $args[0]->name !== null) {
1938+
return null;
1939+
}
1940+
1941+
$argValue = $args[0]->value;
1942+
if (
1943+
!$argValue instanceof Variable
1944+
|| !is_string($argValue->name)
1945+
// the call read the target's pre-assignment value - after the
1946+
// assignment the record would describe the variable through itself
1947+
|| $argValue->name === $targetVariableName
1948+
// closure binds rebind $this without touching provenance
1949+
|| $argValue->name === 'this'
1950+
|| in_array($argValue->name, Scope::SUPERGLOBAL_VARIABLES, true)
1951+
) {
1952+
return null;
1953+
}
1954+
1955+
return $assignedExpr;
1956+
}
1957+
19041958
/**
19051959
* @param array<string, ConditionalExpressionHolder[]> $conditionalExpressions
19061960
* @param ImpurePoint[] $rhsImpurePoints

‎src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php‎

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
use PHPStan\Type\UnionType;
4747
use function count;
4848
use function in_array;
49+
use function is_string;
4950

5051
/**
5152
* New-world narrowing for `===` (and, via a negated context, `!==`): composed
@@ -239,6 +240,20 @@ private function specifyAgainstScalarLiteral(
239240
return null;
240241
}
241242

243+
// a provenance-recorded subject variable narrows through its defining
244+
// call's families, exactly like the direct call comparison - the
245+
// variable itself still pins to the constant alongside
246+
$provenanceTypes = $this->specifyThroughResultProvenance($subject, $constantExpr, $constantType, $context, $evaluationScope);
247+
if ($provenanceTypes !== null) {
248+
return $provenanceTypes->unionWith($this->defaultNarrowingHelper->createSubjectTypes(
249+
$evaluationScope,
250+
$subject,
251+
$subjectResult,
252+
$constantType,
253+
$context,
254+
));
255+
}
256+
242257
// $a::class === Foo::class narrows $a to a final Foo when true;
243258
// other contexts and plain-string sides only pin the fetch
244259
if (
@@ -816,6 +831,40 @@ private function specifyEqualAgainstConstantSide(
816831
return false;
817832
}
818833

834+
/**
835+
* When the subject variable is provenance-recorded as holding the result
836+
* of a pure call (`$v = count($x)`), the comparison against the constant
837+
* also runs the call's own family narrowing - `$v == 3` narrows $x like
838+
* `count($x) == 3` would. The recorded call comes from an earlier
839+
* statement and has no stored results in this walk, so the families read
840+
* the argument's current type from the evaluation scope's tracked state.
841+
*/
842+
private function specifyThroughResultProvenance(
843+
Expr $subject,
844+
Expr $constantExpr,
845+
Type $constantType,
846+
TypeSpecifierContext $context,
847+
MutatingScope $evaluationScope,
848+
): ?SpecifiedTypes
849+
{
850+
$unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject;
851+
if (!$unwrappedSubject instanceof Expr\Variable || !is_string($unwrappedSubject->name)) {
852+
return null;
853+
}
854+
855+
$call = $evaluationScope->getResultProvenanceCall('$' . $unwrappedSubject->name);
856+
if ($call === null) {
857+
return null;
858+
}
859+
860+
$familyTypes = $this->specifyFuncCallFamilies($call, null, $call, $constantExpr, $constantType, $context, $evaluationScope, null, true);
861+
if (!$familyTypes instanceof SpecifiedTypes) {
862+
return null;
863+
}
864+
865+
return $familyTypes;
866+
}
867+
819868
/**
820869
* The function-family compositions, shared by the literal and the
821870
* TYPE-based constant sides: a family answer, null to fall back to the
@@ -826,13 +875,14 @@ private function specifyEqualAgainstConstantSide(
826875
*/
827876
private function specifyFuncCallFamilies(
828877
Expr $subject,
829-
ExpressionResult $subjectResult,
878+
?ExpressionResult $subjectResult,
830879
Expr\FuncCall $call,
831880
Expr $constantExpr,
832881
Type $constantType,
833882
TypeSpecifierContext $context,
834883
MutatingScope $evaluationScope,
835884
?ExpressionResult $argResult,
885+
bool $argTypesFromScopeState = false,
836886
): SpecifiedTypes|false|null
837887
{
838888
if (!($call->name instanceof Name) || $call->isFirstClassCallable() || !isset($call->getArgs()[0])) {
@@ -845,6 +895,10 @@ private function specifyFuncCallFamilies(
845895
$call->name->toLowerString() === 'preg_match'
846896
) {
847897
if ($context->true() && (new ConstantIntegerType(1))->isSuperTypeOf($constantType)->yes()) {
898+
if ($subjectResult === null) {
899+
return null;
900+
}
901+
848902
return $subjectResult->getSpecifiedTypesForScope($evaluationScope, $context);
849903
}
850904

@@ -859,10 +913,11 @@ private function specifyFuncCallFamilies(
859913
$constantStrings = $constantType->getConstantStrings();
860914
if (count($constantStrings) === 1 && $constantStrings[0]->getValue() === '') {
861915
$argExpr = $call->getArgs()[0]->value;
862-
if ($argResult === null) {
916+
$argType = $this->resolveFamilyArgType($call, $argResult, $argTypesFromScopeState, $evaluationScope);
917+
if ($argType === null) {
863918
return null;
864919
}
865-
if ($argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted)->isString()->yes()) {
920+
if ($argType->isString()->yes()) {
866921
return $this->defaultNarrowingHelper->createForSubject(
867922
$argExpr,
868923
new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]),
@@ -882,10 +937,10 @@ private function specifyFuncCallFamilies(
882937
$constantStrings = $constantType->getConstantStrings();
883938
if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') {
884939
$argExpr = $call->getArgs()[0]->value;
885-
if ($argResult === null) {
940+
$argType = $this->resolveFamilyArgType($call, $argResult, $argTypesFromScopeState, $evaluationScope);
941+
if ($argType === null) {
886942
return null;
887943
}
888-
$argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted);
889944
$objectType = new ObjectType($constantStrings[0]->getValue());
890945
$classStringType = new GenericClassStringType($objectType);
891946

@@ -916,10 +971,10 @@ private function specifyFuncCallFamilies(
916971
) {
917972
if ($context->truthy() && $constantType->isNonEmptyString()->yes()) {
918973
$argExpr = $call->getArgs()[0]->value;
919-
if ($argResult === null) {
974+
$argType = $this->resolveFamilyArgType($call, $argResult, $argTypesFromScopeState, $evaluationScope);
975+
if ($argType === null) {
920976
return null;
921977
}
922-
$argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted);
923978

924979
if ($argType->isString()->yes()) {
925980
$types = new SpecifiedTypes();
@@ -961,10 +1016,10 @@ private function specifyFuncCallFamilies(
9611016
return $this->defaultNarrowingHelper->createForSubject($argExpr, new NeverType(), $context, $evaluationScope);
9621017
}
9631018

964-
if ($argResult === null) {
1019+
$argType = $this->resolveFamilyArgType($call, $argResult, $argTypesFromScopeState, $evaluationScope);
1020+
if ($argType === null) {
9651021
return null;
9661022
}
967-
$argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted);
9681023

9691024
if ((new ConstantIntegerType(0))->isSuperTypeOf($constantType)->yes()) {
9701025
$newArgType = $context->truthy() && !$argType->isArray()->yes()
@@ -1022,10 +1077,11 @@ private function specifyFuncCallFamilies(
10221077
}
10231078

10241079
if ($context->truthy() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($constantType)->yes()) {
1025-
if ($argResult === null) {
1080+
$argType = $this->resolveFamilyArgType($call, $argResult, $argTypesFromScopeState, $evaluationScope);
1081+
if ($argType === null) {
10261082
return null;
10271083
}
1028-
if ($argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted)->isString()->yes()) {
1084+
if ($argType->isString()->yes()) {
10291085
$accessory = IntegerRangeType::fromInterval(2, null)->isSuperTypeOf($constantType)->yes()
10301086
? new AccessoryNonFalsyStringType()
10311087
: new AccessoryNonEmptyStringType();
@@ -1102,6 +1158,25 @@ private function specifyFuncCallFamilies(
11021158
return false;
11031159
}
11041160

1161+
/**
1162+
* The first argument's current type for the family compositions: from the
1163+
* captured operand result or - for a provenance-recorded call from an
1164+
* earlier statement, which has no captured results in this comparison -
1165+
* from the evaluation scope's tracked state. Null means unknown and the
1166+
* family falls back to the old-world path.
1167+
*/
1168+
private function resolveFamilyArgType(Expr\FuncCall $call, ?ExpressionResult $argResult, bool $argTypesFromScopeState, MutatingScope $evaluationScope): ?Type
1169+
{
1170+
if ($argTypesFromScopeState) {
1171+
return $evaluationScope->getStateType($call->getArgs()[0]->value);
1172+
}
1173+
if ($argResult === null) {
1174+
return null;
1175+
}
1176+
1177+
return $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted);
1178+
}
1179+
11051180
/**
11061181
* The first argument's stored result of a (possibly remembered) call
11071182
* operand, captured by the seams at create time - the composed function

‎src/Analyser/InternalScopeFactory.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface InternalScopeFactory
2121
* @param array<string, bool> $currentlyAssignedExpressions
2222
* @param array<string, true> $currentlyAllowedUndefinedExpressions
2323
* @param list<array{FunctionReflection|MethodReflection|null, ParameterReflection|null}> $inFunctionCallsStack
24+
* @param array<string, ResultProvenance> $resultProvenance
2425
*/
2526
public function create(
2627
ScopeContext $context,
@@ -41,6 +42,7 @@ public function create(
4142
bool $nativeTypesPromoted = false,
4243
?TemplateArgumentFrame $templateArgumentFrame = null,
4344
?TemplateArgumentConstraints $templateArgumentConstraints = null,
45+
array $resultProvenance = [],
4446
): MutatingScope;
4547

4648
public function toNodeCallbackScopeFactory(): self;

‎src/Analyser/LazyInternalScopeFactory.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ public function create(
8989
bool $nativeTypesPromoted = false,
9090
?TemplateArgumentFrame $templateArgumentFrame = null,
9191
?TemplateArgumentConstraints $templateArgumentConstraints = null,
92+
array $resultProvenance = [],
9293
): MutatingScope
9394
{
9495
$className = MutatingScope::class;
@@ -142,6 +143,7 @@ public function create(
142143
$nativeTypesPromoted,
143144
$templateArgumentFrame,
144145
$templateArgumentConstraints,
146+
$resultProvenance,
145147
);
146148
}
147149

0 commit comments

Comments
 (0)