Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
8ab6e10
Initial implementation of unsealed array shapes
ondrejmirtes Apr 17, 2026
643693f
So intersecting of constant arrays works
ondrejmirtes Apr 22, 2026
a0b1930
Dedup code
ondrejmirtes Apr 22, 2026
b744726
intersecting improvement
ondrejmirtes Apr 22, 2026
66dd40b
isSuperTypeOf
ondrejmirtes Apr 22, 2026
f0175e2
Fix CS
ondrejmirtes Apr 22, 2026
466fe03
union improvement
ondrejmirtes Apr 22, 2026
022de2d
SA fixes
ondrejmirtes Apr 23, 2026
677f46b
More SA fixes
ondrejmirtes Apr 23, 2026
980a5b4
Test updates
ondrejmirtes Apr 23, 2026
77eb788
Regression tests
ondrejmirtes Apr 23, 2026
71503bc
Optimization
ondrejmirtes Apr 23, 2026
3e5e5c6
Fix
ondrejmirtes Apr 24, 2026
721c0d0
SA fixes
ondrejmirtes Apr 26, 2026
33c84ac
Fix SA
ondrejmirtes Apr 26, 2026
e11272c
Fix SA
ondrejmirtes Apr 27, 2026
338170a
Remove unrelated tip
ondrejmirtes Apr 27, 2026
7d2a7ce
Fix tests
ondrejmirtes Apr 27, 2026
0fa1aa2
Fix
ondrejmirtes Apr 27, 2026
479e3bf
Preserve unsealed extras when intersecting array{...} with another array
ondrejmirtes Apr 27, 2026
1bd33cf
Fix tests: bug-7963, bug-13978
ondrejmirtes Apr 27, 2026
2219989
Tip in assertNoErrors
ondrejmirtes Apr 27, 2026
fd08231
Re-tighten bug-7963 @phpstan-return on unsealed
ondrejmirtes Apr 27, 2026
771a2e8
Fix CS
ondrejmirtes Apr 27, 2026
3556b3a
Fix baseline
ondrejmirtes Apr 27, 2026
04a737b
Fix
ondrejmirtes Apr 28, 2026
6bcb8e8
Update levels tests
ondrejmirtes Apr 28, 2026
fd67793
Refactor - allow other message to be passed from getIterableTypesWith…
ondrejmirtes Apr 28, 2026
7e126f6
Better "missing iterable value type" for unsealed types
ondrejmirtes Apr 28, 2026
14e158e
Generics
ondrejmirtes Apr 28, 2026
c10aad0
Unsealed types awareness in more methods
ondrejmirtes Apr 28, 2026
cab38fc
Preserve array shape and make it unsealed when non-constant key is as…
ondrejmirtes Apr 29, 2026
deac626
Correctly generalize unsealed array shapes
ondrejmirtes Apr 30, 2026
494f35c
TMP WIP for everyone, not just bleeding edge
ondrejmirtes Apr 23, 2026
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
8 changes: 1 addition & 7 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,6 @@ parameters:
count: 1
path: src/Analyser/ExprHandler/PreIncHandler.php

-
rawMessage: Cannot assign offset 'realCount' to array<mixed>|string.
identifier: offsetAssign.dimType
count: 1
path: src/Analyser/Ignore/IgnoredErrorHelperResult.php

-
rawMessage: Casting to string something that's already string.
identifier: cast.useless
Expand Down Expand Up @@ -1710,7 +1704,7 @@ parameters:
-
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.'
identifier: phpstanApi.instanceofType
count: 19
count: 21
path: src/Type/TypeCombinator.php

-
Expand Down
5 changes: 4 additions & 1 deletion src/Analyser/Ignore/IgnoredError.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@
use function sprintf;
use function str_replace;

