Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Two-stage collapse for oversized constant arrays
Backport of `TypeCombinator` changes from two `unsealed`-branch
commits, plus their test updates:

- `optimizeConstantArrays` now runs a stage 1 same-key-set collapse
  before falling back to the lossy generalization. Variants sharing a
  key signature `mergeWith` losslessly into a single shape; the
  per-position record structure survives, only the values widen.
- `reduceArrays` final pass collapses the loop-accumulator triangular
  variant pattern (conditional `$xs[] = …` push sites leaving behind
  list variants of progressively longer length) into a single
  `non-empty-list<unionValueType>` when their cumulative
  `countConstantArrayValueTypes` exceeds the limit. Skips when every
  list variant shares one key signature — those are stage 1's job
  (per-position precision instead of a flat fold), and on 2.1.x
  without the unsealed-branch's `getUnsealedTypes()` pre-pass the
  flat fold would otherwise pre-empt stage 1 and regress bug-7963's
  144-record literal.

Knock-on effects:

- bug-10717: language-code lookup now produces the precise union of
  every code instead of `bool|literal-string`.
- bug-13509: alert variants land on the precise union of seven
  record shapes instead of the previous `&oversized-array`
  decomposition.
- New nsrt `oversized-array-stages.php` exercises both phases:
  Phase 1 (small literal preserved as-is) and Phase 2 (eight
  conditional pushes with same-shape records — the triangular union
  of list variants pushes the count past the limit, list-collapse
  folds them into a `non-empty-list` whose value type preserves the
  per-record `(kind, value, opts)` correlation as a tagged union of
  the eight original record shapes).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
  • Loading branch information
ondrejmirtes and claude committed Apr 27, 2026
commit 9c54fe745d36e0f60483e3d538bd63ffc5006753
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -1698,7 +1698,7 @@ parameters:
-
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.'
identifier: phpstanApi.instanceofType
count: 18
count: 19
path: src/Type/TypeCombinator.php

-
Expand Down
94 changes: 94 additions & 0 deletions src/Type/TypeCombinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
use function array_values;
use function count;
use function get_class;
use function implode;
use function in_array;
use function is_int;
use function sprintf;
Expand Down Expand Up @@ -976,6 +977,53 @@ private static function optimizeConstantArrays(array $types): array
return $types;
}

// Stage 1: collapse same-key-set ConstantArrayType variants per-position
// before the (lossy) generalization below kicks in. Variants with the
// same key signature mergeWith losslessly into a single shape whose
// values at each position are the union of the variants' values, which
// drops the count while keeping the per-position structure. Without
// this, a list of N similarly-shaped records (e.g. bug-7963) hits the
// limit and the generalization decomposes every nested constant array
// into a flat `non-empty-list<unionOfAllPositionValues>`, losing the
// shape entirely.
$signatureGroups = [];
$nonConstantTypes = [];
foreach ($types as $idx => $type) {
if (!$type instanceof ConstantArrayType) {
$nonConstantTypes[$idx] = $type;
continue;
}
$signatureParts = [];
$signatureParts[] = $type->isList()->yes() ? 'L' : 'A';
foreach ($type->getKeyTypes() as $i => $keyType) {
$signatureParts[] = ($type->isOptionalKey($i) ? '?' : '!') . ($keyType instanceof ConstantIntegerType ? 'i' : 's') . $keyType->getValue();
}
$signatureGroups[implode(',', $signatureParts)][] = $type;
}
if ($signatureGroups !== []) {
$collapsed = $nonConstantTypes;
$anyMerged = false;
foreach ($signatureGroups as $group) {
if (count($group) === 1) {
$collapsed[] = $group[0];
continue;
}
$merged = $group[0];
for ($i = 1, $count = count($group); $i < $count; $i++) {
$merged = $merged->mergeWith($group[$i]);
}
$collapsed[] = $merged;
$anyMerged = true;
}
if ($anyMerged) {
$types = array_values($collapsed);
$constantArrayValuesCount = self::countConstantArrayValueTypes($types);
if ($constantArrayValuesCount <= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
return $types;
}
}
}

