Skip to content

Commit 0227006

Browse files
staabmphpstan-bot
authored andcommitted
Narrow PHP_VERSION_ID scope from version_compare() calls
When version_compare(PHP_VERSION, '8.4', '<') or similar calls are used in conditions, PHPStan now narrows the PHP_VERSION_ID type in the corresponding scope branches, matching the existing behavior of direct PHP_VERSION_ID comparisons. Supports all three-argument operators (<, <=, >, >=, lt, le, gt, ge, ==, =, eq, !=, <>, ne), the two-argument form compared with ===, !==, <, <=, >=, >, and PHP_VERSION in either argument position. Closes phpstan/phpstan#13904
1 parent 47923ed commit 0227006

4 files changed

Lines changed: 552 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,3 +415,13 @@ When adding or editing PHPDoc comments in this codebase, follow these guidelines
415415
### Ternary expression type narrowing in TypeSpecifier
416416

417417
`TypeSpecifier::specifyTypesInCondition()` handles ternary expressions (`$cond ? $a : $b`) for type narrowing. In a truthy context (e.g., inside `assert()`), the ternary is semantically equivalent to `($cond && $a) || (!$cond && $b)` — meaning if the condition is true, the "if" branch must be truthy, and if false, the "else" branch must be truthy. The fix converts ternary expressions to this `BooleanOr(BooleanAnd(...), BooleanAnd(...))` form so the existing OR/AND narrowing logic handles both branches correctly. This enables `assert($cond ? $x instanceof A : $x instanceof B)` to narrow `$x` to `A|B`. The `AssertFunctionTypeSpecifyingExtension` calls `specifyTypesInCondition` with `TypeSpecifierContext::createTruthy()` context for the assert argument.
418+
419+
### Synthetic ConstFetch expression key matching
420+
421+
When creating synthetic `ConstFetch` AST nodes (e.g., for `PHP_VERSION_ID`) to use with `TypeSpecifier::create()`, the `Name` node type matters for expression key matching. `MutatingScope` tracks expression types keyed by `ExprPrinter::printExpr()` output. A `ConstFetch(new Name('PHP_VERSION_ID'))` prints as `PHP_VERSION_ID`, while `ConstFetch(new Name\FullyQualified('PHP_VERSION_ID'))` prints as `\PHP_VERSION_ID`. These are different keys, so using `FullyQualified` in a synthetic node will fail to match the scope's existing entry, causing the narrowing to have no effect. Always use `new Name('PHP_VERSION_ID')` (unqualified) to match how the original AST represents predefined constants.
422+
423+
### version_compare scope narrowing via FunctionTypeSpecifyingExtension
424+
425+
`version_compare(PHP_VERSION, '8.4', '<')` (three-argument form) narrows `PHP_VERSION_ID` scope via `VersionCompareFunctionTypeSpecifyingExtension`, which implements `FunctionTypeSpecifyingExtension`. It converts the version string to a `PHP_VERSION_ID` equivalent and builds a synthetic comparison expression (`Smaller`, `SmallerOrEqual`, `Identical`, `NotIdentical`) that is then processed by `TypeSpecifier::specifyTypesInCondition()`.
426+
427+
The two-argument form (`version_compare(PHP_VERSION, '8.0') === 1`) is handled directly in `TypeSpecifier::resolveNormalizedIdentical()` and the `Smaller/SmallerOrEqual` section. It maps the comparison result (-1, 0, 1) to a set of version_compare outcomes, then converts those to `PHP_VERSION_ID` type constraints using `getSmallerType()`/`getGreaterType()`/etc. Context handling (true vs false) is done by computing the result set for the truthy case and the complement for the falsey case, then always using `TypeSpecifierContext::createTruthy()` in the `create()` call — matching the pattern used by the existing `Smaller/SmallerOrEqual` handler for direct comparisons.

src/Analyser/TypeSpecifier.php

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
use PHPStan\Type\NullType;
7171
use PHPStan\Type\ObjectType;
7272
use PHPStan\Type\ObjectWithoutClassType;
73+
use PHPStan\Type\Php\VersionCompareFunctionTypeSpecifyingExtension;
7374
use PHPStan\Type\ResourceType;
7475
use PHPStan\Type\StaticMethodTypeSpecifyingExtension;
7576
use PHPStan\Type\StaticType;
@@ -87,6 +88,7 @@
8788
use function array_shift;
8889
use function count;
8990
use function in_array;
91+
use function is_int;
9092
use function is_string;
9193
use function strtolower;
9294
use function substr;
@@ -261,6 +263,34 @@ public function specifyTypesInCondition(
261263
)->setRootExpr($expr);
262264
}
263265

