From eddf26403bdbd83063443cb87c883d7a7d86585f Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:47:02 +0000 Subject: [PATCH 01/24] Support integer range keys in constant arrays - When setting a constant array offset with a finite union of constant string types or a finite IntegerRangeType, produce a union of constant arrays instead of degrading to a general array type - Added resolveFiniteScalarKeyTypes() helper on ConstantArrayType to extract constant key types from string unions and integer ranges - Limited expansion to CHUNK_FINITE_TYPES_LIMIT (5) keys to avoid combinatorial explosion in loop fixpoint analysis - Excluded integer constant unions (e.g., 0|1 from loops) to prevent regression in loop variable tracking - Updated constant-array-type-set.php test expectations for more precise results with int<0,4> range offsets --- CLAUDE.md | 124 ++++++++++++++++++ src/Type/Constant/ConstantArrayType.php | 92 +++++++++++++ .../Analyser/nsrt/constant-array-type-set.php | 2 +- ...onstant-union-offset-on-constant-array.php | 50 +++++++ 4 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php diff --git a/CLAUDE.md b/CLAUDE.md index 5a7726f0e6..5990d84828 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -240,6 +240,122 @@ Historical analysis of `Type.php` via `git blame` shows that new methods are add When considering a bug fix that involves checking "is this type a Foo?", first check whether an appropriate method already exists on `Type`. If not, consider whether adding one would be the right fix — especially if the check is needed in more than one place or involves logic that varies by type class. +### Arrow function vs closure parameter handling parity + +`MutatingScope` has separate methods for entering arrow functions (`enterArrowFunctionWithoutReflection`) and closures (`enterAnonymousFunctionWithoutReflection`). Both iterate over parameters and assign types, but they must use the same logic for computing parameter types. In particular, both must call `getFunctionType($parameter->type, $isNullable, $parameter->variadic)` to properly handle variadic parameters (wrapping the inner type in `ArrayType`). Shortcuts like `new MixedType()` for untyped parameters skip the variadic wrapping and cause variadic args to be typed as `mixed` instead of `array`. When fixing bugs in one method, check the other for the same issue. + +### MutatingScope: expression invalidation during scope merging + +When two scopes are merged (e.g. after if/else branches), `MutatingScope::generalizeWith()` must invalidate dependent expressions. If variable `$i` changes, then `$locations[$i]` must be invalidated too. Bugs arise when stale `ExpressionTypeHolder` entries survive scope merges. Fix pattern: in `MutatingScope`, when a root expression changes, skip/invalidate all deep expressions that depend on it. + +### MutatingScope: expression invalidation after method calls and private property visibility + +When a method with side effects is called, `invalidateExpression()` invalidates tracked expression types that depend on the call target. When `$this` is invalidated, `shouldInvalidateExpression()` also matches `self`, `static`, `parent`, and class name references — so `self::$prop`, `$this->prop`, etc. all get invalidated. However, private properties of the current class cannot be modified by methods declared in a different class (parent/other). The `invalidatingClass` parameter on `invalidateExpression()` and `shouldInvalidateExpression()` enables skipping invalidation for private properties whose declaring class differs from the method's declaring class. This is checked via `isPrivatePropertyOfDifferentClass()`. The pattern mirrors the existing readonly property protection (`isReadonlyPropertyFetch`). Both `NodeScopeResolver` call sites (instance method calls at ~line 3188, static method calls at ~line 3398) pass `$methodReflection->getDeclaringClass()` as the invalidating class. + +### Closure::bind() scope leaking into argument evaluation + +`NodeScopeResolver::processArgs()` has special handling for `Closure::bind()` and `Closure::bindTo()` calls. When the first argument is a closure/arrow function literal, a `$closureBindScope` is created with `$this` rebound to the second argument's type, and this scope is used to process the closure body. However, this `$closureBindScope` must ONLY be applied when the first argument is actually an `Expr\Closure` or `Expr\ArrowFunction`. If the first argument is a general expression that returns a closure (e.g. `$this->hydrate()`), the expression itself must be evaluated in the original scope — otherwise `$this` in the expression gets incorrectly resolved as the bound object type instead of the current class. The condition at the `$scopeToPass` assignment must check the argument node type. + +### Array type tracking: SetExistingOffsetValueTypeExpr vs SetOffsetValueTypeExpr + +When assigning to an array offset, NodeScopeResolver must distinguish: +- `SetExistingOffsetValueTypeExpr` - modifying a key known to exist (preserves list type, doesn't widen the array) +- `SetOffsetValueTypeExpr` - adding a potentially new key (may break list type, widens the array) + +Misusing these leads to false positives like "might not be a list" or incorrect offset-exists checks. The fix is in `NodeScopeResolver` where property/variable assignments are processed. + +This distinction also applies in `MutatingScope::enterForeach()`. When a foreach loop iterates by reference (`foreach ($list as &$value)`), modifying `$value` changes an existing offset, not a new one. The `IntertwinedVariableByReferenceWithExpr` created for the no-key by-reference case must use `SetExistingOffsetValueTypeExpr` (not `SetOffsetValueTypeExpr`) so that `AccessoryArrayListType::setExistingOffsetValueType()` preserves the list type. Using `SetOffsetValueTypeExpr` causes `AccessoryArrayListType::setOffsetValueType()` to return `ErrorType` for non-null/non-zero offsets, destroying the list type in the intersection. + +### ConstantArrayType and offset tracking + +Many bugs involve `ConstantArrayType` (array shapes with known keys). Common issues: +- `hasOffsetValueType()` returning wrong results for expression-based offsets +- Offset types not being unioned with empty array when the offset might not exist +- `array_key_exists()` not properly narrowing to `non-empty-array` +- `OversizedArrayType` (array shapes that grew too large to track precisely) needing correct `isSuperTypeOf()` and truthiness behavior + +Fixes typically involve `ConstantArrayType`, `TypeSpecifier` (for narrowing after `array_key_exists`/`isset`), and `MutatingScope` (for tracking assignments). + +### Array literal spread operator and ConstantArrayTypeBuilder degradation + +`InitializerExprTypeResolver::getArrayType()` computes the type of array literals like `[...$a, ...$b]`. It uses `ConstantArrayTypeBuilder` to build the result type. When a spread item is a single constant array (`getConstantArrays()` returns exactly one), its key/value pairs are added individually. When it's not (e.g., `array`), the builder is degraded via `degradeToGeneralArray()`, and all subsequent items are merged into a general `ArrayType` with unioned keys and values. + +The degradation loses specific key information. To preserve it, `getArrayType()` tracks `HasOffsetValueType` accessories for non-optional keys from constant array spreads with string keys. After building, these are intersected with the degraded result. When a non-constant spread appears later that could overwrite tracked keys (its key type is a supertype of the tracked offsets), those entries are invalidated. This ensures correct handling of PHP's spread ordering semantics where later spreads override earlier ones for same-named string keys. + +### Nullsafe operator and ensureShallowNonNullability / revertNonNullability + +`NodeScopeResolver` handles `NullsafeMethodCall` and `NullsafePropertyFetch` by temporarily removing null from the variable's type (`ensureShallowNonNullability`), processing the inner expression, then restoring the original nullable type (`revertNonNullability`). When the expression is an `ArrayDimFetch` (e.g. `$arr['key']?->method()`), `specifyExpressionType` recursively narrows the parent array type via `TypeCombinator::intersect` with `HasOffsetValueType`. This intersection only narrows and cannot widen, so `revertNonNullability` fails to restore the parent array's offset type. The fix is to also save and restore the parent expression's type in `ensureShallowNonNullability`. Without this, subsequent uses of the same nullsafe call are falsely reported as "Using nullsafe method call on non-nullable type" because the parent array retains the narrowed (non-null) offset type. + +### Loop analysis: foreach, for, while + +Loops are a frequent source of false positives because PHPStan must reason about types across iterations: +- **List type preservation in for loops**: When appending to a list inside a `for` loop, the list type must be preserved if operations maintain sequential integer keys. +- **Always-overwritten arrays in foreach**: NodeScopeResolver examines `$a[$k]` at loop body end and `continue` statements. If no `break` exists, the entire array type can be rewritten based on the observed value types. +- **Variable types across iterations**: PHP Fibers are used (PHP 8.1+) for more precise analysis of repeated variable assignments in loops, by running the loop body analysis multiple times to reach a fixpoint. + +### Match expression scope merging + +Match expressions in `NodeScopeResolver` (around line 4154) process each arm and merge the resulting scopes. The critical pattern for variable certainty is: when a match is exhaustive (has a `default` arm or an always-true condition), arm body scopes should be merged only with each other (not with the original pre-match scope). This mirrors how if/else merging works — `$finalScope` starts as `null`, and each branch's scope is merged via `$branchScope->mergeWith($finalScope)`. When the match is NOT exhaustive, the original scope must also participate in the merge (via `$scope->mergeWith($armBodyFinalScope)`) because execution may skip all arms and throw `UnhandledMatchError`. The `mergeVariableHolders()` method in `MutatingScope` uses `ExpressionTypeHolder::createMaybe()` for variables present in only one scope, so merging an arm scope that defines `$x` with the original scope that lacks `$x` degrades certainty to "maybe" — this is the root cause of false "might not be defined" reports for exhaustive match expressions. + +### GenericClassStringType narrowing and tryRemove + +`GenericClassStringType` represents `class-string` where `T` is the generic object type. When the generic type is a union (e.g., `class-string`), it's a single `GenericClassStringType` with an inner `UnionType`. This is distinct from `class-string|class-string|class-string` which is a `UnionType` of individual `GenericClassStringType`s. + +The `tryRemove()` method handles removing a `ConstantStringType` (e.g., `'Car'`) from the class-string type. It must check whether the class is final — only for final classes can exact class-string removal be performed, since non-final classes could have subclasses whose class-strings would still be valid values. When the inner generic type is a union, `TypeCombinator::remove()` is used to remove the corresponding `ObjectType` from the inner union. + +This affects match expression exhaustiveness: `class-string` matched against `FinalA::class` and `FinalB::class` is exhaustive only because both classes are final. + +### StaticType::transformStaticType and ThisType downgrading + +`StaticType::transformStaticType()` is used when resolving method return types on a `StaticType` caller. It traverses the return type and transforms `StaticType`/`ThisType` instances via `changeBaseClass()`. Since `ThisType extends StaticType`, both are caught by the `$type instanceof StaticType` check. The critical invariant: when the **caller** is a `StaticType` (not `ThisType`) and the method's return type contains `ThisType`, the `ThisType` must be downgraded to a plain `StaticType`. This is because `$this` (the exact instance) cannot be guaranteed when calling on a `static` type (which could be any subclass instance). `ThisType::changeBaseClass()` returns a new `ThisType`, which preserves the `$this` semantics — so the downgrade must happen explicitly after `changeBaseClass()`. The `CallbackUnresolvedMethodPrototypeReflection` at line 91 also has special handling for `ThisType` return types intersected with `selfOutType`. + +### PHPDoc inheritance + +PHPDoc types (`@return`, `@param`, `@throws`, `@property`) are inherited through class hierarchies. Bugs arise when: +- Child methods with narrower native return types don't inherit parent PHPDoc return types +- `@property` tags on parent classes don't consider native property types on children +- Trait PHPDoc resolution uses wrong file context + +The `PhpDocInheritanceResolver` and `PhpDocBlock` classes handle this. Recent optimization: resolve through reflection instead of re-walking the hierarchy manually. + +### Dynamic return type extensions for built-in functions + +Many built-in PHP functions need `DynamicFunctionReturnTypeExtension` implementations because their return types depend on arguments: +- `array_rand()`, `array_count_values()`, `array_first()`/`array_last()`, `filter_var()`, `curl_setopt()`, DOM methods, etc. +- Extensions live in `src/Type/Php/` and are registered in `conf/services.neon` +- Each reads the argument types from `Scope::getType()` and returns a more precise `Type` + +### Function signature corrections (`src/Reflection/SignatureMap/`) + +PHPStan maintains its own signature map for built-in PHP functions in `functionMap.php` and delta files. Fixes involve: +- Correcting return types (e.g. `DOMNode::C14N` returning `string|false`) +- Adding `@param-out` for reference parameters (e.g. `stream_socket_client`) +- Marking functions as impure (e.g. `time()`, Redis methods) +- PHP-version-specific signatures (e.g. `bcround` only in PHP 8.4+) + +### PHP-parser name resolution and `originalName` attribute + +PHP-parser's `NameResolver` resolves names through `use` statements. When `preserveOriginalNames: true` is configured (as PHPStan does in `conf/services.neon`), the original unresolved Name node is preserved as an `originalName` attribute on the resolved `FullyQualified` node. This matters for case-sensitivity checking: when `use DateTimeImmutable;` is followed by `dateTimeImmutable` in a typehint, the resolved node has the case from the `use` statement (`DateTimeImmutable`), losing the wrong case from the source. The `originalName` attribute preserves the source-code case (`dateTimeImmutable`). Rules that check class name case (like `class.nameCase` via `ClassCaseSensitivityCheck`) must use this attribute rather than relying on `Type::getReferencedClasses()` which returns already-resolved names. The fix pattern is in `FunctionDefinitionCheck::getOriginalClassNamePairsFromTypeNode()` which extracts original-case class names from AST type nodes. + +### Impure points and side effects + +PHPStan tracks whether expressions/statements have side effects ("impure points"). This enables: +- Reporting useless calls to pure methods (`expr.resultUnused`) +- Detecting void methods with no side effects +- `@phpstan-pure` enforcement + +Bugs occur when impure points are missed (e.g. inherited constructors of anonymous classes) or when `clearstatcache()` calls don't invalidate filesystem function return types. + +### FunctionCallParametersCheck: by-reference argument validation + +`FunctionCallParametersCheck` (`src/Rules/FunctionCallParametersCheck.php`) validates arguments passed to functions/methods. For by-reference parameters, it checks whether the argument is a valid lvalue (variable, array dim fetch, property fetch). It also allows function/method calls that return by reference (`&getString()`, `&staticGetString()`, `&refFunction()`), using `returnsByReference()` on the resolved reflection. The class is manually instantiated in ~20 test files, so adding a constructor parameter requires updating all of them. The `Scope` interface provides `getMethodReflection()` for method calls, while `ReflectionProvider` (injected into the class) is needed for resolving function reflections. + +### FunctionCallParametersCheck: spread argument expansion with optional named keys + +When spreading a constant array into a function/method call (`foo(...$array)`), `FunctionCallParametersCheck::check()` (lines 139-213) expands each array position into an individual argument. For each position, it checks whether the key is optional (`getOptionalKeys()`), extracts the value type, and determines the key name. Optional keys (array positions that might not exist) are normally skipped to avoid asserting they're always present. + +However, when the optional key has a string name (named argument), skipping it causes a fallback path (lines 195-203) that loses the key-to-type correspondence. The fallback uses `getIterableValueType()` which unions ALL value types, then passes this as a single generic unpacked argument. This causes false positives when different keys have different value types — e.g., `non-empty-array{width?: int, bgColor?: string}` spread into `Foo(int|null $width, string|null $bgColor)` reports "int|string given" for `$width` because the fallback unions `int` and `string`. The fix: only skip optional keys when they don't have a named key (`$keyArgumentName === null`), so named optional keys are still expanded as individual named arguments with their correct per-key types. + ### Testing patterns - **Rule tests**: Extend `RuleTestCase`, implement `getRule()`, call `$this->analyse([__DIR__ . '/data/my-test.php'], [...expected errors...])`. Expected errors are `[message, line]` pairs. Test data files live in `tests/PHPStan/Rules/*/data/`. @@ -295,3 +411,11 @@ When adding or editing PHPDoc comments in this codebase, follow these guidelines - `react/child-process`, `react/async` - Parallel analysis - `symfony/console` - CLI interface - `hoa/compiler` - Used for regex type parsing + +### ConstantArrayType::setOffsetValueType union expansion for finite offsets + +When `ConstantArrayType::setOffsetValueType()` receives a union of constant string keys (e.g., `'a'|'b'`) or a finite `IntegerRangeType` (e.g., `int<1,5>`) and at least one key is new (not already in the array), it creates a union of constant arrays — one for each possible key — instead of degrading to a general `ArrayType`. This is controlled by `CHUNK_FINITE_TYPES_LIMIT` (5) to avoid combinatorial explosion in loops. Integer constant unions (e.g., `0|1` from loop fixpoint analysis) are excluded to prevent regression in loop analysis; only string constant unions and `IntegerRangeType` expansions are supported. The `resolveFiniteScalarKeyTypes()` helper method resolves the offset type to individual constant keys. + +### Ternary expression type narrowing in TypeSpecifier + +`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. diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 6cff7ef3af..d581dbc0b8 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -47,6 +47,7 @@ use PHPStan\Type\Traits\UndecidedComparisonTypeTrait; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeUtils; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use function array_keys; @@ -61,6 +62,7 @@ use function count; use function implode; use function in_array; +use function is_int; use function is_string; use function min; use function pow; @@ -699,6 +701,35 @@ public function getOffsetValueType(Type $offsetType): Type public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type { + if ($offsetType !== null) { + $scalarKeyTypes = $this->resolveFiniteScalarKeyTypes($offsetType); + if ($scalarKeyTypes !== null) { + $hasNewKey = false; + foreach ($scalarKeyTypes as $scalarKeyType) { + $existingKeyFound = false; + foreach ($this->keyTypes as $existingKeyType) { + if ($existingKeyType->getValue() === $scalarKeyType->getValue()) { + $existingKeyFound = true; + break; + } + } + if (!$existingKeyFound) { + $hasNewKey = true; + break; + } + } + + if ($hasNewKey) { + $arrayTypes = []; + foreach ($scalarKeyTypes as $scalarKeyType) { + $arrayTypes[] = $this->setOffsetValueType($scalarKeyType, $valueType, $unionValues); + } + + return TypeCombinator::union(...$arrayTypes); + } + } + } + $builder = ConstantArrayTypeBuilder::createFromConstantArray($this); $builder->setOffsetValueType($offsetType, $valueType); @@ -713,6 +744,67 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $builder->getArray(); } + /** + * @return list|null + */ + private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array + { + $offsetType = $offsetType->toArrayKey(); + + // Handle unions of constant string types (e.g. 'a'|'b') + $constantStrings = $offsetType->getConstantStrings(); + if (count($constantStrings) >= 2 && count($constantStrings) <= self::CHUNK_FINITE_TYPES_LIMIT) { + $result = []; + foreach ($constantStrings as $constantString) { + $arrayKeyType = $constantString->toArrayKey(); + $scalarValues = $arrayKeyType->getConstantScalarValues(); + if (count($scalarValues) !== 1) { + return null; + } + if (is_int($scalarValues[0])) { + $result[] = new ConstantIntegerType($scalarValues[0]); + } elseif (is_string($scalarValues[0])) { + $result[] = new ConstantStringType($scalarValues[0]); + } else { + return null; + } + } + return $result; + } + + // Handle integer range types (e.g. int<1,5>) + $integerRanges = TypeUtils::getIntegerRanges($offsetType); + if (count($integerRanges) > 0) { + $finiteScalarTypes = []; + foreach ($integerRanges as $integerRange) { + $finiteTypes = $integerRange->getFiniteTypes(); + if ($finiteTypes === []) { + return null; + } + + foreach ($finiteTypes as $finiteType) { + $finiteScalarTypes[] = $finiteType; + } + } + + if (count($finiteScalarTypes) < 2 || count($finiteScalarTypes) > self::CHUNK_FINITE_TYPES_LIMIT) { + return null; + } + + $result = []; + foreach ($finiteScalarTypes as $scalarType) { + $arrayKeyType = $scalarType->toArrayKey(); + if (!$arrayKeyType instanceof ConstantIntegerType) { + return null; + } + $result[] = $arrayKeyType; + } + return $result; + } + + return null; + } + public function unsetOffset(Type $offsetType): Type { $offsetType = $offsetType->toArrayKey(); diff --git a/tests/PHPStan/Analyser/nsrt/constant-array-type-set.php b/tests/PHPStan/Analyser/nsrt/constant-array-type-set.php index 9ae0b88828..0f32789b28 100644 --- a/tests/PHPStan/Analyser/nsrt/constant-array-type-set.php +++ b/tests/PHPStan/Analyser/nsrt/constant-array-type-set.php @@ -94,7 +94,7 @@ public function doBar5(int $offset): void { $a = [false, false, false]; $a[$offset] = true; - assertType('non-empty-array, bool>', $a); + assertType("array{0: false, 1: false, 2: false, 4: true}|array{false, false, false, true}|array{false, false, true}|array{false, true, false}|array{true, false, false}", $a); } public function doBar6(bool $offset): void diff --git a/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php b/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php new file mode 100644 index 0000000000..24f129c72a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php @@ -0,0 +1,50 @@ + $intRange + */ + public function doBar(array $a, $intRange): void + { + $a[$intRange] = 256; + assertType('array{foo: int, 1: 256}|array{foo: int, 2: 256}|array{foo: int, 3: 256}|array{foo: int, 4: 256}|array{foo: int, 5: 256}', $a); + } + + /** + * @param array{foo: int} $a + * @param int<0, max> $intRange + */ + public function doInfiniteRange(array $a, $intRange): void + { + $a[$intRange] = 256; + assertType('non-empty-array<\'foo\'|int<0, max>, int>', $a); + } + + /** + * @param array{0: 'a', 1: 'b'} $a + * @param int<0,1> $intRange + */ + public function doExistingKeys(array $a, $intRange): void + { + $a[$intRange] = 'c'; + assertType("array{'a'|'c', 'b'|'c'}", $a); + } + +} From d298bcc5023c7af08b055b0ba2b1eeb8b69a2e26 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 16 Feb 2026 09:54:28 +0000 Subject: [PATCH 02/24] Add regression test for #9907 Closes https://github.com/phpstan/phpstan/issues/9907 --- tests/PHPStan/Analyser/nsrt/bug-9907.php | 20 +++++++++++ .../Rules/Methods/ReturnTypeRuleTest.php | 5 +++ tests/PHPStan/Rules/Methods/data/bug-9907.php | 34 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-9907.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-9907.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-9907.php b/tests/PHPStan/Analyser/nsrt/bug-9907.php new file mode 100644 index 0000000000..0985776490 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-9907.php @@ -0,0 +1,20 @@ +analyse([__DIR__ . '/data/bug-9669.php'], []); } + public function testBug9907(): void + { + $this->analyse([__DIR__ . '/data/bug-9907.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-9907.php b/tests/PHPStan/Rules/Methods/data/bug-9907.php new file mode 100644 index 0000000000..39086dbd70 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-9907.php @@ -0,0 +1,34 @@ + + * } + */ + public function diffAddresses(array $address1, array $address2): array + { + $addressDifference = array_diff_assoc($address1, $address2); + $differenceDetails = []; + + foreach ($addressDifference as $name => $differenceValue) { + $differenceDetails[$name] = [ + 'change_to' => $differenceValue, + ]; + } + + if (!empty(count($differenceDetails))) { + $differenceDetails['variation_count'] = count($differenceDetails); + } + + return $differenceDetails; + } +} From db919282ab079e0c4f7f53cc5d921ea92a21c377 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 16 Feb 2026 09:55:01 +0000 Subject: [PATCH 03/24] Add regression test for #7978 Closes https://github.com/phpstan/phpstan/issues/7978 --- tests/PHPStan/Analyser/nsrt/bug-7978.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-7978.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-7978.php b/tests/PHPStan/Analyser/nsrt/bug-7978.php new file mode 100644 index 0000000000..7ae24f8be0 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7978.php @@ -0,0 +1,24 @@ + ['username', 'password'], + 'headers' => ['app_id', 'app_key'], + ]; + + public function doSomething(): void + { + foreach (self::FIELD_SETS as $type => $fields) { + $credentials = []; + foreach ($fields as $field) { + $credentials[$field] = 'fake'; + } + assertType("array{app_id?: 'fake', app_key?: 'fake', password?: 'fake', username?: 'fake'}", $credentials); + } + } +} From 73374f51e606bded102e737ac32b7cf5d1969037 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 16 Feb 2026 09:55:42 +0000 Subject: [PATCH 04/24] Add regression test for #2294 Closes https://github.com/phpstan/phpstan/issues/2294 --- tests/PHPStan/Analyser/nsrt/bug-2294.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-2294.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-2294.php b/tests/PHPStan/Analyser/nsrt/bug-2294.php new file mode 100644 index 0000000000..a69571d6df --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-2294.php @@ -0,0 +1,15 @@ + null, 'B' => null]; + + $entries2 = []; + foreach($entries as $key => $value) { + $entries2[$key] = ['a' => 1, 'b' => 2]; + } + assertType("array{A?: array{a: 1, b: 2}, B?: array{a: 1, b: 2}}", $entries2); +}; From 1fcb7d26552dd810a8840ba0181959b2719fcd39 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 16 Feb 2026 09:56:11 +0000 Subject: [PATCH 05/24] Add regression test for #13000 Closes https://github.com/phpstan/phpstan/issues/13000 --- tests/PHPStan/Analyser/nsrt/bug-13000.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13000.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-13000.php b/tests/PHPStan/Analyser/nsrt/bug-13000.php new file mode 100644 index 0000000000..b0138a3d9b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13000.php @@ -0,0 +1,13 @@ + '1', 'b' => '2'] as $key => $val) { + $r[$key] = $val; + } + assertType("array{a?: '1'|'2', b?: '1'|'2'}", $r); +}; From 8bc6aab773e36f601bdc7fc3c3310ab6e228e51e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 16 Feb 2026 09:56:52 +0000 Subject: [PATCH 06/24] Add regression test for #13759 Closes https://github.com/phpstan/phpstan/issues/13759 --- tests/PHPStan/Analyser/nsrt/bug-13759.php | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13759.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-13759.php b/tests/PHPStan/Analyser/nsrt/bug-13759.php new file mode 100644 index 0000000000..07482c181f --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13759.php @@ -0,0 +1,32 @@ += 8.0 + +namespace Bug13759; + +use function PHPStan\Testing\assertType; + +class Test +{ + public function scenario(): void + { + $ints = []; + foreach (['a', 'b'] as $key) { + $ints[$key] = 1; + } + $ints['c'] = 1; + + assertType("array{a?: 1, b?: 1, c: 1}", $ints); + + foreach (['a'] as $key) { + $ints[$key] = $this->intToSomething($ints[$key]); + } + + assertType("array{a: float|string, b?: 1, c: 1}", $ints); + } + + /** + * @return string|float + */ + protected function intToSomething(int $int): string|float { + return mt_rand(1, 2) ? (string)$int : (float)$int; + } +} From 289fed322bd3d9527b0429e1ab0a46b26fff2bcb Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:31:12 +0000 Subject: [PATCH 07/24] Address review comments - Remove unnecessary toArrayKey() calls in resolveFiniteScalarKeyTypes() - Use ConstantArrayTypeBuilder instead of recursion in setOffsetValueType() - Remove CLAUDE.md changes - Add test case for int<0, 5>|int<10, 15> union of integer ranges Co-authored-by: Markus Staab Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 ---- src/Type/Constant/ConstantArrayType.php | 12 ++++++------ .../set-constant-union-offset-on-constant-array.php | 10 ++++++++++ 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5990d84828..993b10f193 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -412,10 +412,6 @@ When adding or editing PHPDoc comments in this codebase, follow these guidelines - `symfony/console` - CLI interface - `hoa/compiler` - Used for regex type parsing -### ConstantArrayType::setOffsetValueType union expansion for finite offsets - -When `ConstantArrayType::setOffsetValueType()` receives a union of constant string keys (e.g., `'a'|'b'`) or a finite `IntegerRangeType` (e.g., `int<1,5>`) and at least one key is new (not already in the array), it creates a union of constant arrays — one for each possible key — instead of degrading to a general `ArrayType`. This is controlled by `CHUNK_FINITE_TYPES_LIMIT` (5) to avoid combinatorial explosion in loops. Integer constant unions (e.g., `0|1` from loop fixpoint analysis) are excluded to prevent regression in loop analysis; only string constant unions and `IntegerRangeType` expansions are supported. The `resolveFiniteScalarKeyTypes()` helper method resolves the offset type to individual constant keys. - ### Ternary expression type narrowing in TypeSpecifier `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. diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index d581dbc0b8..253823b33d 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -722,7 +722,9 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni if ($hasNewKey) { $arrayTypes = []; foreach ($scalarKeyTypes as $scalarKeyType) { - $arrayTypes[] = $this->setOffsetValueType($scalarKeyType, $valueType, $unionValues); + $builder = ConstantArrayTypeBuilder::createFromConstantArray($this); + $builder->setOffsetValueType($scalarKeyType, $valueType); + $arrayTypes[] = $builder->getArray(); } return TypeCombinator::union(...$arrayTypes); @@ -756,8 +758,7 @@ private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array if (count($constantStrings) >= 2 && count($constantStrings) <= self::CHUNK_FINITE_TYPES_LIMIT) { $result = []; foreach ($constantStrings as $constantString) { - $arrayKeyType = $constantString->toArrayKey(); - $scalarValues = $arrayKeyType->getConstantScalarValues(); + $scalarValues = $constantString->getConstantScalarValues(); if (count($scalarValues) !== 1) { return null; } @@ -793,11 +794,10 @@ private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array $result = []; foreach ($finiteScalarTypes as $scalarType) { - $arrayKeyType = $scalarType->toArrayKey(); - if (!$arrayKeyType instanceof ConstantIntegerType) { + if (!$scalarType instanceof ConstantIntegerType) { return null; } - $result[] = $arrayKeyType; + $result[] = $scalarType; } return $result; } diff --git a/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php b/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php index 24f129c72a..07115d7944 100644 --- a/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php +++ b/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php @@ -37,6 +37,16 @@ public function doInfiniteRange(array $a, $intRange): void assertType('non-empty-array<\'foo\'|int<0, max>, int>', $a); } + /** + * @param array{foo: int} $a + * @param int<0, 5>|int<10, 15> $intRange + */ + public function doUnionOfRanges(array $a, $intRange): void + { + $a[$intRange] = 256; + assertType('non-empty-array<\'foo\'|int<0, 5>|int<10, 15>, int>', $a); + } + /** * @param array{0: 'a', 1: 'b'} $a * @param int<0,1> $intRange From a02c4efcd9179609f88761ea1b842d18820314df Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 16 Feb 2026 10:38:03 +0000 Subject: [PATCH 08/24] Add regression test for #14129 Closes https://github.com/phpstan/phpstan/issues/14129 --- tests/PHPStan/Analyser/nsrt/bug-14129.php | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14129.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-14129.php b/tests/PHPStan/Analyser/nsrt/bug-14129.php new file mode 100644 index 0000000000..c044e0b0e6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14129.php @@ -0,0 +1,30 @@ + $intRange + */ + public function doBar(array $a, $intRange): void + { + $a[$intRange] = 256; + assertType('array{foo: int, 1: 256}|array{foo: int, 2: 256}|array{foo: int, 3: 256}|array{foo: int, 4: 256}|array{foo: int, 5: 256}', $a); + } + +} From 24bf9af09e152bdb570b6c145fc1d0950b5d818c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 16 Feb 2026 10:47:04 +0000 Subject: [PATCH 09/24] Fix CI failures [claude-ci-fix] Automated fix attempt 1 for CI failures. --- src/Type/Constant/ConstantArrayType.php | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 253823b33d..02647714de 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -792,14 +792,7 @@ private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array return null; } - $result = []; - foreach ($finiteScalarTypes as $scalarType) { - if (!$scalarType instanceof ConstantIntegerType) { - return null; - } - $result[] = $scalarType; - } - return $result; + return $finiteScalarTypes; } return null; From 3091c0bb5a6f99edd98e3a10d901d4687d8e44e2 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:35:54 +0000 Subject: [PATCH 10/24] Address review comments - Remove bug-14129.php (already covered by set-constant-union-offset-on-constant-array.php) - Move bug-7978.php from NSRT to CallMethodsRuleTest - Add de-duplication for overlapping integer ranges in resolveFiniteScalarKeyTypes() - Add test for overlapping ranges (int<0,3>|int<2,4>) Co-authored-by: Markus Staab --- src/Type/Constant/ConstantArrayType.php | 5 ++++ tests/PHPStan/Analyser/nsrt/bug-14129.php | 30 ------------------- ...onstant-union-offset-on-constant-array.php | 10 +++++++ .../Rules/Methods/CallMethodsRuleTest.php | 8 +++++ .../nsrt => Rules/Methods/data}/bug-7978.php | 11 +++++-- 5 files changed, 31 insertions(+), 33 deletions(-) delete mode 100644 tests/PHPStan/Analyser/nsrt/bug-14129.php rename tests/PHPStan/{Analyser/nsrt => Rules/Methods/data}/bug-7978.php (69%) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 02647714de..4b3ca1fcc5 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -777,6 +777,7 @@ private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array $integerRanges = TypeUtils::getIntegerRanges($offsetType); if (count($integerRanges) > 0) { $finiteScalarTypes = []; + $seen = []; foreach ($integerRanges as $integerRange) { $finiteTypes = $integerRange->getFiniteTypes(); if ($finiteTypes === []) { @@ -784,6 +785,10 @@ private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array } foreach ($finiteTypes as $finiteType) { + if (isset($seen[$finiteType->getValue()])) { + continue; + } + $seen[$finiteType->getValue()] = true; $finiteScalarTypes[] = $finiteType; } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14129.php b/tests/PHPStan/Analyser/nsrt/bug-14129.php deleted file mode 100644 index c044e0b0e6..0000000000 --- a/tests/PHPStan/Analyser/nsrt/bug-14129.php +++ /dev/null @@ -1,30 +0,0 @@ - $intRange - */ - public function doBar(array $a, $intRange): void - { - $a[$intRange] = 256; - assertType('array{foo: int, 1: 256}|array{foo: int, 2: 256}|array{foo: int, 3: 256}|array{foo: int, 4: 256}|array{foo: int, 5: 256}', $a); - } - -} diff --git a/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php b/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php index 07115d7944..d503ca7b40 100644 --- a/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php +++ b/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php @@ -47,6 +47,16 @@ public function doUnionOfRanges(array $a, $intRange): void assertType('non-empty-array<\'foo\'|int<0, 5>|int<10, 15>, int>', $a); } + /** + * @param array{foo: int} $a + * @param int<0, 3>|int<2, 4> $intRange + */ + public function doOverlappingRanges(array $a, $intRange): void + { + $a[$intRange] = 256; + assertType('array{foo: int, 0: 256}|array{foo: int, 1: 256}|array{foo: int, 2: 256}|array{foo: int, 3: 256}|array{foo: int, 4: 256}', $a); + } + /** * @param array{0: 'a', 1: 'b'} $a * @param int<0,1> $intRange diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 0ab14fd52a..2bb2c5c2e6 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3853,4 +3853,12 @@ public function testBug13805(): void $this->analyse([__DIR__ . '/data/bug-13805.php'], []); } + public function testBug7978(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-7978.php'], []); + } + } diff --git a/tests/PHPStan/Analyser/nsrt/bug-7978.php b/tests/PHPStan/Rules/Methods/data/bug-7978.php similarity index 69% rename from tests/PHPStan/Analyser/nsrt/bug-7978.php rename to tests/PHPStan/Rules/Methods/data/bug-7978.php index 7ae24f8be0..4b3f951835 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7978.php +++ b/tests/PHPStan/Rules/Methods/data/bug-7978.php @@ -2,8 +2,6 @@ namespace Bug7978; -use function PHPStan\Testing\assertType; - class Test { const FIELD_SETS = [ @@ -11,6 +9,13 @@ class Test { 'headers' => ['app_id', 'app_key'], ]; + /** + * @param array $credentials + */ + public function acceptCredentials(array $credentials): void + { + } + public function doSomething(): void { foreach (self::FIELD_SETS as $type => $fields) { @@ -18,7 +23,7 @@ public function doSomething(): void foreach ($fields as $field) { $credentials[$field] = 'fake'; } - assertType("array{app_id?: 'fake', app_key?: 'fake', password?: 'fake', username?: 'fake'}", $credentials); + $this->acceptCredentials($credentials); } } } From 8b21f9ef8422d0d35781316a3ccdc8d62d75b1b5 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 16 Feb 2026 12:52:00 +0100 Subject: [PATCH 11/24] Update ConstantArrayType.php --- src/Type/Constant/ConstantArrayType.php | 35 +++++++------------------ 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 4b3ca1fcc5..89e3750e0c 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -746,17 +746,14 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $builder->getArray(); } - /** - * @return list|null - */ + /** @return array|null */ private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array { - $offsetType = $offsetType->toArrayKey(); + $result = []; - // Handle unions of constant string types (e.g. 'a'|'b') + $offsetType = $offsetType->toArrayKey(); $constantStrings = $offsetType->getConstantStrings(); - if (count($constantStrings) >= 2 && count($constantStrings) <= self::CHUNK_FINITE_TYPES_LIMIT) { - $result = []; + if (count($constantStrings) > 0) { foreach ($constantStrings as $constantString) { $scalarValues = $constantString->getConstantScalarValues(); if (count($scalarValues) !== 1) { @@ -770,14 +767,8 @@ private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array return null; } } - return $result; - } - - // Handle integer range types (e.g. int<1,5>) - $integerRanges = TypeUtils::getIntegerRanges($offsetType); - if (count($integerRanges) > 0) { - $finiteScalarTypes = []; - $seen = []; + } else { + $integerRanges = TypeUtils::getIntegerRanges($offsetType); foreach ($integerRanges as $integerRange) { $finiteTypes = $integerRange->getFiniteTypes(); if ($finiteTypes === []) { @@ -785,19 +776,13 @@ private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array } foreach ($finiteTypes as $finiteType) { - if (isset($seen[$finiteType->getValue()])) { - continue; - } - $seen[$finiteType->getValue()] = true; - $finiteScalarTypes[] = $finiteType; + $result[$finiteType->getValue()] = $finiteType; } } + } - if (count($finiteScalarTypes) < 2 || count($finiteScalarTypes) > self::CHUNK_FINITE_TYPES_LIMIT) { - return null; - } - - return $finiteScalarTypes; + if (count($result) >= 2 && count($result) <= self::CHUNK_FINITE_TYPES_LIMIT) { + return $result; } return null; From 28f15074eb15efc99240676b9db035a0de4f095e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 16 Feb 2026 12:57:37 +0100 Subject: [PATCH 12/24] Update bug-13000.php --- tests/PHPStan/Analyser/nsrt/bug-13000.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13000.php b/tests/PHPStan/Analyser/nsrt/bug-13000.php index b0138a3d9b..4d15a666d6 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13000.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13000.php @@ -9,5 +9,5 @@ function (): void { foreach (['a' => '1', 'b' => '2'] as $key => $val) { $r[$key] = $val; } - assertType("array{a?: '1'|'2', b?: '1'|'2'}", $r); + assertType("array{a?: '1'|'2', b?: '1'|'2'}", $r); // could be array{a: '1', b: '2'} }; From b62a87c698069234cb82ccf61c20b3cca9883ce9 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 16 Feb 2026 13:11:06 +0100 Subject: [PATCH 13/24] simplify --- src/Type/Constant/ConstantArrayType.php | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 89e3750e0c..388d661a4a 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -62,7 +62,6 @@ use function count; use function implode; use function in_array; -use function is_int; use function is_string; use function min; use function pow; @@ -755,17 +754,7 @@ private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array $constantStrings = $offsetType->getConstantStrings(); if (count($constantStrings) > 0) { foreach ($constantStrings as $constantString) { - $scalarValues = $constantString->getConstantScalarValues(); - if (count($scalarValues) !== 1) { - return null; - } - if (is_int($scalarValues[0])) { - $result[] = new ConstantIntegerType($scalarValues[0]); - } elseif (is_string($scalarValues[0])) { - $result[] = new ConstantStringType($scalarValues[0]); - } else { - return null; - } + $result[] = $constantString; } } else { $integerRanges = TypeUtils::getIntegerRanges($offsetType); From 642ff6ce7c7a5f6cac0d055865bfdf4e6f0bcf69 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 16 Feb 2026 13:13:03 +0100 Subject: [PATCH 14/24] Discard changes to CLAUDE.md --- CLAUDE.md | 120 ------------------------------------------------------ 1 file changed, 120 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 993b10f193..5a7726f0e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -240,122 +240,6 @@ Historical analysis of `Type.php` via `git blame` shows that new methods are add When considering a bug fix that involves checking "is this type a Foo?", first check whether an appropriate method already exists on `Type`. If not, consider whether adding one would be the right fix — especially if the check is needed in more than one place or involves logic that varies by type class. -### Arrow function vs closure parameter handling parity - -`MutatingScope` has separate methods for entering arrow functions (`enterArrowFunctionWithoutReflection`) and closures (`enterAnonymousFunctionWithoutReflection`). Both iterate over parameters and assign types, but they must use the same logic for computing parameter types. In particular, both must call `getFunctionType($parameter->type, $isNullable, $parameter->variadic)` to properly handle variadic parameters (wrapping the inner type in `ArrayType`). Shortcuts like `new MixedType()` for untyped parameters skip the variadic wrapping and cause variadic args to be typed as `mixed` instead of `array`. When fixing bugs in one method, check the other for the same issue. - -### MutatingScope: expression invalidation during scope merging - -When two scopes are merged (e.g. after if/else branches), `MutatingScope::generalizeWith()` must invalidate dependent expressions. If variable `$i` changes, then `$locations[$i]` must be invalidated too. Bugs arise when stale `ExpressionTypeHolder` entries survive scope merges. Fix pattern: in `MutatingScope`, when a root expression changes, skip/invalidate all deep expressions that depend on it. - -### MutatingScope: expression invalidation after method calls and private property visibility - -When a method with side effects is called, `invalidateExpression()` invalidates tracked expression types that depend on the call target. When `$this` is invalidated, `shouldInvalidateExpression()` also matches `self`, `static`, `parent`, and class name references — so `self::$prop`, `$this->prop`, etc. all get invalidated. However, private properties of the current class cannot be modified by methods declared in a different class (parent/other). The `invalidatingClass` parameter on `invalidateExpression()` and `shouldInvalidateExpression()` enables skipping invalidation for private properties whose declaring class differs from the method's declaring class. This is checked via `isPrivatePropertyOfDifferentClass()`. The pattern mirrors the existing readonly property protection (`isReadonlyPropertyFetch`). Both `NodeScopeResolver` call sites (instance method calls at ~line 3188, static method calls at ~line 3398) pass `$methodReflection->getDeclaringClass()` as the invalidating class. - -### Closure::bind() scope leaking into argument evaluation - -`NodeScopeResolver::processArgs()` has special handling for `Closure::bind()` and `Closure::bindTo()` calls. When the first argument is a closure/arrow function literal, a `$closureBindScope` is created with `$this` rebound to the second argument's type, and this scope is used to process the closure body. However, this `$closureBindScope` must ONLY be applied when the first argument is actually an `Expr\Closure` or `Expr\ArrowFunction`. If the first argument is a general expression that returns a closure (e.g. `$this->hydrate()`), the expression itself must be evaluated in the original scope — otherwise `$this` in the expression gets incorrectly resolved as the bound object type instead of the current class. The condition at the `$scopeToPass` assignment must check the argument node type. - -### Array type tracking: SetExistingOffsetValueTypeExpr vs SetOffsetValueTypeExpr - -When assigning to an array offset, NodeScopeResolver must distinguish: -- `SetExistingOffsetValueTypeExpr` - modifying a key known to exist (preserves list type, doesn't widen the array) -- `SetOffsetValueTypeExpr` - adding a potentially new key (may break list type, widens the array) - -Misusing these leads to false positives like "might not be a list" or incorrect offset-exists checks. The fix is in `NodeScopeResolver` where property/variable assignments are processed. - -This distinction also applies in `MutatingScope::enterForeach()`. When a foreach loop iterates by reference (`foreach ($list as &$value)`), modifying `$value` changes an existing offset, not a new one. The `IntertwinedVariableByReferenceWithExpr` created for the no-key by-reference case must use `SetExistingOffsetValueTypeExpr` (not `SetOffsetValueTypeExpr`) so that `AccessoryArrayListType::setExistingOffsetValueType()` preserves the list type. Using `SetOffsetValueTypeExpr` causes `AccessoryArrayListType::setOffsetValueType()` to return `ErrorType` for non-null/non-zero offsets, destroying the list type in the intersection. - -### ConstantArrayType and offset tracking - -Many bugs involve `ConstantArrayType` (array shapes with known keys). Common issues: -- `hasOffsetValueType()` returning wrong results for expression-based offsets -- Offset types not being unioned with empty array when the offset might not exist -- `array_key_exists()` not properly narrowing to `non-empty-array` -- `OversizedArrayType` (array shapes that grew too large to track precisely) needing correct `isSuperTypeOf()` and truthiness behavior - -Fixes typically involve `ConstantArrayType`, `TypeSpecifier` (for narrowing after `array_key_exists`/`isset`), and `MutatingScope` (for tracking assignments). - -### Array literal spread operator and ConstantArrayTypeBuilder degradation - -`InitializerExprTypeResolver::getArrayType()` computes the type of array literals like `[...$a, ...$b]`. It uses `ConstantArrayTypeBuilder` to build the result type. When a spread item is a single constant array (`getConstantArrays()` returns exactly one), its key/value pairs are added individually. When it's not (e.g., `array`), the builder is degraded via `degradeToGeneralArray()`, and all subsequent items are merged into a general `ArrayType` with unioned keys and values. - -The degradation loses specific key information. To preserve it, `getArrayType()` tracks `HasOffsetValueType` accessories for non-optional keys from constant array spreads with string keys. After building, these are intersected with the degraded result. When a non-constant spread appears later that could overwrite tracked keys (its key type is a supertype of the tracked offsets), those entries are invalidated. This ensures correct handling of PHP's spread ordering semantics where later spreads override earlier ones for same-named string keys. - -### Nullsafe operator and ensureShallowNonNullability / revertNonNullability - -`NodeScopeResolver` handles `NullsafeMethodCall` and `NullsafePropertyFetch` by temporarily removing null from the variable's type (`ensureShallowNonNullability`), processing the inner expression, then restoring the original nullable type (`revertNonNullability`). When the expression is an `ArrayDimFetch` (e.g. `$arr['key']?->method()`), `specifyExpressionType` recursively narrows the parent array type via `TypeCombinator::intersect` with `HasOffsetValueType`. This intersection only narrows and cannot widen, so `revertNonNullability` fails to restore the parent array's offset type. The fix is to also save and restore the parent expression's type in `ensureShallowNonNullability`. Without this, subsequent uses of the same nullsafe call are falsely reported as "Using nullsafe method call on non-nullable type" because the parent array retains the narrowed (non-null) offset type. - -### Loop analysis: foreach, for, while - -Loops are a frequent source of false positives because PHPStan must reason about types across iterations: -- **List type preservation in for loops**: When appending to a list inside a `for` loop, the list type must be preserved if operations maintain sequential integer keys. -- **Always-overwritten arrays in foreach**: NodeScopeResolver examines `$a[$k]` at loop body end and `continue` statements. If no `break` exists, the entire array type can be rewritten based on the observed value types. -- **Variable types across iterations**: PHP Fibers are used (PHP 8.1+) for more precise analysis of repeated variable assignments in loops, by running the loop body analysis multiple times to reach a fixpoint. - -### Match expression scope merging - -Match expressions in `NodeScopeResolver` (around line 4154) process each arm and merge the resulting scopes. The critical pattern for variable certainty is: when a match is exhaustive (has a `default` arm or an always-true condition), arm body scopes should be merged only with each other (not with the original pre-match scope). This mirrors how if/else merging works — `$finalScope` starts as `null`, and each branch's scope is merged via `$branchScope->mergeWith($finalScope)`. When the match is NOT exhaustive, the original scope must also participate in the merge (via `$scope->mergeWith($armBodyFinalScope)`) because execution may skip all arms and throw `UnhandledMatchError`. The `mergeVariableHolders()` method in `MutatingScope` uses `ExpressionTypeHolder::createMaybe()` for variables present in only one scope, so merging an arm scope that defines `$x` with the original scope that lacks `$x` degrades certainty to "maybe" — this is the root cause of false "might not be defined" reports for exhaustive match expressions. - -### GenericClassStringType narrowing and tryRemove - -`GenericClassStringType` represents `class-string` where `T` is the generic object type. When the generic type is a union (e.g., `class-string`), it's a single `GenericClassStringType` with an inner `UnionType`. This is distinct from `class-string|class-string|class-string` which is a `UnionType` of individual `GenericClassStringType`s. - -The `tryRemove()` method handles removing a `ConstantStringType` (e.g., `'Car'`) from the class-string type. It must check whether the class is final — only for final classes can exact class-string removal be performed, since non-final classes could have subclasses whose class-strings would still be valid values. When the inner generic type is a union, `TypeCombinator::remove()` is used to remove the corresponding `ObjectType` from the inner union. - -This affects match expression exhaustiveness: `class-string` matched against `FinalA::class` and `FinalB::class` is exhaustive only because both classes are final. - -### StaticType::transformStaticType and ThisType downgrading - -`StaticType::transformStaticType()` is used when resolving method return types on a `StaticType` caller. It traverses the return type and transforms `StaticType`/`ThisType` instances via `changeBaseClass()`. Since `ThisType extends StaticType`, both are caught by the `$type instanceof StaticType` check. The critical invariant: when the **caller** is a `StaticType` (not `ThisType`) and the method's return type contains `ThisType`, the `ThisType` must be downgraded to a plain `StaticType`. This is because `$this` (the exact instance) cannot be guaranteed when calling on a `static` type (which could be any subclass instance). `ThisType::changeBaseClass()` returns a new `ThisType`, which preserves the `$this` semantics — so the downgrade must happen explicitly after `changeBaseClass()`. The `CallbackUnresolvedMethodPrototypeReflection` at line 91 also has special handling for `ThisType` return types intersected with `selfOutType`. - -### PHPDoc inheritance - -PHPDoc types (`@return`, `@param`, `@throws`, `@property`) are inherited through class hierarchies. Bugs arise when: -- Child methods with narrower native return types don't inherit parent PHPDoc return types -- `@property` tags on parent classes don't consider native property types on children -- Trait PHPDoc resolution uses wrong file context - -The `PhpDocInheritanceResolver` and `PhpDocBlock` classes handle this. Recent optimization: resolve through reflection instead of re-walking the hierarchy manually. - -### Dynamic return type extensions for built-in functions - -Many built-in PHP functions need `DynamicFunctionReturnTypeExtension` implementations because their return types depend on arguments: -- `array_rand()`, `array_count_values()`, `array_first()`/`array_last()`, `filter_var()`, `curl_setopt()`, DOM methods, etc. -- Extensions live in `src/Type/Php/` and are registered in `conf/services.neon` -- Each reads the argument types from `Scope::getType()` and returns a more precise `Type` - -### Function signature corrections (`src/Reflection/SignatureMap/`) - -PHPStan maintains its own signature map for built-in PHP functions in `functionMap.php` and delta files. Fixes involve: -- Correcting return types (e.g. `DOMNode::C14N` returning `string|false`) -- Adding `@param-out` for reference parameters (e.g. `stream_socket_client`) -- Marking functions as impure (e.g. `time()`, Redis methods) -- PHP-version-specific signatures (e.g. `bcround` only in PHP 8.4+) - -### PHP-parser name resolution and `originalName` attribute - -PHP-parser's `NameResolver` resolves names through `use` statements. When `preserveOriginalNames: true` is configured (as PHPStan does in `conf/services.neon`), the original unresolved Name node is preserved as an `originalName` attribute on the resolved `FullyQualified` node. This matters for case-sensitivity checking: when `use DateTimeImmutable;` is followed by `dateTimeImmutable` in a typehint, the resolved node has the case from the `use` statement (`DateTimeImmutable`), losing the wrong case from the source. The `originalName` attribute preserves the source-code case (`dateTimeImmutable`). Rules that check class name case (like `class.nameCase` via `ClassCaseSensitivityCheck`) must use this attribute rather than relying on `Type::getReferencedClasses()` which returns already-resolved names. The fix pattern is in `FunctionDefinitionCheck::getOriginalClassNamePairsFromTypeNode()` which extracts original-case class names from AST type nodes. - -### Impure points and side effects - -PHPStan tracks whether expressions/statements have side effects ("impure points"). This enables: -- Reporting useless calls to pure methods (`expr.resultUnused`) -- Detecting void methods with no side effects -- `@phpstan-pure` enforcement - -Bugs occur when impure points are missed (e.g. inherited constructors of anonymous classes) or when `clearstatcache()` calls don't invalidate filesystem function return types. - -### FunctionCallParametersCheck: by-reference argument validation - -`FunctionCallParametersCheck` (`src/Rules/FunctionCallParametersCheck.php`) validates arguments passed to functions/methods. For by-reference parameters, it checks whether the argument is a valid lvalue (variable, array dim fetch, property fetch). It also allows function/method calls that return by reference (`&getString()`, `&staticGetString()`, `&refFunction()`), using `returnsByReference()` on the resolved reflection. The class is manually instantiated in ~20 test files, so adding a constructor parameter requires updating all of them. The `Scope` interface provides `getMethodReflection()` for method calls, while `ReflectionProvider` (injected into the class) is needed for resolving function reflections. - -### FunctionCallParametersCheck: spread argument expansion with optional named keys - -When spreading a constant array into a function/method call (`foo(...$array)`), `FunctionCallParametersCheck::check()` (lines 139-213) expands each array position into an individual argument. For each position, it checks whether the key is optional (`getOptionalKeys()`), extracts the value type, and determines the key name. Optional keys (array positions that might not exist) are normally skipped to avoid asserting they're always present. - -However, when the optional key has a string name (named argument), skipping it causes a fallback path (lines 195-203) that loses the key-to-type correspondence. The fallback uses `getIterableValueType()` which unions ALL value types, then passes this as a single generic unpacked argument. This causes false positives when different keys have different value types — e.g., `non-empty-array{width?: int, bgColor?: string}` spread into `Foo(int|null $width, string|null $bgColor)` reports "int|string given" for `$width` because the fallback unions `int` and `string`. The fix: only skip optional keys when they don't have a named key (`$keyArgumentName === null`), so named optional keys are still expanded as individual named arguments with their correct per-key types. - ### Testing patterns - **Rule tests**: Extend `RuleTestCase`, implement `getRule()`, call `$this->analyse([__DIR__ . '/data/my-test.php'], [...expected errors...])`. Expected errors are `[message, line]` pairs. Test data files live in `tests/PHPStan/Rules/*/data/`. @@ -411,7 +295,3 @@ When adding or editing PHPDoc comments in this codebase, follow these guidelines - `react/child-process`, `react/async` - Parallel analysis - `symfony/console` - CLI interface - `hoa/compiler` - Used for regex type parsing - -### Ternary expression type narrowing in TypeSpecifier - -`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. From b1bb7856e2a259ed334ffd53e3d2c7380207f81e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 16 Feb 2026 13:25:23 +0100 Subject: [PATCH 15/24] Delete tests/PHPStan/Analyser/nsrt/bug-13759.php --- tests/PHPStan/Analyser/nsrt/bug-13759.php | 32 ----------------------- 1 file changed, 32 deletions(-) delete mode 100644 tests/PHPStan/Analyser/nsrt/bug-13759.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-13759.php b/tests/PHPStan/Analyser/nsrt/bug-13759.php deleted file mode 100644 index 07482c181f..0000000000 --- a/tests/PHPStan/Analyser/nsrt/bug-13759.php +++ /dev/null @@ -1,32 +0,0 @@ -= 8.0 - -namespace Bug13759; - -use function PHPStan\Testing\assertType; - -class Test -{ - public function scenario(): void - { - $ints = []; - foreach (['a', 'b'] as $key) { - $ints[$key] = 1; - } - $ints['c'] = 1; - - assertType("array{a?: 1, b?: 1, c: 1}", $ints); - - foreach (['a'] as $key) { - $ints[$key] = $this->intToSomething($ints[$key]); - } - - assertType("array{a: float|string, b?: 1, c: 1}", $ints); - } - - /** - * @return string|float - */ - protected function intToSomething(int $int): string|float { - return mt_rand(1, 2) ? (string)$int : (float)$int; - } -} From 394fcfa2c4fef6c2a3fe452ba37b7b61e5647358 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 16 Feb 2026 13:25:34 +0100 Subject: [PATCH 16/24] Delete tests/PHPStan/Analyser/nsrt/bug-13000.php --- tests/PHPStan/Analyser/nsrt/bug-13000.php | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 tests/PHPStan/Analyser/nsrt/bug-13000.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-13000.php b/tests/PHPStan/Analyser/nsrt/bug-13000.php deleted file mode 100644 index 4d15a666d6..0000000000 --- a/tests/PHPStan/Analyser/nsrt/bug-13000.php +++ /dev/null @@ -1,13 +0,0 @@ - '1', 'b' => '2'] as $key => $val) { - $r[$key] = $val; - } - assertType("array{a?: '1'|'2', b?: '1'|'2'}", $r); // could be array{a: '1', b: '2'} -}; From 72c4541439cadade5658eeaa38e2dedbcd4146ba Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 16 Feb 2026 13:29:04 +0100 Subject: [PATCH 17/24] Delete tests/PHPStan/Analyser/nsrt/bug-2294.php --- tests/PHPStan/Analyser/nsrt/bug-2294.php | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 tests/PHPStan/Analyser/nsrt/bug-2294.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-2294.php b/tests/PHPStan/Analyser/nsrt/bug-2294.php deleted file mode 100644 index a69571d6df..0000000000 --- a/tests/PHPStan/Analyser/nsrt/bug-2294.php +++ /dev/null @@ -1,15 +0,0 @@ - null, 'B' => null]; - - $entries2 = []; - foreach($entries as $key => $value) { - $entries2[$key] = ['a' => 1, 'b' => 2]; - } - assertType("array{A?: array{a: 1, b: 2}, B?: array{a: 1, b: 2}}", $entries2); -}; From b304060e1426550af802bd1dafdabf4716072c28 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 16 Feb 2026 13:31:07 +0100 Subject: [PATCH 18/24] Update ConstantArrayType.php --- src/Type/Constant/ConstantArrayType.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 388d661a4a..9fcadb4d28 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -702,7 +702,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni { if ($offsetType !== null) { $scalarKeyTypes = $this->resolveFiniteScalarKeyTypes($offsetType); - if ($scalarKeyTypes !== null) { + if ($scalarKeyTypes !== null && count($scalarKeyTypes) >= 2) { $hasNewKey = false; foreach ($scalarKeyTypes as $scalarKeyType) { $existingKeyFound = false; @@ -770,7 +770,7 @@ private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array } } - if (count($result) >= 2 && count($result) <= self::CHUNK_FINITE_TYPES_LIMIT) { + if (count($result) <= self::CHUNK_FINITE_TYPES_LIMIT) { return $result; } From 90d9ac9893c68b7bb886a013427349ea94a691b5 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 16 Feb 2026 13:32:35 +0100 Subject: [PATCH 19/24] Update ConstantArrayType.php --- src/Type/Constant/ConstantArrayType.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 9fcadb4d28..7124d4c43c 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -702,7 +702,12 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni { if ($offsetType !== null) { $scalarKeyTypes = $this->resolveFiniteScalarKeyTypes($offsetType); - if ($scalarKeyTypes !== null && count($scalarKeyTypes) >= 2) { + // turn into tagged union for more precise results + if ( + $scalarKeyTypes !== null + && count($scalarKeyTypes) >= 2 + && count($scalarKeyTypes) <= self::CHUNK_FINITE_TYPES_LIMIT + ) { $hasNewKey = false; foreach ($scalarKeyTypes as $scalarKeyType) { $existingKeyFound = false; @@ -770,11 +775,7 @@ private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array } } - if (count($result) <= self::CHUNK_FINITE_TYPES_LIMIT) { - return $result; - } - - return null; + return $result; } public function unsetOffset(Type $offsetType): Type From d07cfbc5fda227c810ed622a0dbbb9a2900e12d7 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 16 Feb 2026 13:37:24 +0100 Subject: [PATCH 20/24] use proper LIMIT --- src/Type/Constant/ConstantArrayType.php | 2 +- .../nsrt/set-constant-union-offset-on-constant-array.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 7124d4c43c..56eee638a4 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -706,7 +706,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni if ( $scalarKeyTypes !== null && count($scalarKeyTypes) >= 2 - && count($scalarKeyTypes) <= self::CHUNK_FINITE_TYPES_LIMIT + && count($scalarKeyTypes) <= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT ) { $hasNewKey = false; foreach ($scalarKeyTypes as $scalarKeyType) { diff --git a/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php b/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php index d503ca7b40..66dace8c80 100644 --- a/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php +++ b/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php @@ -44,7 +44,7 @@ public function doInfiniteRange(array $a, $intRange): void public function doUnionOfRanges(array $a, $intRange): void { $a[$intRange] = 256; - assertType('non-empty-array<\'foo\'|int<0, 5>|int<10, 15>, int>', $a); + assertType('array{foo: int, 0: 256}|array{foo: int, 10: 256}|array{foo: int, 11: 256}|array{foo: int, 12: 256}|array{foo: int, 13: 256}|array{foo: int, 14: 256}|array{foo: int, 15: 256}|array{foo: int, 1: 256}|array{foo: int, 2: 256}|array{foo: int, 3: 256}|array{foo: int, 4: 256}|array{foo: int, 5: 256}', $a); } /** From 101f110f4baa49c725ef62ade07d299d2941479a Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 16 Feb 2026 13:38:52 +0100 Subject: [PATCH 21/24] Update ConstantArrayType.php --- src/Type/Constant/ConstantArrayType.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 56eee638a4..dea816b3cc 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -706,7 +706,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni if ( $scalarKeyTypes !== null && count($scalarKeyTypes) >= 2 - && count($scalarKeyTypes) <= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT + && count($scalarKeyTypes) <= InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT ) { $hasNewKey = false; foreach ($scalarKeyTypes as $scalarKeyType) { From cdaf2f139cf236da348f01ca4684cd2157031d5a Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 16 Feb 2026 16:31:24 +0100 Subject: [PATCH 22/24] Update ConstantArrayType.php --- src/Type/Constant/ConstantArrayType.php | 53 ++++++++++--------------- 1 file changed, 21 insertions(+), 32 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index dea816b3cc..95a6dbbead 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -701,12 +701,29 @@ public function getOffsetValueType(Type $offsetType): Type public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type { if ($offsetType !== null) { - $scalarKeyTypes = $this->resolveFiniteScalarKeyTypes($offsetType); + $offsetType = $offsetType->toArrayKey(); + + $scalarKeyTypes = $offsetType->getConstantStrings(); + if (count($scalarKeyTypes) === 0) { + $integerRanges = TypeUtils::getIntegerRanges($offsetType); + if (count($integerRanges) > 0) { + foreach ($integerRanges as $integerRange) { + $finiteTypes = $integerRange->getFiniteTypes(); + if (count($finiteTypes) === 0) { + break; + } + + foreach ($finiteTypes as $finiteType) { + $scalarKeyTypes[] = $finiteType; + } + } + } + } + // turn into tagged union for more precise results if ( - $scalarKeyTypes !== null - && count($scalarKeyTypes) >= 2 - && count($scalarKeyTypes) <= InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT + count($scalarKeyTypes) >= 2 + && count($scalarKeyTypes) <= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT ) { $hasNewKey = false; foreach ($scalarKeyTypes as $scalarKeyType) { @@ -750,34 +767,6 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $builder->getArray(); } - /** @return array|null */ - private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array - { - $result = []; - - $offsetType = $offsetType->toArrayKey(); - $constantStrings = $offsetType->getConstantStrings(); - if (count($constantStrings) > 0) { - foreach ($constantStrings as $constantString) { - $result[] = $constantString; - } - } else { - $integerRanges = TypeUtils::getIntegerRanges($offsetType); - foreach ($integerRanges as $integerRange) { - $finiteTypes = $integerRange->getFiniteTypes(); - if ($finiteTypes === []) { - return null; - } - - foreach ($finiteTypes as $finiteType) { - $result[$finiteType->getValue()] = $finiteType; - } - } - } - - return $result; - } - public function unsetOffset(Type $offsetType): Type { $offsetType = $offsetType->toArrayKey(); From 6ae63c82ddc8f3c19b9be2728eda7539966562d9 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 16 Feb 2026 17:17:58 +0100 Subject: [PATCH 23/24] fix --- src/Type/Constant/ConstantArrayType.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 95a6dbbead..140f1a994c 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -723,7 +723,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni // turn into tagged union for more precise results if ( count($scalarKeyTypes) >= 2 - && count($scalarKeyTypes) <= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT + && count($scalarKeyTypes) < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT ) { $hasNewKey = false; foreach ($scalarKeyTypes as $scalarKeyType) { From b854fbfbec553d11012df2f8d9421df6dda6539d Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 16 Feb 2026 17:25:55 +0100 Subject: [PATCH 24/24] Don't overwrite $offsetType --- src/Type/Constant/ConstantArrayType.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 140f1a994c..0593049044 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -701,9 +701,7 @@ public function getOffsetValueType(Type $offsetType): Type public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type { if ($offsetType !== null) { - $offsetType = $offsetType->toArrayKey(); - - $scalarKeyTypes = $offsetType->getConstantStrings(); + $scalarKeyTypes = $offsetType->toArrayKey()->getConstantStrings(); if (count($scalarKeyTypes) === 0) { $integerRanges = TypeUtils::getIntegerRanges($offsetType); if (count($integerRanges) > 0) {