$results = [];
$eachIsOversized = true;
foreach ($types as $type) {
Expand Down Expand Up @@ -1208,6 +1256,52 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged
}
}

// Final pass: collapse the loop-accumulator pattern where each iteration
// produced a longer non-empty list variant. When several non-empty list
// ConstantArrayTypes survive earlier merging and together push the
// constant-array value count past the limit, fold them into a single
// non-empty-list<unionValueType> so the result stays bounded without
// going through the lossier optimizeConstantArrays generalization.
// Skip when every list variant shares one key signature — those collapse
// losslessly via the stage 1 same-key-set merge in optimizeConstantArrays
// (each position keeps its own value union), which is strictly more
// precise than this flat fold.
if ($preserveTaggedUnions && count($arraysToProcess) > 1) {
$listVariantIndices = [];
$listValueTypes = [];
$listVariants = [];
$listVariantSignatures = [];
foreach ($arraysToProcess as $idx => $arr) {
if (!$arr->isList()->yes() || !$arr->isIterableAtLeastOnce()->yes()) {
continue;
}
$listVariantIndices[] = $idx;
$listValueTypes[] = $arr->getIterableValueType();
$listVariants[] = $arr;
$signatureParts = [];
foreach ($arr->getKeyTypes() as $i => $keyType) {
$signatureParts[] = ($arr->isOptionalKey($i) ? '?' : '!') . ($keyType instanceof ConstantIntegerType ? 'i' : 's') . $keyType->getValue();
}
$listVariantSignatures[implode(',', $signatureParts)] = true;
}
if (
count($listVariantIndices) >= 2
&& count($listVariantSignatures) >= 2
&& self::countConstantArrayValueTypes($listVariants) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT
) {
$mergedValueType = self::union(...$listValueTypes);
$merged = self::intersect(
new ArrayType(new IntegerType(), $mergedValueType),
new NonEmptyArrayType(),
new AccessoryArrayListType(),
);
$newArrays[] = $merged;
foreach ($listVariantIndices as $idx) {
unset($arraysToProcess[$idx]);
}
}
}

return array_merge($newArrays, $arraysToProcess);
}

Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/bug-10717.php
Original file line number Diff line number Diff line change
Expand Up @@ -1046,7 +1046,7 @@ function test(string $code): void
if ($country === 'fo' || $country === 'Faroese' || $country === 'Føroyskt') {
// foo
} else {
assertType('(bool|(literal-string&non-falsy-string))', $country);
assertType("'al'|'am'|'az'|'ba'|'bd'|'bg'|'br'|'by'|'ca'|'cn'|'cz'|'de'|'dk'|'ee'|'eo'|'er'|'es'|'es-ca'|'es-ga'|'et'|'eus'|'fi'|'fj'|'fr'|'fr-co'|'gb'|'gb-sct'|'gb-wls'|'ge'|'gh'|'gr'|'hmn'|'hr'|'ht'|'hu'|'hw'|'id'|'ie'|'il'|'in'|'iq'|'ir'|'is'|'it'|'jp'|'ke'|'kg'|'kh'|'kr'|'kz'|'la'|'lk'|'lt'|'lu'|'lv'|'mg'|'mk'|'ml'|'mm'|'mn'|'mt'|'mw'|'my'|'ne'|'ng'|'nl'|'no'|'np'|'nz'|'pf'|'ph'|'pk'|'pl'|'pt'|'ro'|'rs'|'ru'|'rw'|'sa'|'sd'|'se'|'si'|'sk'|'so'|'th'|'tj'|'to'|'tr'|'tw'|'ua'|'ug'|'uig'|'uz'|'vn'|'ws'|'za'|'zw'", $country);
}
}

2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/bug-13509.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function alert(): ?array
return null;
}

