Skip to content

Commit 047ac4e

Browse files
committed
Preserve precision across trailing shapes
Combine required string-keyed shapes after a generic string array while preserving first insertion order and last-write value types. Keep unsupported inputs on the broad fallback and avoid builder degradation for large shapes.
1 parent 1f587ed commit 047ac4e

4 files changed

Lines changed: 274 additions & 21 deletions

File tree

src/ArrayMergeType.php

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ protected function getResult(): Type
287287
}
288288
}
289289

290-
$unsealedShape = self::mergeGenericStringArrayWithConstantShape($types);
290+
$unsealedShape = self::mergeGenericStringArrayWithConstantShapes($types);
291291
if (null !== $unsealedShape) {
292292
return $unsealedShape;
293293
}
@@ -367,55 +367,86 @@ protected function getResult(): Type
367367
/**
368368
* @param non-empty-list<Type> $types
369369
*/
370-
private static function mergeGenericStringArrayWithConstantShape(array $types): ?Type
370+
private static function mergeGenericStringArrayWithConstantShapes(array $types): ?Type
371371
{
372-
if (2 !== count($types)) {
372+
if (count($types) < 2) {
373373
return null;
374374
}
375375

376376
$genericArrays = $types[0]->getArrays();
377-
$constantArrays = $types[1]->getConstantArrays();
378377

379378
if (
380379
1 !== count($genericArrays)
381380
|| !$types[0]->equals($genericArrays[0])
382-
|| 1 !== count($constantArrays)
383-
|| !$types[1]->equals($constantArrays[0])
384381
) {
385382
return null;
386383
}
387384

388385
$genericArray = $genericArrays[0];
389-
$constantArray = $constantArrays[0];
390386

391-
if (
392-
!$genericArray->getKeyType()->equals(new StringType())
393-
|| self::hasUnknownExtraOffsets($constantArray)
394-
|| [] === $constantArray->getKeyTypes()
395-
|| [0] !== $constantArray->getNextAutoIndexes()
396-
) {
387+
if (!$genericArray->getKeyType()->equals(new StringType())) {
397388
return null;
398389
}
399390

400-
foreach ($constantArray->getKeyTypes() as $i => $keyType) {
401-
$constantStrings = $keyType->getConstantStrings();
391+
$keyTypes = [];
392+
$valueTypes = [];
393+
/** @var array<string, int> $keyIndexes */
394+
$keyIndexes = [];
395+
396+
for ($typeIndex = 1, $typeCount = count($types); $typeIndex < $typeCount; $typeIndex++) {
397+
$operandConstantArrays = $types[$typeIndex]->getConstantArrays();
398+
399+
if (
400+
1 !== count($operandConstantArrays)
401+
|| !$types[$typeIndex]->equals($operandConstantArrays[0])
402+
) {
403+
return null;
404+
}
405+
406+
$constantArray = $operandConstantArrays[0];
402407

403408
if (
404-
1 !== count($constantStrings)
405-
|| !$keyType->equals($constantStrings[0])
406-
|| !$keyType->equals(self::normalizeArrayMergeKeyType($keyType))
407-
|| $constantArray->isOptionalKey($i)
409+
self::hasUnknownExtraOffsets($constantArray)
410+
|| [] === $constantArray->getKeyTypes()
411+
|| [0] !== $constantArray->getNextAutoIndexes()
408412
) {
409413
return null;
410414
}
415+
416+
foreach ($constantArray->getKeyTypes() as $i => $keyType) {
417+
$constantStrings = $keyType->getConstantStrings();
418+
419+
if (
420+
1 !== count($constantStrings)
421+
|| !$keyType->equals($constantStrings[0])
422+
|| !$keyType->equals(self::normalizeArrayMergeKeyType($keyType))
423+
|| $constantArray->isOptionalKey($i)
424+
) {
425+
return null;
426+
}
427+
428+
$keyValue = $constantStrings[0]->getValue();
429+
if (isset($keyIndexes[$keyValue])) {
430+
$valueTypes[$keyIndexes[$keyValue]] = $constantArray->getValueTypes()[$i];
431+
continue;
432+
}
433+
434+
$keyIndexes[$keyValue] = count($keyTypes);
435+
$keyTypes[] = $keyType;
436+
$valueTypes[] = $constantArray->getValueTypes()[$i];
437+
}
411438
}
412439

413-
$builder = ConstantArrayTypeBuilder::createFromConstantArray($constantArray);
440+
$builder = ConstantArrayTypeBuilder::createFromConstantArray(
441+
new ConstantArrayType($keyTypes, $valueTypes),
442+
);
414443
$makeUnsealed = self::getOptionalMethod($builder, 'makeUnsealed');
415-
if (null === $makeUnsealed) {
444+
$disableArrayDegradation = self::getOptionalMethod($builder, 'disableArrayDegradation');
445+
if (null === $makeUnsealed || null === $disableArrayDegradation) {
416446
return null;
417447
}
418448

449+
$disableArrayDegradation();
419450
$makeUnsealed($genericArray->getKeyType(), $genericArray->getItemType());
420451

421452
return $builder->getArray();

tests/ArrayMergeTypeNodeResolverExtensionTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ private static function expectedTypeForPhpStanRenderer(string $expectedType): st
121121
$expectedType = match ($expectedType) {
122122
'array{fixed: int, ...<string, string>}',
123123
'array{fixed: string, ...<string, int>}' => 'non-empty-array<string, int|string>',
124+
'array{first: string, second: bool, ...<string, int>}',
125+
'array{fixed: bool, ...<string, int>}' => 'non-empty-array<string, bool|int|string>',
124126
'array{outer: array{removed?: never, kept: int, ...<string, bool>}}' =>
125127
'array{outer: array{kept: int}}',
126128
default => $expectedType,

tests/GenericStringArrayShapePrecisionAdversarialTest.php

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
use PHPStan\Type\Type;
3737
use PHPStan\Type\UnionType;
3838
use PHPStan\Type\VerbosityLevel;
39+
use function array_keys;
40+
use function array_map;
3941
use function array_merge;
4042
use function is_bool;
4143
use function is_callable;
@@ -255,6 +257,218 @@ public function testUnusualAutoIndexStateIsNotRetainedInAConflictingShape(): voi
255257
}
256258
}
257259

260+
public function testMultipleTrailingShapesRetainKnownOffsetsAtBuilderBoundary(): void
261+
{
262+
$firstShapeKeyTypes = [];
263+
$firstShapeValueTypes = [];
264+
265+
for ($i = 0; $i < 256; $i++) {
266+
$firstShapeKeyTypes[] = new ConstantStringType('first' . $i);
267+
$firstShapeValueTypes[] = new ConstantIntegerType($i);
268+
}
269+
270+
$result = (new ArrayMergeType([
271+
new ArrayType(new StringType(), new IntegerType()),
272+
new ConstantArrayType($firstShapeKeyTypes, $firstShapeValueTypes),
273+
new ConstantArrayType(
274+
[new ConstantStringType('first0'), new ConstantStringType('last')],
275+
[new ConstantStringType('overwritten'), new ConstantStringType('tail')],
276+
),
277+
]))->resolve();
278+
279+
if (!self::supportsUnsealedShapes()) {
280+
$this->assertSame([], $result->getConstantArrays());
281+
return;
282+
}
283+
284+
$constantArrays = $result->getConstantArrays();
285+
$this->assertCount(
286+
1,
287+
$constantArrays,
288+
sprintf(
289+
'All eligible trailing-shape offsets must remain known when their combined size crosses 256; got %s.',
290+
$result->describe(VerbosityLevel::precise()),
291+
),
292+
);
293+
$this->assertCount(257, $constantArrays[0]->getKeyTypes());
294+
$this->assertSame('first0', $constantArrays[0]->getKeyTypes()[0]->getValue());
295+
$this->assertSame('last', $constantArrays[0]->getKeyTypes()[256]->getValue());
296+
$this->assertTrue($constantArrays[0]->hasOffsetValueType(new ConstantStringType('first0'))->yes());
297+
$this->assertTrue((new ConstantStringType('overwritten'))->equals(
298+
$constantArrays[0]->getOffsetValueType(new ConstantStringType('first0')),
299+
));
300+
$this->assertTrue($constantArrays[0]->hasOffsetValueType(new ConstantStringType('first255'))->yes());
301+
$this->assertTrue($constantArrays[0]->hasOffsetValueType(new ConstantStringType('last'))->yes());
302+
}
303+
304+
public function testMultipleTrailingShapesMatchNativeOverwriteAndInsertionOrder(): void
305+
{
306+
$runtimeShapes = [
307+
['first' => 'left', '08' => 'leading zero', 'shared' => 'first value', 'middle' => 1],
308+
['+8' => true, 'second' => true, 'shared' => 'second value', '08' => 'overwritten leading zero'],
309+
['middle' => 'last middle', '-0' => false, 'third' => 3, '+8' => false, 'shared' => false],
310+
];
311+
$shapeTypes = array_map(self::constantArrayFromRuntime(...), $runtimeShapes);
312+
313+
$result = (new ArrayMergeType([
314+
new ArrayType(new StringType(), new IntegerType()),
315+
...$shapeTypes,
316+
]))->resolve();
317+
$runtimeOutcome = self::constantArrayFromRuntime(array_merge(
318+
['dynamic' => 7],
319+
...$runtimeShapes,
320+
));
321+
322+
$this->assertTrue(
323+
$result->isSuperTypeOf($runtimeOutcome)->yes(),
324+
sprintf(
325+
'Inferred %s must contain representative native result %s.',
326+
$result->describe(VerbosityLevel::precise()),
327+
$runtimeOutcome->describe(VerbosityLevel::precise()),
328+
),
329+
);
330+
331+
if (!self::supportsUnsealedShapes()) {
332+
$this->assertSame([], $result->getConstantArrays());
333+
return;
334+
}
335+
336+
$constantArrays = $result->getConstantArrays();
337+
$this->assertCount(1, $constantArrays);
338+
$actualKnownKeys = array_map(
339+
static fn(ConstantIntegerType|ConstantStringType $keyType): int|string => $keyType->getValue(),
340+
$constantArrays[0]->getKeyTypes(),
341+
);
342+
$nativeKnownKeys = array_keys(array_merge(...$runtimeShapes));
343+
$this->assertSame($nativeKnownKeys, $actualKnownKeys);
344+
345+
$expectedKnownOffsets = self::constantArrayFromRuntime(array_merge(...$runtimeShapes));
346+
$expectedConstantArrays = $expectedKnownOffsets->getConstantArrays();
347+
$this->assertCount(1, $expectedConstantArrays);
348+
349+
foreach ($expectedConstantArrays[0]->getKeyTypes() as $keyType) {
350+
$this->assertTrue(
351+
$expectedConstantArrays[0]->getOffsetValueType($keyType)->equals(
352+
$constantArrays[0]->getOffsetValueType($keyType),
353+
),
354+
sprintf('Known offset %s must have the native last-writer value type.', $keyType->getValue()),
355+
);
356+
}
357+
358+
$this->assertTrue((new IntegerType())->equals(
359+
$constantArrays[0]->getOffsetValueType(new ConstantStringType('dynamic')),
360+
));
361+
}
362+
363+
public function testDisqualifiedThirdOperandForcesConservativeFallback(): void
364+
{
365+
$generic = new ArrayType(new StringType(), new IntegerType());
366+
$validShape = new ConstantArrayType(
367+
[new ConstantStringType('fixed')],
368+
[new StringType()],
369+
);
370+
$optionalShape = new ConstantArrayType(
371+
[new ConstantStringType('fixed')],
372+
[new BooleanType()],
373+
[0],
374+
[0],
375+
);
376+
$integerShape = new ConstantArrayType(
377+
[new ConstantIntegerType(7)],
378+
[new BooleanType()],
379+
[8],
380+
);
381+
$canonicalIntegerStringShape = new ConstantArrayType(
382+
[new ConstantStringType('7')],
383+
[new BooleanType()],
384+
);
385+
$otherShape = new ConstantArrayType(
386+
[new ConstantStringType('other')],
387+
[new BooleanType()],
388+
);
389+
$unusualAutoIndexShape = new ConstantArrayType(
390+
[new ConstantStringType('other')],
391+
[new BooleanType()],
392+
[7],
393+
);
394+
395+
/**
396+
* @var array<string, array{
397+
* non-empty-list<Type>,
398+
* non-empty-list<array<int|string, bool|int|string>>
399+
* }> $scenarios
400+
*/
401+
$scenarios = [
402+
'optional keys' => [
403+
[$generic, $validShape, $optionalShape],
404+
[['dynamic' => 1], ['fixed' => 'shape'], []],
405+
],
406+
'integer keys' => [
407+
[$generic, $validShape, $integerShape],
408+
[['dynamic' => 1], ['fixed' => 'shape'], [7 => true]],
409+
],
410+
'canonical integer string keys' => [
411+
[$generic, $validShape, $canonicalIntegerStringShape],
412+
[['dynamic' => 1], ['fixed' => 'shape'], [7 => true]],
413+
],
414+
'shape unions' => [
415+
[$generic, $validShape, new UnionType([$validShape, $otherShape])],
416+
[['dynamic' => 1], ['fixed' => 'shape'], ['other' => true]],
417+
],
418+
'shape intersections' => [
419+
[$generic, $validShape, new IntersectionType([$otherShape, new NonEmptyArrayType()])],
420+
[['dynamic' => 1], ['fixed' => 'shape'], ['other' => true]],
421+
],
422+
'generic operands after shapes' => [
423+
[$generic, $validShape, new ArrayType(new StringType(), new BooleanType())],
424+
[['dynamic' => 1], ['fixed' => 'shape'], ['fixed' => true]],
425+
],
426+
'empty shapes' => [
427+
[$generic, $validShape, ConstantArrayTypeBuilder::createEmpty()->getArray()],
428+
[['dynamic' => 1], ['fixed' => 'shape'], []],
429+
],
430+
'unusual auto-index metadata' => [
431+
[$generic, $validShape, $unusualAutoIndexShape],
432+
[['dynamic' => 1], ['fixed' => 'shape'], ['other' => true]],
433+
],
434+
];
435+
436+
if (self::supportsUnsealedShapes()) {
437+
$unsealedShape = new ConstantArrayType(
438+
[new ConstantStringType('other')],
439+
[new BooleanType()],
440+
[0],
441+
[],
442+
TrinaryLogic::createNo(),
443+
[new StringType(), new BooleanType()],
444+
);
445+
$scenarios['unsealed shapes'] = [
446+
[$generic, $validShape, $unsealedShape],
447+
[['dynamic' => 1], ['fixed' => 'shape'], ['other' => true, 'extra' => false]],
448+
];
449+
}
450+
451+
foreach ($scenarios as $name => [$declaredTypes, $runtimeOperands]) {
452+
$result = (new ArrayMergeType($declaredTypes))->resolve();
453+
$this->assertSame(
454+
[],
455+
$result->getConstantArrays(),
456+
sprintf('Scenario %s must retain the prior conservative fallback.', $name),
457+
);
458+
459+
$runtimeOutcome = self::constantArrayFromRuntime(array_merge(...$runtimeOperands));
460+
$this->assertTrue(
461+
$result->isSuperTypeOf($runtimeOutcome)->yes(),
462+
sprintf(
463+
'Scenario %s inferred %s, which excludes native result %s.',
464+
$name,
465+
$result->describe(VerbosityLevel::precise()),
466+
$runtimeOutcome->describe(VerbosityLevel::precise()),
467+
),
468+
);
469+
}
470+
}
471+
258472
private static function supportsUnsealedShapes(): bool
259473
{
260474
return self::hasOptionalMethod(ConstantArrayTypeBuilder::createEmpty(), 'makeUnsealed');

tests/data/mixed-shapes.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
/** @phpstan-var array-merge<array<string, int>, array{fixed: string}> $constantLastWithKnownOffset */
1414
assertType('array{fixed: string, ...<string, int>}', $constantLastWithKnownOffset);
1515

16+
/** @phpstan-var array-merge<array<string, int>, array{first: string}, array{second: bool}> $multipleConstantsLast */
17+
assertType('array{first: string, second: bool, ...<string, int>}', $multipleConstantsLast);
18+
19+
/** @phpstan-var array-merge<array<string, int>, array{fixed: string}, array{fixed: bool}> $multipleConstantsLastWriteWins */
20+
assertType('array{fixed: bool, ...<string, int>}', $multipleConstantsLastWriteWins);
21+
1622
/** @phpstan-var array-merge<array<string, string>, array{fixed?: int}> $optionalConstantLast */
1723
assertType('array<string, int|string>', $optionalConstantLast);
1824

0 commit comments

Comments
 (0)