From adac30e7f26217277b26183ddda213dd98b29675 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:58:18 +0000 Subject: [PATCH 1/8] Preserve constant array shape when setting union of constant scalar keys - Modified ConstantArrayTypeBuilder::setOffsetValueType() to add unmatched scalar keys as optional entries instead of degrading to a general array, when none of the union members match existing keys and the array is non-empty - New regression test in tests/PHPStan/Analyser/nsrt/bug-12665.php - New test for non-loop union offset setting in nsrt/set-constant-union-offset-on-constant-array.php - Updated array-fill-keys test expectations to reflect improved type precision Closes https://github.com/phpstan/phpstan/issues/12665 --- .../Constant/ConstantArrayTypeBuilder.php | 22 +++++++++++++++++++ .../PHPStan/Analyser/nsrt/array-fill-keys.php | 4 ++-- tests/PHPStan/Analyser/nsrt/bug-12665.php | 19 ++++++++++++++++ ...onstant-union-offset-on-constant-array.php | 20 +++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-12665.php create mode 100644 tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index ad9340252e..c901306f28 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -258,6 +258,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt } if (count($scalarTypes) > 0 && count($scalarTypes) < self::ARRAY_COUNT_LIMIT) { $match = true; + $hasMatch = false; $valueTypes = $this->valueTypes; foreach ($scalarTypes as $scalarType) { $offsetMatch = false; @@ -273,6 +274,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt } if ($offsetMatch) { + $hasMatch = true; continue; } @@ -283,6 +285,26 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt $this->valueTypes = $valueTypes; return; } + + if (!$hasMatch && count($this->keyTypes) > 0) { + foreach ($scalarTypes as $scalarType) { + $this->keyTypes[] = $scalarType; + $this->valueTypes[] = $valueType; + $this->optionalKeys[] = count($this->keyTypes) - 1; + } + + $this->isList = TrinaryLogic::createNo(); + + if ( + !$this->disableArrayDegradation + && count($this->keyTypes) > self::ARRAY_COUNT_LIMIT + ) { + $this->degradeToGeneralArray = true; + $this->oversized = true; + } + + return; + } } $this->isList = TrinaryLogic::createNo(); diff --git a/tests/PHPStan/Analyser/nsrt/array-fill-keys.php b/tests/PHPStan/Analyser/nsrt/array-fill-keys.php index 9a56ef0cfb..39d1df4b5d 100644 --- a/tests/PHPStan/Analyser/nsrt/array-fill-keys.php +++ b/tests/PHPStan/Analyser/nsrt/array-fill-keys.php @@ -54,14 +54,14 @@ function withObjectKey() : array function withUnionKeys(): void { $arr1 = ['foo', rand(0, 1) ? 'bar1' : 'bar2', 'baz']; - assertType("non-empty-array<'bar1'|'bar2'|'baz'|'foo', 'b'>", array_fill_keys($arr1, 'b')); + assertType("array{foo: 'b', bar1?: 'b', bar2?: 'b', baz: 'b'}", array_fill_keys($arr1, 'b')); $arr2 = ['foo']; if (rand(0, 1)) { $arr2[] = 'bar'; } $arr2[] = 'baz'; - assertType("non-empty-array<'bar'|'baz'|'foo', 'b'>", array_fill_keys($arr2, 'b')); + assertType("array{foo: 'b', bar?: 'b', baz?: 'b'}", array_fill_keys($arr2, 'b')); } function withOptionalKeys(): void diff --git a/tests/PHPStan/Analyser/nsrt/bug-12665.php b/tests/PHPStan/Analyser/nsrt/bug-12665.php new file mode 100644 index 0000000000..d488e450ea --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12665.php @@ -0,0 +1,19 @@ + $s]; + foreach (['b', 'c'] as $letter) { + $array[$letter] = $i; + } + assertType('array{a: string, b?: int, c?: int}', $array); + return $array; + } +} 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..15638a7bd4 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php @@ -0,0 +1,20 @@ + Date: Sat, 14 Mar 2026 09:47:02 +0000 Subject: [PATCH 2/8] Unroll foreach over small constant arrays to make keys non-optional When iterating over a constant array like ['b', 'c'] with a foreach loop, all elements are guaranteed to be iterated. The fixed-point loop analysis processes the body with the union type ('b'|'c'), which results in optional keys since only one value is set per iteration. This adds a post-loop unrolling refinement step that processes the body once per element with specific types. This correctly determines that all keys are definitely set after the loop completes, changing the result from array{a: string, b?: int, c?: int} to array{a: string, b: int, c: int}. The unrolling is limited to constant arrays with <= 8 elements, no optional keys, no break statements, and no by-reference iteration. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/NodeScopeResolver.php | 101 ++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-12665.php | 2 +- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d6c6832f85..233ac81181 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -187,6 +187,7 @@ class NodeScopeResolver private const LOOP_SCOPE_ITERATIONS = 3; private const GENERALIZE_AFTER_ITERATION = 1; + private const FOREACH_UNROLL_LIMIT = 8; /** @var array filePath(string) => bool(true) */ private array $analysedFiles = []; @@ -1383,6 +1384,106 @@ public function processStmtNode( // get types from finalScope, but don't create new variables } + if ( + $context->isTopLevel() + && $isIterableAtLeastOnce->yes() + && count($breakExitPoints) === 0 + && !$stmt->byRef + && $exprType->isConstantArray()->yes() + && $stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name) + && ($stmt->keyVar === null || ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name))) + ) { + $constantArraysForUnroll = $exprType->getConstantArrays(); + if ( + count($constantArraysForUnroll) === 1 + && count($constantArraysForUnroll[0]->getOptionalKeys()) === 0 + && ($unrollKeyCount = count($constantArraysForUnroll[0]->getKeyTypes())) > 0 + && $unrollKeyCount <= self::FOREACH_UNROLL_LIMIT + ) { + $unrolledScope = $scope; + $unrollSucceeded = true; + foreach ($constantArraysForUnroll[0]->getKeyTypes() as $i => $keyType) { + $valueType = $constantArraysForUnroll[0]->getValueTypes()[$i]; + + $iterScope = $unrolledScope->assignVariable( + $stmt->valueVar->name, + $valueType, + $valueType, + TrinaryLogic::createYes(), + ); + if ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name)) { + $iterScope = $iterScope->assignVariable( + $stmt->keyVar->name, + $keyType, + $keyType, + TrinaryLogic::createYes(), + ); + } + + $iterStorage = $storage->duplicate(); + $iterResult = $this->processStmtNodesInternal( + $stmt, $stmt->stmts, $iterScope, $iterStorage, + new NoopNodeCallback(), $context->enterDeep(), + )->filterOutLoopExitPoints(); + + $unrolledScope = $iterResult->getScope(); + foreach ($iterResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $unrolledScope = $unrolledScope->mergeWith($continueExitPoint->getScope()); + } + + if ( + count($iterResult->getExitPointsByType(Break_::class)) > 0 + || $iterResult->isAlwaysTerminating() + ) { + $unrollSucceeded = false; + break; + } + } + + if ($unrollSucceeded) { + foreach ($unrolledScope->expressionTypes as $exprString => $holder) { + if (!str_starts_with($exprString, '$') || str_contains($exprString, '[') || str_contains($exprString, '>')) { + continue; + } + if (!$holder->getCertainty()->yes()) { + continue; + } + $unrolledType = $holder->getType(); + if (!$unrolledType->isConstantArray()->yes()) { + continue; + } + if (!isset($finalScope->expressionTypes[$exprString])) { + continue; + } + $finalHolder = $finalScope->expressionTypes[$exprString]; + $finalType = $finalHolder->getType(); + if (!$finalType->isConstantArray()->yes()) { + continue; + } + + $unrolledArrays = $unrolledType->getConstantArrays(); + $finalArrays = $finalType->getConstantArrays(); + if ( + count($unrolledArrays) === 1 + && count($finalArrays) === 1 + && count($unrolledArrays[0]->getOptionalKeys()) < count($finalArrays[0]->getOptionalKeys()) + && count($unrolledArrays[0]->getKeyTypes()) === count($finalArrays[0]->getKeyTypes()) + ) { + $varName = substr($exprString, 1); + $varExpr = $holder->getExpr(); + $nativeType = $unrolledScope->getNativeType($varExpr); + $finalScope = $finalScope->assignVariable( + $varName, + $unrolledType, + $nativeType, + TrinaryLogic::createYes(), + ); + } + } + } + } + } + if (!$isIterableAtLeastOnce->no()) { $throwPoints = array_merge($throwPoints, $finalScopeResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $finalScopeResult->getImpurePoints()); diff --git a/tests/PHPStan/Analyser/nsrt/bug-12665.php b/tests/PHPStan/Analyser/nsrt/bug-12665.php index d488e450ea..5e79e60ece 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12665.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12665.php @@ -13,7 +13,7 @@ public function break(string $s, int $i): array foreach (['b', 'c'] as $letter) { $array[$letter] = $i; } - assertType('array{a: string, b?: int, c?: int}', $array); + assertType('array{a: string, b: int, c: int}', $array); return $array; } } From 0888c0d93c6dfd9f3982d50bd19beddd2e93c692 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 10:03:30 +0000 Subject: [PATCH 3/8] Revert "Unroll foreach over small constant arrays to make keys non-optional" This reverts commit 62e722d8914bc76a4ee3e19053857da0108a70a0. --- src/Analyser/NodeScopeResolver.php | 101 ---------------------- tests/PHPStan/Analyser/nsrt/bug-12665.php | 2 +- 2 files changed, 1 insertion(+), 102 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 233ac81181..d6c6832f85 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -187,7 +187,6 @@ class NodeScopeResolver private const LOOP_SCOPE_ITERATIONS = 3; private const GENERALIZE_AFTER_ITERATION = 1; - private const FOREACH_UNROLL_LIMIT = 8; /** @var array filePath(string) => bool(true) */ private array $analysedFiles = []; @@ -1384,106 +1383,6 @@ public function processStmtNode( // get types from finalScope, but don't create new variables } - if ( - $context->isTopLevel() - && $isIterableAtLeastOnce->yes() - && count($breakExitPoints) === 0 - && !$stmt->byRef - && $exprType->isConstantArray()->yes() - && $stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name) - && ($stmt->keyVar === null || ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name))) - ) { - $constantArraysForUnroll = $exprType->getConstantArrays(); - if ( - count($constantArraysForUnroll) === 1 - && count($constantArraysForUnroll[0]->getOptionalKeys()) === 0 - && ($unrollKeyCount = count($constantArraysForUnroll[0]->getKeyTypes())) > 0 - && $unrollKeyCount <= self::FOREACH_UNROLL_LIMIT - ) { - $unrolledScope = $scope; - $unrollSucceeded = true; - foreach ($constantArraysForUnroll[0]->getKeyTypes() as $i => $keyType) { - $valueType = $constantArraysForUnroll[0]->getValueTypes()[$i]; - - $iterScope = $unrolledScope->assignVariable( - $stmt->valueVar->name, - $valueType, - $valueType, - TrinaryLogic::createYes(), - ); - if ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name)) { - $iterScope = $iterScope->assignVariable( - $stmt->keyVar->name, - $keyType, - $keyType, - TrinaryLogic::createYes(), - ); - } - - $iterStorage = $storage->duplicate(); - $iterResult = $this->processStmtNodesInternal( - $stmt, $stmt->stmts, $iterScope, $iterStorage, - new NoopNodeCallback(), $context->enterDeep(), - )->filterOutLoopExitPoints(); - - $unrolledScope = $iterResult->getScope(); - foreach ($iterResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $unrolledScope = $unrolledScope->mergeWith($continueExitPoint->getScope()); - } - - if ( - count($iterResult->getExitPointsByType(Break_::class)) > 0 - || $iterResult->isAlwaysTerminating() - ) { - $unrollSucceeded = false; - break; - } - } - - if ($unrollSucceeded) { - foreach ($unrolledScope->expressionTypes as $exprString => $holder) { - if (!str_starts_with($exprString, '$') || str_contains($exprString, '[') || str_contains($exprString, '>')) { - continue; - } - if (!$holder->getCertainty()->yes()) { - continue; - } - $unrolledType = $holder->getType(); - if (!$unrolledType->isConstantArray()->yes()) { - continue; - } - if (!isset($finalScope->expressionTypes[$exprString])) { - continue; - } - $finalHolder = $finalScope->expressionTypes[$exprString]; - $finalType = $finalHolder->getType(); - if (!$finalType->isConstantArray()->yes()) { - continue; - } - - $unrolledArrays = $unrolledType->getConstantArrays(); - $finalArrays = $finalType->getConstantArrays(); - if ( - count($unrolledArrays) === 1 - && count($finalArrays) === 1 - && count($unrolledArrays[0]->getOptionalKeys()) < count($finalArrays[0]->getOptionalKeys()) - && count($unrolledArrays[0]->getKeyTypes()) === count($finalArrays[0]->getKeyTypes()) - ) { - $varName = substr($exprString, 1); - $varExpr = $holder->getExpr(); - $nativeType = $unrolledScope->getNativeType($varExpr); - $finalScope = $finalScope->assignVariable( - $varName, - $unrolledType, - $nativeType, - TrinaryLogic::createYes(), - ); - } - } - } - } - } - if (!$isIterableAtLeastOnce->no()) { $throwPoints = array_merge($throwPoints, $finalScopeResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $finalScopeResult->getImpurePoints()); diff --git a/tests/PHPStan/Analyser/nsrt/bug-12665.php b/tests/PHPStan/Analyser/nsrt/bug-12665.php index 5e79e60ece..d488e450ea 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12665.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12665.php @@ -13,7 +13,7 @@ public function break(string $s, int $i): array foreach (['b', 'c'] as $letter) { $array[$letter] = $i; } - assertType('array{a: string, b: int, c: int}', $array); + assertType('array{a: string, b?: int, c?: int}', $array); return $array; } } From 342f5b431cf4c2ee9ad919be6ba126d3b11191fd Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 10:27:43 +0000 Subject: [PATCH 4/8] Add regression tests for related issues fixed by constant array shape preservation Add non-regression tests for: - phpstan/phpstan#14080: array key type preserved through foreach loop - phpstan/phpstan#13623: nested array with dynamic keys and ??= operator - phpstan/phpstan#8774: constant string keys not overwritten by loop - phpstan/phpstan#9907: array shape preserved with union key parameter - phpstan/phpstan#11006: array shape preserved with nullable union key Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-11006.php | 44 +++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-13623.php | 26 ++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-14080.php | 28 +++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-8774.php | 28 +++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-9907.php | 18 ++++++++++ 5 files changed, 144 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-11006.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13623.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14080.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-8774.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-9907.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-11006.php b/tests/PHPStan/Analyser/nsrt/bug-11006.php new file mode 100644 index 0000000000..e4df8aa720 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11006.php @@ -0,0 +1,44 @@ + [ + new StringOrNullAttributeDto(''), + ], + $sizeAttributeCode => [ + new StringOrNullAttributeDto(''), + ], + 'osa_sizes' => [ + new StringAttributeDto(''), + ], + ]; + + assertType("array{ean: array{Bug11006\StringOrNullAttributeDto}, ''?: array{Bug11006\StringOrNullAttributeDto}, size_uk?: array{Bug11006\StringOrNullAttributeDto}, size_us?: array{Bug11006\StringOrNullAttributeDto}, osa_sizes: array{Bug11006\StringAttributeDto}}", $values); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-13623.php b/tests/PHPStan/Analyser/nsrt/bug-13623.php new file mode 100644 index 0000000000..6e069efb13 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13623.php @@ -0,0 +1,26 @@ + $results + */ +function doFoo(array $results): void +{ + $customers = []; + + foreach ($results as $row) { + $customers[$row['customer_id']] ??= []; + $customers[$row['customer_id']]['orders'] ??= []; + $customers[$row['customer_id']]['orders'][$row['order_id']] ??= []; + + $customers[$row['customer_id']]['orders'][$row['order_id']]['balance_forward'] ??= 0; + $customers[$row['customer_id']]['orders'][$row['order_id']]['new_invoice'] ??= 0; + $customers[$row['customer_id']]['orders'][$row['order_id']]['payments'] ??= 0; + $customers[$row['customer_id']]['orders'][$row['order_id']]['balance'] ??= $row['order_total']; + } + + assertType('array}>', $customers); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14080.php b/tests/PHPStan/Analyser/nsrt/bug-14080.php new file mode 100644 index 0000000000..2e6fd38de3 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14080.php @@ -0,0 +1,28 @@ + $queries + */ + public function doFoo(array $queries): void + { + $queryTotals = ['all' => 0, 'duplicates' => 0]; + $queryTypes = ['select', 'update', 'delete', 'insert']; + + $queryTotals['time'] = array_sum(array_column($queries, 'time')); + + foreach ($queryTypes as $type) { + assertType('int', $queryTotals['time']); + $tq = array_filter($queries, fn ($v) => str_starts_with(strtolower($v['sql']), $type)); + $queryTotals['all'] += count($tq); + $queryTotals[$type] = [ + 'count' => count($tq), + ]; + } + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-8774.php b/tests/PHPStan/Analyser/nsrt/bug-8774.php new file mode 100644 index 0000000000..af5d487fcf --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-8774.php @@ -0,0 +1,28 @@ + 'Posting on forum and comments', + 'DisableAvatar' => 'Avatar and Custom Icon', + ]; + + public static function handleModerate(): void + { + $summaryTemplates = [ + 'PermissionID' => "Class changed from '%s' to '%s'.", + 'Reset' => '%s reset.', + ]; + + foreach (self::DISABLE_KEYS_AND_LABELS as $key => $label) { + $summaryTemplates[$key] = "Disable $label status %s."; + } + + assertType("array{PermissionID: 'Class changed from \\'%s\\' to \\'%s\\'.', Reset: '%s reset.', DisablePosting?: 'Disable Avatar and Custom Icon status %s.'|'Disable Posting on forum and comments status %s.', DisableAvatar?: 'Disable Avatar and Custom Icon status %s.'|'Disable Posting on forum and comments status %s.'}", $summaryTemplates); + assertType("'%s reset.'", $summaryTemplates['Reset']); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-9907.php b/tests/PHPStan/Analyser/nsrt/bug-9907.php new file mode 100644 index 0000000000..f1bf25bc09 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-9907.php @@ -0,0 +1,18 @@ + null, $key => 'string']; + + assertType("array{id: null, foo?: 'string', bar?: 'string'}", $a); + } +} From 5d9315dfad5d42cbf85a81f4999630e1de66535f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 10:41:33 +0000 Subject: [PATCH 5/8] Revert "Add regression tests for related issues fixed by constant array shape preservation" This reverts commit 342f5b431cf4c2ee9ad919be6ba126d3b11191fd. --- tests/PHPStan/Analyser/nsrt/bug-11006.php | 44 ----------------------- tests/PHPStan/Analyser/nsrt/bug-13623.php | 26 -------------- tests/PHPStan/Analyser/nsrt/bug-14080.php | 28 --------------- tests/PHPStan/Analyser/nsrt/bug-8774.php | 28 --------------- tests/PHPStan/Analyser/nsrt/bug-9907.php | 18 ---------- 5 files changed, 144 deletions(-) delete mode 100644 tests/PHPStan/Analyser/nsrt/bug-11006.php delete mode 100644 tests/PHPStan/Analyser/nsrt/bug-13623.php delete mode 100644 tests/PHPStan/Analyser/nsrt/bug-14080.php delete mode 100644 tests/PHPStan/Analyser/nsrt/bug-8774.php delete mode 100644 tests/PHPStan/Analyser/nsrt/bug-9907.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-11006.php b/tests/PHPStan/Analyser/nsrt/bug-11006.php deleted file mode 100644 index e4df8aa720..0000000000 --- a/tests/PHPStan/Analyser/nsrt/bug-11006.php +++ /dev/null @@ -1,44 +0,0 @@ - [ - new StringOrNullAttributeDto(''), - ], - $sizeAttributeCode => [ - new StringOrNullAttributeDto(''), - ], - 'osa_sizes' => [ - new StringAttributeDto(''), - ], - ]; - - assertType("array{ean: array{Bug11006\StringOrNullAttributeDto}, ''?: array{Bug11006\StringOrNullAttributeDto}, size_uk?: array{Bug11006\StringOrNullAttributeDto}, size_us?: array{Bug11006\StringOrNullAttributeDto}, osa_sizes: array{Bug11006\StringAttributeDto}}", $values); - } -} diff --git a/tests/PHPStan/Analyser/nsrt/bug-13623.php b/tests/PHPStan/Analyser/nsrt/bug-13623.php deleted file mode 100644 index 6e069efb13..0000000000 --- a/tests/PHPStan/Analyser/nsrt/bug-13623.php +++ /dev/null @@ -1,26 +0,0 @@ - $results - */ -function doFoo(array $results): void -{ - $customers = []; - - foreach ($results as $row) { - $customers[$row['customer_id']] ??= []; - $customers[$row['customer_id']]['orders'] ??= []; - $customers[$row['customer_id']]['orders'][$row['order_id']] ??= []; - - $customers[$row['customer_id']]['orders'][$row['order_id']]['balance_forward'] ??= 0; - $customers[$row['customer_id']]['orders'][$row['order_id']]['new_invoice'] ??= 0; - $customers[$row['customer_id']]['orders'][$row['order_id']]['payments'] ??= 0; - $customers[$row['customer_id']]['orders'][$row['order_id']]['balance'] ??= $row['order_total']; - } - - assertType('array}>', $customers); -} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14080.php b/tests/PHPStan/Analyser/nsrt/bug-14080.php deleted file mode 100644 index 2e6fd38de3..0000000000 --- a/tests/PHPStan/Analyser/nsrt/bug-14080.php +++ /dev/null @@ -1,28 +0,0 @@ - $queries - */ - public function doFoo(array $queries): void - { - $queryTotals = ['all' => 0, 'duplicates' => 0]; - $queryTypes = ['select', 'update', 'delete', 'insert']; - - $queryTotals['time'] = array_sum(array_column($queries, 'time')); - - foreach ($queryTypes as $type) { - assertType('int', $queryTotals['time']); - $tq = array_filter($queries, fn ($v) => str_starts_with(strtolower($v['sql']), $type)); - $queryTotals['all'] += count($tq); - $queryTotals[$type] = [ - 'count' => count($tq), - ]; - } - } -} diff --git a/tests/PHPStan/Analyser/nsrt/bug-8774.php b/tests/PHPStan/Analyser/nsrt/bug-8774.php deleted file mode 100644 index af5d487fcf..0000000000 --- a/tests/PHPStan/Analyser/nsrt/bug-8774.php +++ /dev/null @@ -1,28 +0,0 @@ - 'Posting on forum and comments', - 'DisableAvatar' => 'Avatar and Custom Icon', - ]; - - public static function handleModerate(): void - { - $summaryTemplates = [ - 'PermissionID' => "Class changed from '%s' to '%s'.", - 'Reset' => '%s reset.', - ]; - - foreach (self::DISABLE_KEYS_AND_LABELS as $key => $label) { - $summaryTemplates[$key] = "Disable $label status %s."; - } - - assertType("array{PermissionID: 'Class changed from \\'%s\\' to \\'%s\\'.', Reset: '%s reset.', DisablePosting?: 'Disable Avatar and Custom Icon status %s.'|'Disable Posting on forum and comments status %s.', DisableAvatar?: 'Disable Avatar and Custom Icon status %s.'|'Disable Posting on forum and comments status %s.'}", $summaryTemplates); - assertType("'%s reset.'", $summaryTemplates['Reset']); - } -} diff --git a/tests/PHPStan/Analyser/nsrt/bug-9907.php b/tests/PHPStan/Analyser/nsrt/bug-9907.php deleted file mode 100644 index f1bf25bc09..0000000000 --- a/tests/PHPStan/Analyser/nsrt/bug-9907.php +++ /dev/null @@ -1,18 +0,0 @@ - null, $key => 'string']; - - assertType("array{id: null, foo?: 'string', bar?: 'string'}", $a); - } -} From 9696f459afdd08f801f1c4edf3a14615319f5a48 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 10:47:56 +0000 Subject: [PATCH 6/8] Add regression tests for related issues fixed by constant array shape preservation - Bug 14080: Rule test ensuring no binary operation error on += with preserved array shape - Bug 13623: Rule test for ??= with nested dynamic-key arrays (documents remaining errors) - Bug 8774: Rule test ensuring no sprintf placeholder mismatch with dynamic array keys - Bug 9907: NSRT test for array shape preservation with union key parameter - Bug 11006: Rule test ensuring no constructor parameter type mismatch with union key Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-9907.php | 18 +++++ .../Rules/Classes/InstantiationRuleTest.php | 5 ++ .../PHPStan/Rules/Classes/data/bug-11006.php | 66 +++++++++++++++++++ .../Functions/PrintfParametersRuleTest.php | 5 ++ .../PHPStan/Rules/Functions/data/bug-8774.php | 25 +++++++ .../InvalidBinaryOperationRuleTest.php | 5 ++ .../Rules/Operators/data/bug-14080.php | 25 +++++++ .../Rules/Variables/NullCoalesceRuleTest.php | 18 +++++ .../Rules/Variables/data/bug-13623.php | 22 +++++++ 9 files changed, 189 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-9907.php create mode 100644 tests/PHPStan/Rules/Classes/data/bug-11006.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-8774.php create mode 100644 tests/PHPStan/Rules/Operators/data/bug-14080.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-13623.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..f1bf25bc09 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-9907.php @@ -0,0 +1,18 @@ + null, $key => 'string']; + + assertType("array{id: null, foo?: 'string', bar?: 'string'}", $a); + } +} diff --git a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index f96ec73dcb..2118321ea5 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -609,4 +609,9 @@ public function testBug14251(): void ]); } + public function testBug11006(): void + { + $this->analyse([__DIR__ . '/data/bug-11006.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Classes/data/bug-11006.php b/tests/PHPStan/Rules/Classes/data/bug-11006.php new file mode 100644 index 0000000000..c600eb826f --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/bug-11006.php @@ -0,0 +1,66 @@ +, + * osa_sizes?: array, + * size_uk?: array, + * size_us?: array, + * } $values + */ + public function __construct( + public array $values, + ) { + } +} + +class ProductParentPayloadDto +{ + /** + * @param 'size_uk'|'size_us' $SizeAttributeCode + */ + public function __construct( + public string $SizeAttributeCode, + ) { + } +} + +class PhpStanProblem +{ + public function example(ProductParentPayloadDto $parentPayload): void + { + $values = [ + 'ean' => [ + new StringOrNullAttributeDto(''), + ], + $parentPayload->SizeAttributeCode => [ + new StringOrNullAttributeDto(''), + ], + 'osa_sizes' => [ + new StringAttributeDto(''), + ], + ]; + + new AkeneoUpdateProductDto($values); + } +} diff --git a/tests/PHPStan/Rules/Functions/PrintfParametersRuleTest.php b/tests/PHPStan/Rules/Functions/PrintfParametersRuleTest.php index 9f6af54439..c4e5f101a3 100644 --- a/tests/PHPStan/Rules/Functions/PrintfParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/PrintfParametersRuleTest.php @@ -137,4 +137,9 @@ public function testBug1889(): void ]); } + public function testBug8774(): void + { + $this->analyse([__DIR__ . '/data/bug-8774.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-8774.php b/tests/PHPStan/Rules/Functions/data/bug-8774.php new file mode 100644 index 0000000000..5b5c68f8e3 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-8774.php @@ -0,0 +1,25 @@ + 'Posting on forum and comments', + 'DisableAvatar' => 'Avatar and Custom Icon', + ]; + + public static function handleModerate(): void + { + $summaryTemplates = [ + 'PermissionID' => "Class changed from '%s' to '%s'.", + 'Reset' => '%s reset.', + ]; + + foreach (self::DISABLE_KEYS_AND_LABELS as $key => $label) { + $summaryTemplates[$key] = "Disable $label status %s."; + } + + echo sprintf($summaryTemplates['Reset'], 'foo'); + } +} diff --git a/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php b/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php index fb1b119745..605590d721 100644 --- a/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php +++ b/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php @@ -821,4 +821,9 @@ public function testBug10595(): void $this->analyse([__DIR__ . '/data/bug-10595.php'], []); } + public function testBug14080(): void + { + $this->analyse([__DIR__ . '/data/bug-14080.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Operators/data/bug-14080.php b/tests/PHPStan/Rules/Operators/data/bug-14080.php new file mode 100644 index 0000000000..b786d0aed5 --- /dev/null +++ b/tests/PHPStan/Rules/Operators/data/bug-14080.php @@ -0,0 +1,25 @@ + $queries + */ + public function doFoo(array $queries): void + { + $queryTotals = ['all' => 0, 'duplicates' => 0]; + $queryTypes = ['select', 'update', 'delete', 'insert']; + + $queryTotals['time'] = array_sum(array_column($queries, 'time')); + + foreach ($queryTypes as $type) { + $tq = array_filter($queries, fn ($v) => str_starts_with(strtolower($v['sql']), $type)); + $queryTotals['all'] += count($tq); + $queryTotals[$type] = [ + 'count' => count($tq), + ]; + } + } +} diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index 7a92e20046..4ba8090ffc 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -372,4 +372,22 @@ public function testBug13921(): void ]); } + public function testBug13623(): void + { + $this->analyse([__DIR__ . '/data/bug-13623.php'], [ + [ + 'Offset \'new_invoice\' on array{balance_forward: 0, new_invoice: 0, payments: 0, balance: float} on left side of ??= always exists and is not nullable.', + 18, + ], + [ + 'Offset \'payments\' on array{balance_forward: 0, new_invoice: 0, payments: 0, balance: float} on left side of ??= always exists and is not nullable.', + 19, + ], + [ + 'Offset \'balance\' on array{balance_forward: 0, new_invoice: 0, payments: 0, balance: float} on left side of ??= always exists and is not nullable.', + 20, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-13623.php b/tests/PHPStan/Rules/Variables/data/bug-13623.php new file mode 100644 index 0000000000..c100ee4c31 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-13623.php @@ -0,0 +1,22 @@ + $results + */ +function doFoo(array $results): void +{ + $customers = []; + + foreach ($results as $row) { + $customers[$row['customer_id']] ??= []; + $customers[$row['customer_id']]['orders'] ??= []; + $customers[$row['customer_id']]['orders'][$row['order_id']] ??= []; + + $customers[$row['customer_id']]['orders'][$row['order_id']]['balance_forward'] ??= 0; + $customers[$row['customer_id']]['orders'][$row['order_id']]['new_invoice'] ??= 0; + $customers[$row['customer_id']]['orders'][$row['order_id']]['payments'] ??= 0; + $customers[$row['customer_id']]['orders'][$row['order_id']]['balance'] ??= $row['order_total']; + } +} From f75ec2dd824c213bb2f6327dc40cbd11ffd20d41 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 14 Mar 2026 12:02:41 +0100 Subject: [PATCH 7/8] Fix --- .../PHPStan/Rules/Classes/data/bug-11006.php | 46 +++++++++++-------- .../Rules/Operators/data/bug-14080.php | 30 +++++------- .../Rules/Variables/NullCoalesceRuleTest.php | 18 -------- .../Rules/Variables/data/bug-13623.php | 22 --------- 4 files changed, 39 insertions(+), 77 deletions(-) delete mode 100644 tests/PHPStan/Rules/Variables/data/bug-13623.php diff --git a/tests/PHPStan/Rules/Classes/data/bug-11006.php b/tests/PHPStan/Rules/Classes/data/bug-11006.php index c600eb826f..ae20fb42e3 100644 --- a/tests/PHPStan/Rules/Classes/data/bug-11006.php +++ b/tests/PHPStan/Rules/Classes/data/bug-11006.php @@ -1,19 +1,14 @@ -= 8.0 -namespace Bug11006; +declare(strict_types = 1); -class StringOrNullAttributeDto -{ - public function __construct( - public ?string $data, - ) { - } -} +namespace Bug11006; -class StringAttributeDto +class ProductParentPayloadDto { + /** @param null|'size_uk'|'size_us' $SizeAttributeCode */ public function __construct( - public string $data, + public ?string $SizeAttributeCode, ) { } } @@ -34,33 +29,46 @@ public function __construct( } } -class ProductParentPayloadDto +class StringOrNullAttributeDto { - /** - * @param 'size_uk'|'size_us' $SizeAttributeCode - */ public function __construct( - public string $SizeAttributeCode, + public ?string $data, ) { } } +class StringAttributeDto +{ + public function __construct( + public string $data, + ) { + } +} + + class PhpStanProblem { - public function example(ProductParentPayloadDto $parentPayload): void + public function example(ProductParentPayloadDto $productParentPayloadDto): void { + if (null === $productParentPayloadDto->SizeAttributeCode) { + return; + } + $values = [ 'ean' => [ new StringOrNullAttributeDto(''), ], - $parentPayload->SizeAttributeCode => [ + $productParentPayloadDto->SizeAttributeCode => [ new StringOrNullAttributeDto(''), ], + // This part goes wrong 'osa_sizes' => [ new StringAttributeDto(''), ], ]; - new AkeneoUpdateProductDto($values); + $productData = new AkeneoUpdateProductDto( + values: $values, + ); } } diff --git a/tests/PHPStan/Rules/Operators/data/bug-14080.php b/tests/PHPStan/Rules/Operators/data/bug-14080.php index b786d0aed5..d15875d35e 100644 --- a/tests/PHPStan/Rules/Operators/data/bug-14080.php +++ b/tests/PHPStan/Rules/Operators/data/bug-14080.php @@ -2,24 +2,18 @@ namespace Bug14080; -class Foo -{ - /** - * @param list $queries - */ - public function doFoo(array $queries): void - { - $queryTotals = ['all' => 0, 'duplicates' => 0]; - $queryTypes = ['select', 'update', 'delete', 'insert']; +$queryTotals = ['all' => 0, 'duplicates' => 0]; +$queryTypes = ['select', 'update', 'delete', 'insert']; +$queries = [['sql' => 'select', 'time' => 8234], ['sql' => 'select', 'time' => 4558], ['sql' => 'insert', 'time' => 9928]]; - $queryTotals['time'] = array_sum(array_column($queries, 'time')); +$queryTotals['time'] = array_sum(array_column($queries, 'time')); - foreach ($queryTypes as $type) { - $tq = array_filter($queries, fn ($v) => str_starts_with(strtolower($v['sql']), $type)); - $queryTotals['all'] += count($tq); - $queryTotals[$type] = [ - 'count' => count($tq), - ]; - } - } +foreach ($queryTypes as $type) { + $tq = array_filter($queries, fn ($v) => str_starts_with(strtolower($v['sql']), $type)); + $tq_time = array_sum(array_column($tq, 'time')); + $queryTotals['all'] += count($tq); + $queryTotals[$type] = [ + 'count' => count($tq), + 'time' => $tq_time / $queryTotals['time'] * 100, + ]; } diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index 4ba8090ffc..7a92e20046 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -372,22 +372,4 @@ public function testBug13921(): void ]); } - public function testBug13623(): void - { - $this->analyse([__DIR__ . '/data/bug-13623.php'], [ - [ - 'Offset \'new_invoice\' on array{balance_forward: 0, new_invoice: 0, payments: 0, balance: float} on left side of ??= always exists and is not nullable.', - 18, - ], - [ - 'Offset \'payments\' on array{balance_forward: 0, new_invoice: 0, payments: 0, balance: float} on left side of ??= always exists and is not nullable.', - 19, - ], - [ - 'Offset \'balance\' on array{balance_forward: 0, new_invoice: 0, payments: 0, balance: float} on left side of ??= always exists and is not nullable.', - 20, - ], - ]); - } - } diff --git a/tests/PHPStan/Rules/Variables/data/bug-13623.php b/tests/PHPStan/Rules/Variables/data/bug-13623.php deleted file mode 100644 index c100ee4c31..0000000000 --- a/tests/PHPStan/Rules/Variables/data/bug-13623.php +++ /dev/null @@ -1,22 +0,0 @@ - $results - */ -function doFoo(array $results): void -{ - $customers = []; - - foreach ($results as $row) { - $customers[$row['customer_id']] ??= []; - $customers[$row['customer_id']]['orders'] ??= []; - $customers[$row['customer_id']]['orders'][$row['order_id']] ??= []; - - $customers[$row['customer_id']]['orders'][$row['order_id']]['balance_forward'] ??= 0; - $customers[$row['customer_id']]['orders'][$row['order_id']]['new_invoice'] ??= 0; - $customers[$row['customer_id']]['orders'][$row['order_id']]['payments'] ??= 0; - $customers[$row['customer_id']]['orders'][$row['order_id']]['balance'] ??= $row['order_total']; - } -} From b75eea14e5e7dccc76d813c4e57463fcb43006d6 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 14 Mar 2026 13:45:05 +0100 Subject: [PATCH 8/8] Fix --- tests/PHPStan/Rules/Classes/InstantiationRuleTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index 2118321ea5..8558296e14 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -609,6 +609,7 @@ public function testBug14251(): void ]); } + #[RequiresPhp('>= 8.0')] public function testBug11006(): void { $this->analyse([__DIR__ . '/data/bug-11006.php'], []);