Skip to content

Move operator type inference to Type methods - #6930

Merged
ondrejmirtes merged 14 commits into
2.3.xfrom
operator-type-methods
Nov 4, 2026
Merged

ondrejmirtes merged 14 commits into
2.3.xfrom
operator-type-methods

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Every operator InitializerExprTypeResolver handled is now a method on Type that takes the right operand and the configured PhpVersion:

  • arithmeticAdd, arithmeticSubtract, arithmeticMultiply, arithmeticDivide, arithmeticIntDivide, arithmeticModulo, arithmeticPower, arithmeticNegate
  • bitwiseAnd, bitwiseOr, bitwiseXor, bitwiseShiftLeft, bitwiseShiftRight
  • stringConcatenate, arrayUnion

exponentiate() is deprecated in favour of arithmeticPower(). The logic lives in OperatorTypeHelper, which OperatorTypeTrait delegates to. Both have native mirrors in turbo-ext. The resolver still runs the operator type-specifying extensions first (GMP, BcMath).

Constant scalar comparisons no longer run ==/< at analysis time, so they follow the configured PHP version instead of the runtime one (LooseComparisonHelper::compareValues()).

The move commit doesn't change inferred types. The only exception is BcMath\Number * null, which is now an error instead of 0. The commits after it are soundness fixes, each with an nsrt test that fails without it:

  • Overflowing numeric strings saturate as integer operands instead of wrapping (this fixed crashes).
  • 0 * $float is float, because 0 * INF is NAN.
  • Powers of integer intervals with non-monotonic bases: 0 ** int<0, 10>, (-1) ** int<0, 10>, negative exponents.
  • $int . $string stays numeric only when the string is digits.
  • Integer ranges are subtracted as intervals.
  • $string & $mixed is int|string.
  • No numeric-string for a concatenation of two possibly empty strings.
  • On PHP 8, integer ranges are compared with non-numeric strings integer by integer.
  • On PHP 8, a concatenation keeps numeric-string only when the left side can't end in whitespace, as an int, a float or a decimal-int-string can't ('1 ' is numeric, '1 ' . '5' isn't).
  • $numericString ** 'abc' is an error.

Verified with a local differential fuzzer that compares inferred types with the values the operators produce on PHP 7.1–8.6. It runs with turbo on and off and is not part of this PR. The full test suite passes with and without turbo. Self-analysis output is byte-identical with turbo on and off. side-by-side.php, smoke.php, signature-parity.php and make lint-turbo pass.

Closes phpstan/phpstan#11732

… methods

Each operator is now a Type method taking the right operand and the
configured PhpVersion: arithmeticAdd(), arithmeticSubtract(),
arithmeticMultiply(), arithmeticDivide(), arithmeticIntDivide(),
arithmeticModulo(), arithmeticPower(), arithmeticNegate(), bitwiseAnd(),
bitwiseOr(), bitwiseXor(), bitwiseShiftLeft(), bitwiseShiftRight(),
stringConcatenate() and arrayUnion(). Their shared bodies live in
OperatorTypeHelper (shadowed natively) through OperatorTypeTrait.
exponentiate() is deprecated in favour of arithmeticPower().

InitializerExprTypeResolver calls the operator type-specifying extensions
first and the Type method second, so an extension now also sees operations
the resolver used to shortcut (BcMath\Number * null is an error now instead
of 0).

The inferred types are otherwise unchanged: they are byte-identical on every
supported PHP version, with and without the turbo
extension.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1a1e7-5e72-7235-a4b2-ba1b689f7e1b
%, <<, >>, &, | and ^ converted a constant numeric string through the
float of toNumber(), so '9223372036854775808' wrapped around instead of
saturating like PHP's (int) conversion does, and a shift by it crashed the
analysis with ArithmeticError. The operands now come from toInteger().

Amp-Thread-ID: https://ampcode.com/threads/T-01a1a1e7-5e72-7235-a4b2-ba1b689f7e1b
Zero times a float is 0.0, -0.0 or NAN (0 * INF), so 0 * $float is float
now instead of 0.0, and zero times a type that is not certainly an integer
(numeric-string, int|float) is no longer the integer 0.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1a1e7-5e72-7235-a4b2-ba1b689f7e1b
The bounds of base ** exponent were the powers of the bounds, which holds
only for bases of at least 1: 0 ** int<0, 10> was never instead of 0|1,
(-1) ** int<0, 10> was 1 instead of -1|1, and an int<2, 3> base raised to
int<min, 3> missed the floats of negative exponents. ExponentiateHelper
now handles integer intervals for every base, IntegerRangeType inherits it
from IntegerType and null is exponentiated as 0 instead of always 0|1.

A float operand makes the result a float, the power of 0 is 1|1.0 unless
the base is an integer, and a non-numeric constant string operand is an
error.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1a1e7-5e72-7235-a4b2-ba1b689f7e1b
An integer followed by ' 1' or '+5' is not numeric although those strings
are. The appended string now has to be digits with an optional fraction and
exponent, and neither check accepts a trailing newline, which PHP 7 does
not allow in numeric strings.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1a1e7-5e72-7235-a4b2-ba1b689f7e1b
A constant minus a range unbounded on either side was int<min, constant>,
which is wrong for a negative operand (0 - int<min, -1> is int<1, max>,
not int<min, 0>), and the other cases took the bounds from mismatched ends
before swapping them. [a, b] - [c, d] is [a - d, b - c].

Amp-Thread-ID: https://ampcode.com/threads/T-01a1a1e7-5e72-7235-a4b2-ba1b689f7e1b
Only two strings make a string; with an int, a float, a bool or null on
the other side the string is a number, so '1' & $mixed is 1 for $mixed = 1.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1a1e7-5e72-7235-a4b2-ba1b689f7e1b
… strings

int|null . bool is '' for null . false, so it is not numeric although
removing '' from the left side leaves a numeric string.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1a1e7-5e72-7235-a4b2-ba1b689f7e1b
… PHP 8

PHP 8 compares an integer with a non-numeric string as strings, which does
not keep the order of the integers ('10' < '1abc' < '2'), so the bounds of
a range do not decide the comparison: int<0, 10> < '1abc' was true although
2 < '1abc' is false. Each integer of a finite range decides it now, and an
infinite range is maybe.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1a1e7-5e72-7235-a4b2-ba1b689f7e1b
…whitespace

PHP 8 allows trailing whitespace in numeric strings, so '1 ' is numeric but
'1 ' . '5' is not. Only an integer, a decimal-int-string or constant strings
not ending in whitespace (and any numeric-string on PHP 7) keep the result
numeric.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1a1e7-5e72-7235-a4b2-ba1b689f7e1b
A member's ErrorType is a MixedType, so intersecting it with the other
members' powers hid it: numeric-string ** 'abc' was float|int although it
always throws a TypeError. An erroring member now decides for the
intersection.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1a1e7-5e72-7235-a4b2-ba1b689f7e1b
…in the config

PHP 7.4 reads the class attribute as a comment, so the class starts a line
later there and the inline ignore above the attribute matched nothing.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1a1e7-5e72-7235-a4b2-ba1b689f7e1b
@ondrejmirtes
ondrejmirtes force-pushed the operator-type-methods branch from 1e63163 to d9fc04e Compare November 3, 2026 22:17
@ondrejmirtes
ondrejmirtes merged commit 4489229 into 2.3.x Nov 4, 2026
288 checks passed
@ondrejmirtes
ondrejmirtes deleted the operator-type-methods branch November 4, 2026 03:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Type::isSmallerThanOrEqual does not care about the PHP Version

1 participant