266+
// version_compare(PHP_VERSION, '8.0') < 0 or 0 < version_compare(PHP_VERSION, '8.0')
267+
if (!$context->null()) {
268+
$orEqual = $expr instanceof Node\Expr\BinaryOp\SmallerOrEqual;
269+
270+
if (
271+
$expr->left instanceof FuncCall
272+
&& $expr->left->name instanceof Name
273+
&& $expr->left->name->toLowerString() === 'version_compare'
274+
&& count($expr->left->getArgs()) === 2
275+
) {
276+
$result = $this->specifyTypesForVersionCompareSmallerResult($expr->left, $scope, $scope->getType($expr->right), $orEqual, false, $context, $expr);
277+
if ($result !== null) {
278+
return $result;
279+
}
280+
} elseif (
281+
$expr->right instanceof FuncCall
282+
&& $expr->right->name instanceof Name
283+
&& $expr->right->name->toLowerString() === 'version_compare'
284+
&& count($expr->right->getArgs()) === 2
285+
) {
286+
// value < version_compare(...) → version_compare(...) > value
287+
$result = $this->specifyTypesForVersionCompareSmallerResult($expr->right, $scope, $scope->getType($expr->left), $orEqual, true, $context, $expr);
288+
if ($result !== null) {
289+
return $result;
290+
}
291+
}
292+
}
293+
264294
$orEqual = $expr instanceof Node\Expr\BinaryOp\SmallerOrEqual;
265295
$offset = $orEqual ? 0 : 1;
266296
$leftType = $scope->getType($expr->left);
@@ -2486,6 +2516,21 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
24862516
)->setRootExpr($expr);
24872517
}
24882518

2519+
// version_compare(PHP_VERSION, '8.0') === 1
2520+
if (
2521+
!$context->null()
2522+
&& $unwrappedLeftExpr instanceof FuncCall
2523+
&& $unwrappedLeftExpr->name instanceof Name
2524+
&& $unwrappedLeftExpr->name->toLowerString() === 'version_compare'
2525+
&& count($unwrappedLeftExpr->getArgs()) === 2
2526+
&& $rightType->isInteger()->yes()
2527+
) {
2528+
$result = $this->specifyTypesForVersionCompareResult($unwrappedLeftExpr, $scope, $rightType, $context, $expr);
2529+
if ($result !== null) {
2530+
return $result;
2531+
}
2532+
}
2533+
24892534
// get_class($a) === 'Foo'
24902535
if (
24912536
$context->true()
@@ -2776,4 +2821,219 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
27762821
return (new SpecifiedTypes([], []))->setRootExpr($expr);
27772822
}
27782823