assertType('non-empty-list<non-empty-array<literal-string&lowercase-string&non-falsy-string, int|(literal-string&non-falsy-string)|null>&oversized-array>&oversized-array', $alerts);
assertType("non-empty-list<array{message: 'Foo', details: 'bar', duration: int<1, max>|null, severity: 100}|array{message: 'Idle', duration: int<1, max>|null, severity: 23}|array{message: 'No Queue', duration: int<1, max>|null, severity: 60}|array{message: 'Not Scheduled', duration: null, severity: 25}|array{message: 'Offline', duration: int<1, max>|null, severity: 99}|array{message: 'On Break'|'On Lunch', duration: int<1, max>|null, severity: 24}|array{message: 'Running W/O Operator', duration: int<1, max>|null, severity: 75}>", $alerts);

usort($alerts, fn ($a, $b) => $b['severity'] <=> $a['severity']);

Expand Down
74 changes: 74 additions & 0 deletions tests/PHPStan/Analyser/nsrt/oversized-array-stages.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace OversizedArrayStages;

use function PHPStan\Testing\assertType;

/**
* Phase 1: small enough that no generalization is needed. The
* cumulative `countConstantArrayValueTypes` stays under
* `ARRAY_COUNT_LIMIT`, so `optimizeConstantArrays` short-circuits and
* each variant is preserved literally.
*/
function phase1Small(): array
{
$arr = [];
$arr[] = ['kind' => 'a', 'value' => 1];
$arr[] = ['kind' => 'b', 'value' => 2];
$arr[] = ['kind' => 'c', 'value' => 3];
assertType("array{array{kind: 'a', value: 1}, array{kind: 'b', value: 2}, array{kind: 'c', value: 3}}", $arr);

return $arr;
}

/**
* Phase 2: conditional `$items[] = …` pushes leave behind a triangular
* union of list variants of progressively longer length. Together
* they push `countConstantArrayValueTypes` past `ARRAY_COUNT_LIMIT`,
* which triggers the `reduceArrays` final-pass list-collapse: the
* variants fold into `non-empty-list<unionValueType>` — the
* `unionValueType` is the union of each variant's iterable value
* type, which preserves the per-record `(kind, value, opts)`
* correlation as a tagged union of the eight original record shapes.
* Without the list-collapse, `optimizeConstantArrays`'s fallback
* generalization would decompose every record into a flat
* `non-empty-array<keyUnion, valueUnion>&oversized-array`, losing
* both the per-record correlation and the sealed shape.
*/
function phase2TriangularCollapse(): array
{
$items = [];

if (rand()) {
$items[] = ['kind' => 'k1', 'value' => 1, 'opts' => ['a' => 1]];
}
if (rand()) {
$items[] = ['kind' => 'k2', 'value' => 2, 'opts' => ['a' => 2]];
}
if (rand()) {
$items[] = ['kind' => 'k3', 'value' => 3, 'opts' => ['a' => 3]];
}
if (rand()) {
$items[] = ['kind' => 'k4', 'value' => 4, 'opts' => ['a' => 4]];
}
if (rand()) {
$items[] = ['kind' => 'k5', 'value' => 5, 'opts' => ['a' => 5]];
}
if (rand()) {
$items[] = ['kind' => 'k6', 'value' => 6, 'opts' => ['a' => 6]];
}
if (rand()) {
$items[] = ['kind' => 'k7', 'value' => 7, 'opts' => ['a' => 7]];
}
if (rand()) {
$items[] = ['kind' => 'k8', 'value' => 8, 'opts' => ['a' => 8]];
}

if ($items === []) {
return [];
}

assertType("non-empty-list<array{kind: 'k1', value: 1, opts: array{a: 1}}|array{kind: 'k2', value: 2, opts: array{a: 2}}|array{kind: 'k3', value: 3, opts: array{a: 3}}|array{kind: 'k4', value: 4, opts: array{a: 4}}|array{kind: 'k5', value: 5, opts: array{a: 5}}|array{kind: 'k6', value: 6, opts: array{a: 6}}|array{kind: 'k7', value: 7, opts: array{a: 7}}|array{kind: 'k8', value: 8, opts: array{a: 8}}>", $items);

return $items;
}
Loading