/**
* @phpstan-import-type ExpandedIgnoredErrorData from IgnoredErrorHelperResult
*/
final class IgnoredError
{

/**
* @param array{message?: string, rawMessage?: string, identifier?: string, identifiers?: list<string>, path?: string, paths?: list<string>}|string $ignoredError
* @param ExpandedIgnoredErrorData|string $ignoredError
*/
public static function getIgnoredErrorLabel(array|string $ignoredError): string
{
Expand Down
18 changes: 16 additions & 2 deletions src/Analyser/Ignore/IgnoredErrorHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,26 @@
use function is_file;
use function sprintf;

/**
* @phpstan-type IgnoredErrorData = array{
* message?: string,
* messages?: list<string>,
* rawMessage?: string,
* rawMessages?: list<string>,
* identifier?: string,
* identifiers?: list<string>,
* path?: string,
* paths?: list<string>,
* count?: int,
* reportUnmatched?: bool,
* }
*/
#[AutowiredService]
final class IgnoredErrorHelper
{

/**
* @param (string|mixed[])[] $ignoreErrors
* @param (string|IgnoredErrorData)[] $ignoreErrors
*/
public function __construct(
private FileHelper $fileHelper,
Expand Down Expand Up @@ -106,7 +120,7 @@ public function initialize(): IgnoredErrorHelperResult
continue;
}

$reportUnmatched = (bool) ($uniquedExpandedIgnoreErrors[$key]['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors);
$reportUnmatched = $uniquedExpandedIgnoreErrors[$key]['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors;
if (!$reportUnmatched) {
$reportUnmatched = $ignoreError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors;
}
Expand Down
91 changes: 66 additions & 25 deletions src/Analyser/Ignore/IgnoredErrorHelperResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,39 @@
use function is_string;
use function sprintf;

/**
* `IgnoredErrorHelper` may collapse several configured ignores into one
* merged entry, so `message`/`rawMessage`/`identifier` are nullable here.
* It also attaches `realPath` once the configured path is resolved. The
* `messages`/`rawMessages`/`identifiers` keys remain in the inferred shape
* even after expansion + unset (PHPStan does not strip optional keys via
* negative isset on sealed shapes), so the type lists them explicitly here
* — they are never read, only tolerated. `paths` is `array<int<0, max>,
* string>` rather than `list<string>` because `process()` unsets matched
* entries by index, breaking list-ness.
*
* @phpstan-type ExpandedIgnoredErrorData = array{
* message?: string|null,
* rawMessage?: string|null,
* identifier?: string|null,
* messages?: list<string>,
* rawMessages?: list<string>,
* identifiers?: list<string>,
* path?: string,
* paths?: array<int<0, max>, string>,
* count?: int,
* reportUnmatched?: bool,
* realPath?: string,
* }
*/
final class IgnoredErrorHelperResult
{

/**
* @param list<string> $errors
* @param array<array<mixed>> $otherIgnoreErrors
* @param array<string, array<array<mixed>>> $ignoreErrorsByFile
* @param (string|mixed[])[] $ignoreErrors
* @param array<array{index: int<0, max>, ignoreError: string|ExpandedIgnoredErrorData}> $otherIgnoreErrors
* @param array<string, array<array{index: int<0, max>, ignoreError: string|ExpandedIgnoredErrorData}>> $ignoreErrorsByFile
* @param (string|ExpandedIgnoredErrorData)[] $ignoreErrors
*/
public function __construct(
private FileHelper $fileHelper,
Expand Down Expand Up @@ -55,7 +80,14 @@ public function process(
$unmatchedIgnoredErrors = $this->ignoreErrors;
$stringErrors = [];

$processIgnoreError = function (Error $error, int $i, $ignore) use (&$unmatchedIgnoredErrors, &$stringErrors): bool {
// Per-entry runtime state for `count`-bounded ignores. Tracked in side
// maps keyed by the same index so `$unmatchedIgnoredErrors` keeps the
// `(string|ExpandedIgnoredErrorData)[]` shape across the closure's
// offset writes — otherwise PHPStan widens it to `array<mixed>`.
$realCounts = [];
$matchedAt = [];

$processIgnoreError = function (Error $error, int $i, $ignore) use (&$unmatchedIgnoredErrors, &$stringErrors, &$realCounts, &$matchedAt): bool {
$shouldBeIgnored = false;
if (is_string($ignore)) {
$shouldBeIgnored = IgnoredError::shouldIgnore($this->fileHelper, $error, ignoredErrorPattern: $ignore, ignoredErrorMessage: null, identifier: null, path: null);
Expand All @@ -67,13 +99,11 @@ public function process(
$shouldBeIgnored = IgnoredError::shouldIgnore($this->fileHelper, $error, ignoredErrorPattern: $ignore['message'] ?? null, ignoredErrorMessage: $ignore['rawMessage'] ?? null, identifier: $ignore['identifier'] ?? null, path: $ignore['path']);
if ($shouldBeIgnored) {
if (isset($ignore['count'])) {
$realCount = $unmatchedIgnoredErrors[$i]['realCount'] ?? 0;
$realCount++;
$unmatchedIgnoredErrors[$i]['realCount'] = $realCount;
$realCount = ($realCounts[$i] ?? 0) + 1;
$realCounts[$i] = $realCount;

if (!isset($unmatchedIgnoredErrors[$i]['file'])) {
$unmatchedIgnoredErrors[$i]['file'] = $error->getFile();
$unmatchedIgnoredErrors[$i]['line'] = $error->getLine();
if (!isset($matchedAt[$i])) {
$matchedAt[$i] = ['file' => $error->getFile(), 'line' => $error->getLine()];
}

if ($realCount > $ignore['count']) {
Expand Down Expand Up @@ -171,48 +201,59 @@ public function process(

$errors = array_values($errors);

foreach ($unmatchedIgnoredErrors as $unmatchedIgnoredError) {
if (!isset($unmatchedIgnoredError['count']) || !isset($unmatchedIgnoredError['realCount'])) {
foreach ($unmatchedIgnoredErrors as $i => $unmatchedIgnoredError) {
if (!is_array($unmatchedIgnoredError) || !isset($unmatchedIgnoredError['count']) || !isset($realCounts[$i])) {
continue;
}

if ($unmatchedIgnoredError['realCount'] <= $unmatchedIgnoredError['count']) {
$realCount = $realCounts[$i];
if ($realCount <= $unmatchedIgnoredError['count']) {
continue;
}

$matchedFile = $matchedAt[$i]['file'] ?? null;
$matchedLine = $matchedAt[$i]['line'] ?? null;

$errors[] = (new Error(sprintf(
'%s %s is expected to occur %d %s, but occurred %d %s.',
IgnoredError::getIgnoredErrorLabel($unmatchedIgnoredError),
IgnoredError::stringifyPattern($unmatchedIgnoredError),
$unmatchedIgnoredError['count'],
$unmatchedIgnoredError['count'] === 1 ? 'time' : 'times',
$unmatchedIgnoredError['realCount'],
$unmatchedIgnoredError['realCount'] === 1 ? 'time' : 'times',
), $unmatchedIgnoredError['file'], $unmatchedIgnoredError['line'], false))->withIdentifier('ignore.count');
$realCount,
$realCount === 1 ? 'time' : 'times',
), $matchedFile ?? '', $matchedLine, false))->withIdentifier('ignore.count');
}

$analysedFilesKeys = array_fill_keys($analysedFiles, true);

if (!$hasInternalErrors) {
foreach ($unmatchedIgnoredErrors as $unmatchedIgnoredError) {
$reportUnmatched = $unmatchedIgnoredError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors;
foreach ($unmatchedIgnoredErrors as $i => $unmatchedIgnoredError) {
$reportUnmatched = is_array($unmatchedIgnoredError)
? ($unmatchedIgnoredError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors)
: $this->reportUnmatchedIgnoredErrors;
if ($reportUnmatched === false) {
continue;
}
$realCount = $realCounts[$i] ?? null;
if (
isset($unmatchedIgnoredError['count'], $unmatchedIgnoredError['realCount'])
isset($unmatchedIgnoredError['count'])
&& $realCount !== null
&& (isset($unmatchedIgnoredError['realPath']) || !$onlyFiles)
) {
if ($unmatchedIgnoredError['realCount'] < $unmatchedIgnoredError['count']) {
if ($realCount < $unmatchedIgnoredError['count']) {
$matchedFile = $matchedAt[$i]['file'] ?? null;
$matchedLine = $matchedAt[$i]['line'] ?? null;
// $realCount is at least 1 (it was incremented in the closure)
// and strictly less than count, so count is always >= 2.
$errors[] = (new Error(sprintf(
'%s %s is expected to occur %d %s, but occurred only %d %s.',
'%s %s is expected to occur %d times, but occurred only %d %s.',
IgnoredError::getIgnoredErrorLabel($unmatchedIgnoredError),
IgnoredError::stringifyPattern($unmatchedIgnoredError),
$unmatchedIgnoredError['count'],
$unmatchedIgnoredError['count'] === 1 ? 'time' : 'times',
$unmatchedIgnoredError['realCount'],
$unmatchedIgnoredError['realCount'] === 1 ? 'time' : 'times',
), $unmatchedIgnoredError['file'], $unmatchedIgnoredError['line'], false))->withIdentifier('ignore.count');
$realCount,
$realCount === 1 ? 'time' : 'times',
), $matchedFile ?? '', $matchedLine, false))->withIdentifier('ignore.count');
}
} elseif (isset($unmatchedIgnoredError['realPath'])) {
if (!array_key_exists($unmatchedIgnoredError['realPath'], $analysedFilesKeys)) {
Expand Down
24 changes: 23 additions & 1 deletion src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -4187,8 +4187,30 @@ private function generalizeType(Type $a, Type $b, int $depth): Type

$resultTypes[] = $resultArrayBuilder->getArray();
} else {
// Both inputs are sealed constant array shapes — their
// key sets are finite by construction. When taking the
// fall-through ArrayType path we still recurse into
// `generalizeType` for the iterable key, which would
// widen e.g. `0|1` to `int<0, max>` and lose the loop's
// per-iteration precision. Instead, keep the literal
// union of constant keys so the loop's bound stays
// visible.
$bothSealed = true;
foreach ([...$constantArrays['a'], ...$constantArrays['b']] as $constantArrayCheck) {
foreach ($constantArrayCheck->getConstantArrays() as $constantArrayInstance) {
if (!$constantArrayInstance->isSealed()->yes()) {
$bothSealed = false;
break 2;
}
}
}
if ($bothSealed) {
$resultKeyType = TypeCombinator::union($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType());
} else {
$resultKeyType = TypeCombinator::union($this->generalizeType($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType(), $depth + 1));
}
$resultType = new ArrayType(
TypeCombinator::union($this->generalizeType($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType(), $depth + 1)),
$resultKeyType,
TypeCombinator::union($this->generalizeType($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType(), $depth + 1)),
);
$accessories = [];
Expand Down
2 changes: 1 addition & 1 deletion src/Node/AnonymousClassNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ final class AnonymousClassNode extends Class_
public static function createFromClassNode(Class_ $node): self
{
$subNodes = [];
foreach ($node->getSubNodeNames() as $subNodeName) {
foreach (['attrGroups', 'flags', 'extends', 'implements', 'stmts'] as $subNodeName) {
$subNodes[$subNodeName] = $node->$subNodeName;
}

Expand Down
84 changes: 62 additions & 22 deletions src/PhpDoc/TypeNodeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -705,24 +705,7 @@ static function (string $variance): TemplateTypeVariance {
if (count($genericTypes) === 1) { // array<ValueType>
$arrayType = new ArrayType((new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(), $genericTypes[0]);
} elseif (count($genericTypes) === 2) { // array<KeyType, ValueType>
$originalKey = $genericTypes[0];
if ($this->reportUnsafeArrayStringKeyCasting === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) {
$originalKey = TypeTraverser::map($originalKey, static function (Type $type, callable $traverse) {
if ($type instanceof UnionType || $type instanceof IntersectionType) {
return $traverse($type);
}

if ($type instanceof StringType) {
return TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true));
}

return $type;
});
}
$keyType = TypeCombinator::intersect($originalKey->toArrayKey(), new UnionType([
new IntegerType(),
new StringType(),
]))->toArrayKey();
$keyType = $this->transformUnsafeArrayKey($genericTypes[0]);
$finiteTypes = $keyType->getFiniteTypes();
if (
count($finiteTypes) === 1
Expand Down Expand Up @@ -1002,6 +985,28 @@ static function (string $variance): TemplateTypeVariance {
return new ErrorType();
}

private function transformUnsafeArrayKey(Type $keyType): Type
{
if ($this->reportUnsafeArrayStringKeyCasting === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) {
$keyType = TypeTraverser::map($keyType, static function (Type $type, callable $traverse) {
if ($type instanceof UnionType || $type instanceof IntersectionType) {
return $traverse($type);
}

if ($type instanceof StringType) {
return TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true));
}

return $type;
});
}

return TypeCombinator::intersect($keyType->toArrayKey(), new UnionType([
new IntegerType(),
new StringType(),
]))->toArrayKey();
}

private function resolveCallableTypeNode(CallableTypeNode $typeNode, NameScope $nameScope): Type
{
$templateTags = [];
Expand Down Expand Up @@ -1101,13 +1106,48 @@ private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $name
$builder->setOffsetValueType($offsetType, $this->resolve($itemNode->valueType, $nameScope), $itemNode->optional);
}

$isList = in_array($typeNode->kind, [
ArrayShapeNode::KIND_LIST,
ArrayShapeNode::KIND_NON_EMPTY_LIST,
], true);

if (!$typeNode->sealed) {
if ($typeNode->unsealedType === null) {
if ($isList) {
$unsealedKeyType = IntegerRangeType::createAllGreaterThanOrEqualTo(0);
} else {
$unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey();
}
$builder->makeUnsealed(
$unsealedKeyType,
new MixedType(),
);
} else {
if ($typeNode->unsealedType->keyType === null) {
if ($isList) {
$unsealedKeyType = IntegerRangeType::createAllGreaterThanOrEqualTo(0);
} else {
$unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey();
}
} else {
$unsealedKeyType = $this->transformUnsafeArrayKey($this->resolve($typeNode->unsealedType->keyType, $nameScope));
}
$unsealedKeyFiniteTypes = $unsealedKeyType->getFiniteTypes();
$unsealedValueType = $this->resolve($typeNode->unsealedType->valueType, $nameScope);
if (count($unsealedKeyFiniteTypes) > 0) {
foreach ($unsealedKeyFiniteTypes as $unsealedKeyFiniteType) {
$builder->setOffsetValueType($unsealedKeyFiniteType, $unsealedValueType, true);
}
} else {
$builder->makeUnsealed($unsealedKeyType, $unsealedValueType);
}
}
}

$arrayType = $builder->getArray();

$accessories = [];
if (in_array($typeNode->kind, [
ArrayShapeNode::KIND_LIST,
ArrayShapeNode::KIND_NON_EMPTY_LIST,
], true)) {
if ($isList) {
$accessories[] = new AccessoryArrayListType();
}

Expand Down
5 changes: 2 additions & 3 deletions src/Rules/Classes/LocalTypeAliasesCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,9 @@ public function checkInTraitDefinitionContext(ClassReflection $reflection): arra
continue;
}

foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($resolvedType) as $iterableType) {
$iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly());
foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($resolvedType) as $iterableTypeDescription) {
$errors[] = RuleErrorBuilder::message(sprintf(
'%s %s has type alias %s with no value type specified in iterable type %s.',
'%s %s has type alias %s with no value type specified in %s.',
$reflection->getClassTypeDescription(),
$reflection->getDisplayName(),
$aliasName,
Expand Down
Loading
Loading