Skip to content

Commit 36e9fdd

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 797b949 commit 36e9fdd

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
@@ -64,6 +64,7 @@ public function create(
6464
bool $afterExtractCall = false,
6565
?MutatingScope $parentScope = null,
6666
bool $nativeTypesPromoted = false,
67+
array $resultProvenance = [],
6768
): MutatingScope
6869
{
6970
$className = MutatingScope::class;
@@ -103,6 +104,7 @@ public function create(
103104
$afterExtractCall,
104105
$parentScope,
105106
$nativeTypesPromoted,
107+
$resultProvenance,
106108
);
107109
}
108110

‎src/Analyser/ExprHandler/AssignHandler.php‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@
101101
final class AssignHandler implements ExprHandler
102102
{
103103

104+
/**
105+
* Pure information-carrying functions whose call result keeps provenance
106+
* when assigned to a variable: comparing the variable later narrows
107+
* through the call's own comparison machinery, exactly like comparing the
108+
* call directly (`$type = gettype($x); if ($type === 'string')` narrows
109+
* $x the way `if (gettype($x) === 'string')` does).
110+
*/
111+
private const RESULT_PROVENANCE_FUNCTIONS = ['count', 'sizeof', 'gettype', 'get_class', 'get_debug_type'];
112+
104113
public function __construct(
105114
private VarAnnotationProcessor $varAnnotationProcessor,
106115
private PhpVersion $phpVersion,
@@ -1211,6 +1220,11 @@ public function applyWrite(
12111220
$scope = $scope->addConditionalExpressions((string) $exprString, $holders);
12121221
}
12131222

1223+
$provenanceCall = $this->resolveResultProvenanceCall($assignedExpr, $var->name);
1224+
if ($provenanceCall !== null) {
1225+
$scope = $scope->recordResultProvenance('$' . $var->name, $provenanceCall);
1226+
}
1227+
12141228
if ($assignedExpr instanceof Expr\Array_) {
12151229
$scope = $this->processArrayByRefItems($nodeScopeResolver, $scope, $storage, $var->name, $assignedExpr, new Variable($var->name));
12161230
}
@@ -1761,6 +1775,46 @@ private function unwrapAssign(Expr $expr): Expr
17611775
return $expr;
17621776
}
17631777

1778+
/**
1779+
* The assigned expression, when it is a whitelisted pure call over a plain
1780+
* variable whose result the target variable now provably holds - the shape
1781+
* whose provenance the scope records. The single-variable-argument
1782+
* requirement keeps invalidation exact: a write to the argument (or the
1783+
* target) drops the record by key containment alone.
1784+
*/
1785+
private function resolveResultProvenanceCall(Expr $assignedExpr, string $targetVariableName): ?FuncCall
1786+
{
1787+
if (
1788+
!$assignedExpr instanceof FuncCall
1789+
|| !$assignedExpr->name instanceof Name
1790+
|| $assignedExpr->isFirstClassCallable()
1791+
|| !in_array($assignedExpr->name->toLowerString(), self::RESULT_PROVENANCE_FUNCTIONS, true)
1792+
) {
1793+
return null;
1794+
}
1795+
1796+
$args = $assignedExpr->getArgs();
1797+
if (count($args) !== 1 || $args[0]->unpack || $args[0]->name !== null) {
1798+
return null;
1799+
}
1800+
1801+
$argValue = $args[0]->value;
1802+
if (
1803+
!$argValue instanceof Variable
1804+
|| !is_string($argValue->name)
1805+
// the call read the target's pre-assignment value - after the
1806+
// assignment the record would describe the variable through itself
1807+
|| $argValue->name === $targetVariableName
1808+
// closure binds rebind $this without touching provenance
1809+
|| $argValue->name === 'this'
1810+
|| in_array($argValue->name, Scope::SUPERGLOBAL_VARIABLES, true)
1811+
) {
1812+
return null;
1813+
}
1814+
1815+
return $assignedExpr;
1816+
}
1817+
17641818
/**
17651819
* @param array<string, ConditionalExpressionHolder[]> $conditionalExpressions
17661820
* @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 (
@@ -817,6 +832,40 @@ private function specifyEqualAgainstConstantSide(
817832
return false;
818833
}
819834

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

@@ -860,10 +914,11 @@ private function specifyFuncCallFamilies(
860914
$constantStrings = $constantType->getConstantStrings();
861915
if (count($constantStrings) === 1 && $constantStrings[0]->getValue() === '') {
862916
$argExpr = $call->getArgs()[0]->value;
863-
if ($argResult === null) {
917+
$argType = $this->resolveFamilyArgType($call, $argResult, $argTypesFromScopeState, $evaluationScope);
918+
if ($argType === null) {
864919
return null;
865920
}
866-
if ($argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted)->isString()->yes()) {
921+
if ($argType->isString()->yes()) {
867922
return $this->defaultNarrowingHelper->createForSubject(
868923
$argExpr,
869924
new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]),
@@ -883,10 +938,10 @@ private function specifyFuncCallFamilies(
883938
$constantStrings = $constantType->getConstantStrings();
884939
if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') {
885940
$argExpr = $call->getArgs()[0]->value;
886-
if ($argResult === null) {
941+
$argType = $this->resolveFamilyArgType($call, $argResult, $argTypesFromScopeState, $evaluationScope);
942+
if ($argType === null) {
887943
return null;
888944
}
889-
$argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted);
890945
$objectType = new ObjectType($constantStrings[0]->getValue());
891946
$classStringType = new GenericClassStringType($objectType);
892947

@@ -917,10 +972,10 @@ private function specifyFuncCallFamilies(
917972
) {
918973
if ($context->truthy() && $constantType->isNonEmptyString()->yes()) {
919974
$argExpr = $call->getArgs()[0]->value;
920-
if ($argResult === null) {
975+
$argType = $this->resolveFamilyArgType($call, $argResult, $argTypesFromScopeState, $evaluationScope);
976+
if ($argType === null) {
921977
return null;
922978
}
923-
$argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted);
924979

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

965-
if ($argResult === null) {
1020+
$argType = $this->resolveFamilyArgType($call, $argResult, $argTypesFromScopeState, $evaluationScope);
1021+
if ($argType === null) {
9661022
return null;
9671023
}
968-
$argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted);
9691024

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

10251080
if ($context->truthy() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($constantType)->yes()) {
1026-
if ($argResult === null) {
1081+
$argType = $this->resolveFamilyArgType($call, $argResult, $argTypesFromScopeState, $evaluationScope);
1082+
if ($argType === null) {
10271083
return null;
10281084
}
1029-
if ($argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted)->isString()->yes()) {
1085+
if ($argType->isString()->yes()) {
10301086
$accessory = IntegerRangeType::fromInterval(2, null)->isSuperTypeOf($constantType)->yes()
10311087
? new AccessoryNonFalsyStringType()
10321088
: new AccessoryNonEmptyStringType();
@@ -1103,6 +1159,25 @@ private function specifyFuncCallFamilies(
11031159
return false;
11041160
}
11051161

1162+
/**
1163+
* The first argument's current type for the family compositions: from the
1164+
* captured operand result or - for a provenance-recorded call from an
1165+
* earlier statement, which has no captured results in this comparison -
1166+
* from the evaluation scope's tracked state. Null means unknown and the
1167+
* family falls back to the old-world path.
1168+
*/
1169+
private function resolveFamilyArgType(Expr\FuncCall $call, ?ExpressionResult $argResult, bool $argTypesFromScopeState, MutatingScope $evaluationScope): ?Type
1170+
{
1171+
if ($argTypesFromScopeState) {
1172+
return $evaluationScope->getStateType($call->getArgs()[0]->value);
1173+
}
1174+
if ($argResult === null) {
1175+
return null;
1176+
}
1177+
1178+
return $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted);
1179+
}
1180+
11061181
/**
11071182
* The first argument's stored result of a (possibly remembered) call
11081183
* 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
@@ -19,6 +19,7 @@ interface InternalScopeFactory
1919
* @param array<string, bool> $currentlyAssignedExpressions
2020
* @param array<string, true> $currentlyAllowedUndefinedExpressions
2121
* @param list<array{FunctionReflection|MethodReflection|null, ParameterReflection|null}> $inFunctionCallsStack
22+
* @param array<string, ResultProvenance> $resultProvenance
2223
*/
2324
public function create(
2425
ScopeContext $context,
@@ -37,6 +38,7 @@ public function create(
3738
bool $afterExtractCall = false,
3839
?MutatingScope $parentScope = null,
3940
bool $nativeTypesPromoted = false,
41+
array $resultProvenance = [],
4042
): MutatingScope;
4143

4244
public function toNodeCallbackScopeFactory(): self;

‎src/Analyser/LazyInternalScopeFactory.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ public function create(
8585
bool $afterExtractCall = false,
8686
?MutatingScope $parentScope = null,
8787
bool $nativeTypesPromoted = false,
88+
array $resultProvenance = [],
8889
): MutatingScope
8990
{
9091
$className = MutatingScope::class;
@@ -136,6 +137,7 @@ public function create(
136137
$afterExtractCall,
137138
$parentScope,
138139
$nativeTypesPromoted,
140+
$resultProvenance,
139141
);
140142
}
141143

0 commit comments

Comments
 (0)