2824+
/**
2825+
* Parses a version_compare FuncCall to extract the PHP version argument index and the version ID.
2826+
*
2827+
* @return array{int, int}|null [phpVersionArgIndex, versionId] or null if not applicable
2828+
*/
2829+
private function parseVersionCompareFuncCall(FuncCall $funcCall, Scope $scope): ?array
2830+
{
2831+
$args = $funcCall->getArgs();
2832+
if (count($args) !== 2) {
2833+
return null;
2834+
}
2835+
2836+
$phpVersionArgIndex = null;
2837+
if ($args[0]->value instanceof ConstFetch && $args[0]->value->name->toString() === 'PHP_VERSION') {
2838+
$phpVersionArgIndex = 0;
2839+
} elseif ($args[1]->value instanceof ConstFetch && $args[1]->value->name->toString() === 'PHP_VERSION') {
2840+
$phpVersionArgIndex = 1;
2841+
}
2842+
2843+
if ($phpVersionArgIndex === null) {
2844+
return null;
2845+
}
2846+
2847+
$otherArgIndex = $phpVersionArgIndex === 0 ? 1 : 0;
2848+
$versionStrings = $scope->getType($args[$otherArgIndex]->value)->getConstantStrings();
2849+
if (count($versionStrings) !== 1) {
2850+
return null;
2851+
}
2852+
2853+
$versionId = VersionCompareFunctionTypeSpecifyingExtension::parseVersionStringToId($versionStrings[0]->getValue());
2854+
if ($versionId === null) {
2855+
return null;
2856+
}
2857+
2858+
return [$phpVersionArgIndex, $versionId];
2859+
}
2860+
2861+
/**
2862+
* Handles version_compare(PHP_VERSION, 'ver') === value in resolveNormalizedIdentical.
2863+
*/
2864+
private function specifyTypesForVersionCompareResult(
2865+
FuncCall $funcCall,
2866+
Scope $scope,
2867+
Type $comparisonValue,
2868+
TypeSpecifierContext $context,
2869+
Expr $rootExpr,
2870+
): ?SpecifiedTypes
2871+
{
2872+
$parsed = $this->parseVersionCompareFuncCall($funcCall, $scope);
2873+
if ($parsed === null) {
2874+
return null;
2875+
}
2876+
2877+
[$phpVersionArgIndex, $versionId] = $parsed;
2878+
$phpVersionSwapped = $phpVersionArgIndex === 1;
2879+
2880+
$constantValues = $comparisonValue->getConstantScalarValues();
2881+
if (count($constantValues) !== 1 || !is_int($constantValues[0])) {
2882+
return null;
2883+
}
2884+
2885+
$value = $constantValues[0];
2886+
if (!in_array($value, [-1, 0, 1], true)) {
2887+
return null;
2888+
}
2889+
2890+
// For true/truthy context (===), the result set is the matched value
2891+
// For false/falsey context (!==), the result set is the complement
2892+
if ($context->true() || $context->truthy()) {
2893+
$resultSet = [$value];
2894+
} else {
2895+
$all = [-1, 0, 1];
2896+
$resultSet = [];
2897+
foreach ($all as $r) {
2898+
if ($r === $value) {
2899+
continue;
2900+
}
2901+
2902+
$resultSet[] = $r;
2903+
}
2904+
}
2905+
2906+
$phpVersionIdExpr = new ConstFetch(new Name('PHP_VERSION_ID'));
2907+
$versionIdType = new ConstantIntegerType($versionId);
2908+
2909+
return $this->specifyTypesFromVersionCompareResultSet($resultSet, $phpVersionIdExpr, $versionIdType, $phpVersionSwapped, $scope, $rootExpr);
2910+
}
2911+
2912+
/**
2913+
* Handles version_compare(PHP_VERSION, 'ver') < value or value < version_compare(PHP_VERSION, 'ver')
2914+
* in the Smaller/SmallerOrEqual section.
2915+
*/
2916+
private function specifyTypesForVersionCompareSmallerResult(
2917+
FuncCall $funcCall,
2918+
Scope $scope,
2919+
Type $comparisonValue,
2920+
bool $orEqual,
2921+
bool $exprSwapped,
2922+
TypeSpecifierContext $context,
2923+
Expr $rootExpr,
2924+
): ?SpecifiedTypes
2925+
{
2926+
$parsed = $this->parseVersionCompareFuncCall($funcCall, $scope);
2927+
if ($parsed === null) {
2928+
return null;
2929+
}
2930+
2931+
[$phpVersionArgIndex, $versionId] = $parsed;
2932+
$phpVersionSwapped = $phpVersionArgIndex === 1;
2933+
2934+
$constantValues = $comparisonValue->getConstantScalarValues();
2935+
if (count($constantValues) !== 1 || !is_int($constantValues[0])) {
2936+
return null;
2937+
}
2938+
2939+
$value = $constantValues[0];
2940+
2941+
// Determine what comparison operation version_compare_result OP value implies
2942+
// First, compute the effective operator on version_compare_result
2943+
$op = $orEqual ? '<=' : '<';
2944+
if ($exprSwapped) {
2945+
// value < version_compare(...) → version_compare(...) > value
2946+
// value <= version_compare(...) → version_compare(...) >= value
2947+
$op = $orEqual ? '>=' : '>';
2948+
}
2949+
2950+
// For false context, flip the operator (like the standard Smaller handler does)
2951+
if ($context->false()) {
2952+
$op = match ($op) {
2953+
'<' => '>=',
2954+
'<=' => '>',
2955+
'>' => '<=',
2956+
'>=' => '<',
2957+
};
2958+
}
2959+
2960+
// Determine which version_compare results satisfy: result OP value
2961+
$resultSet = match ($op) {
2962+
'<' => $value > -1 ? ($value > 0 ? ($value > 1 ? [] : [-1, 0]) : [-1]) : [],
2963+
'<=' => $value >= -1 ? ($value >= 0 ? ($value >= 1 ? [-1, 0, 1] : [-1, 0]) : [-1]) : [],
2964+
'>' => $value < 1 ? ($value < 0 ? ($value < -1 ? [] : [0, 1]) : [1]) : [],
2965+
default => $value <= 1 ? ($value <= 0 ? ($value <= -1 ? [-1, 0, 1] : [0, 1]) : [1]) : [],
2966+
};
2967+
2968+
if ($resultSet === [] || count($resultSet) === 3) {
2969+
return null;
2970+
}
2971+
2972+
$phpVersionIdExpr = new ConstFetch(new Name('PHP_VERSION_ID'));
2973+
$versionIdType = new ConstantIntegerType($versionId);
2974+
2975+
// Always use truthy context since we've already computed the correct type for the branch
2976+
return $this->specifyTypesFromVersionCompareResultSet($resultSet, $phpVersionIdExpr, $versionIdType, $phpVersionSwapped, $scope, $rootExpr);
2977+
}
2978+
2979+
/**
2980+
* Creates SpecifiedTypes for PHP_VERSION_ID based on a set of version_compare results.
2981+
*
2982+
* @param list<int> $resultSet Subset of [-1, 0, 1]
2983+
*/
2984+
private function specifyTypesFromVersionCompareResultSet(
2985+
array $resultSet,
2986+
Expr $phpVersionIdExpr,
2987+
ConstantIntegerType $versionIdType,
2988+
bool $phpVersionSwapped,
2989+
Scope $scope,
2990+
Expr $rootExpr,
2991+
): ?SpecifiedTypes
2992+
{
2993+
// Map result set to PHP_VERSION_ID type, accounting for swapped argument order
2994+
if (count($resultSet) === 1) {
2995+
$r = $resultSet[0];
2996+
$effectiveR = $phpVersionSwapped ? -$r : $r;
2997+
2998+
$type = match ($effectiveR) {
2999+
1 => $versionIdType->getGreaterType($this->phpVersion),
3000+
-1 => $versionIdType->getSmallerType($this->phpVersion),
3001+
0 => $versionIdType,
3002+
default => null,
3003+
};
3004+
3005+
if ($type === null) {
3006+
return null;
3007+
}
3008+
3009+
return $this->create($phpVersionIdExpr, $type, TypeSpecifierContext::createTruthy(), $scope)->setRootExpr($rootExpr);
3010+
}
3011+
3012+
// Two results: [-1, 0] or [0, 1] or [-1, 1]
3013+
if ($resultSet === [-1, 0]) {
3014+
$effectiveOp = $phpVersionSwapped ? '>' : '<=';
3015+
} elseif ($resultSet === [0, 1]) {
3016+
$effectiveOp = $phpVersionSwapped ? '<' : '>=';
3017+
} elseif ($resultSet === [-1, 1]) {
3018+
// != case: PHP_VERSION_ID < versionId OR PHP_VERSION_ID > versionId
3019+
$type = TypeCombinator::union(
3020+
$versionIdType->getSmallerType($this->phpVersion),
3021+
$versionIdType->getGreaterType($this->phpVersion),
3022+
);
3023+
3024+
return $this->create($phpVersionIdExpr, $type, TypeSpecifierContext::createTruthy(), $scope)->setRootExpr($rootExpr);
3025+
} else {
3026+
return null;
3027+
}
3028+
3029+
$type = match ($effectiveOp) {
3030+
'<' => $versionIdType->getSmallerType($this->phpVersion),
3031+
'<=' => $versionIdType->getSmallerOrEqualType($this->phpVersion),
3032+
'>' => $versionIdType->getGreaterType($this->phpVersion),
3033+
default => $versionIdType->getGreaterOrEqualType($this->phpVersion),
3034+
};
3035+
3036+
return $this->create($phpVersionIdExpr, $type, TypeSpecifierContext::createTruthy(), $scope)->setRootExpr($rootExpr);
3037+
}
3038+
27793039
}

0 commit comments

Comments
 (0)