Skip to content

Commit f7fb170

Browse files
committed
Unsealed array shapes
2 parents 0576944 + e2d4e60 commit f7fb170

115 files changed

Lines changed: 6181 additions & 480 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

phpstan-baseline.neon

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,6 @@ parameters:
4848
count: 1
4949
path: src/Analyser/ExprHandler/PreIncHandler.php
5050

51-
-
52-
rawMessage: Cannot assign offset 'realCount' to array<mixed>|string.
53-
identifier: offsetAssign.dimType
54-
count: 1
55-
path: src/Analyser/Ignore/IgnoredErrorHelperResult.php
56-
5751
-
5852
rawMessage: Casting to string something that's already string.
5953
identifier: cast.useless
@@ -966,7 +960,7 @@ parameters:
966960
-
967961
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.'
968962
identifier: phpstanApi.instanceofType
969-
count: 3
963+
count: 5
970964
path: src/Type/Constant/ConstantArrayType.php
971965

972966
-
@@ -1722,7 +1716,7 @@ parameters:
17221716
-
17231717
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.'
17241718
identifier: phpstanApi.instanceofType
1725-
count: 19
1719+
count: 21
17261720
path: src/Type/TypeCombinator.php
17271721

17281722
-

src/Analyser/ExprHandler/FuncCallHandler.php

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -699,19 +699,60 @@ static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arra
699699
$constantArray->isOptionalKey($k),
700700
);
701701
}
702+
703+
$unsealedTypes = $constantArray->getUnsealedTypes();
704+
if ($unsealedTypes !== null) {
705+
$arrayTypeBuilder->makeUnsealed($unsealedTypes[0], $unsealedTypes[1]);
706+
}
702707
}
703708

704709
$constantArray = $arrayTypeBuilder->getArray();
705710

706711
if ($constantArray->isConstantArray()->yes() && $nonConstantArrayWasUnpacked) {
707-
$array = new ArrayType($constantArray->generalize(GeneralizePrecision::lessSpecific())->getIterableKeyType(), $constantArray->getIterableValueType());
708-
$isList = $constantArray->isList()->yes();
709-
$constantArray = $constantArray->isIterableAtLeastOnce()->yes()
710-
? new IntersectionType([$array, new NonEmptyArrayType()])
711-
: $array;
712-
$constantArray = $isList
713-
? TypeCombinator::intersect($constantArray, new AccessoryArrayListType())
714-
: $constantArray;
712+
$constantArrays = $constantArray->getConstantArrays();
713+
if ($constantArray->isList()->yes()) {
714+
// A list can't preserve precise indices when an
715+
// unknown number of values is prepended/appended —
716+
// every index would be shifted by an unknown
717+
// amount. Degrade to a `non-empty-list<...>` of
718+
// the value union.
719+
$array = new ArrayType($constantArray->generalize(GeneralizePrecision::lessSpecific())->getIterableKeyType(), $constantArray->getIterableValueType());
720+
$constantArray = $constantArray->isIterableAtLeastOnce()->yes()
721+
? new IntersectionType([$array, new NonEmptyArrayType()])
722+
: $array;
723+
$constantArray = TypeCombinator::intersect($constantArray, new AccessoryArrayListType());
724+
} elseif (count($constantArrays) === 1) {
725+
// Associative input — string keys keep their
726+
// precise values and the unknown count of
727+
// unpacked items lives in an unsealed `int` slot
728+
// of the result. Drops the auto-indexed
729+
// representatives that the unpacked-arg loop
730+
// inserted (they stand in for "0..N-1 of the
731+
// unpack value type" and are now subsumed by the
732+
// unsealed slot).
733+
$builder = ConstantArrayTypeBuilder::createEmpty();
734+
$intValues = [];
735+
foreach ($constantArrays[0]->getKeyTypes() as $i => $keyType) {
736+
$valueType = $constantArrays[0]->getValueTypes()[$i];
737+
if ($keyType->isString()->yes()) {
738+
$builder->setOffsetValueType($keyType, $valueType, $constantArrays[0]->isOptionalKey($i));
739+
continue;
740+
}
741+
$intValues[] = $valueType;
742+
}
743+
744+
$unsealedKey = new IntegerType();
745+
$unsealedValue = count($intValues) > 0 ? TypeCombinator::union(...$intValues) : new MixedType();
746+
if ($constantArrays[0]->isUnsealed()->yes()) {
747+
$existing = $constantArrays[0]->getUnsealedTypes();
748+
if ($existing !== null) {
749+
$unsealedKey = TypeCombinator::union($unsealedKey, $existing[0]);
750+
$unsealedValue = TypeCombinator::union($unsealedValue, $existing[1]);
751+
}
752+
}
753+
$builder->makeUnsealed($unsealedKey, $unsealedValue);
754+
$constantArray = $builder->getArray();
755+
}
715756
}
716757

717758
$newArrayTypes[] = $constantArray;

src/Analyser/Ignore/IgnoredError.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@
1414
use function sprintf;
1515
use function str_replace;
1616

17+
/**
18+
* @phpstan-import-type ExpandedIgnoredErrorData from IgnoredErrorHelperResult
19+
*/
1720
final class IgnoredError
1821
{
1922

2023
/**
21-
* @param array{message?: string, rawMessage?: string, identifier?: string, identifiers?: list<string>, path?: string, paths?: list<string>}|string $ignoredError
24+
* @param ExpandedIgnoredErrorData|string $ignoredError
2225
*/
2326
public static function getIgnoredErrorLabel(array|string $ignoredError): string
2427
{

src/Analyser/Ignore/IgnoredErrorHelper.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,26 @@
1414
use function is_file;
1515
use function sprintf;
1616

17+
/**
18+
* @phpstan-type IgnoredErrorData = array{
19+
* message?: string,
20+
* messages?: list<string>,
21+
* rawMessage?: string,
22+
* rawMessages?: list<string>,
23+
* identifier?: string,
24+
* identifiers?: list<string>,
25+
* path?: string,
26+
* paths?: list<string>,
27+
* count?: int,
28+
* reportUnmatched?: bool,
29+
* }
30+
*/
1731
#[AutowiredService]
1832
final class IgnoredErrorHelper
1933
{
2034

2135
/**
22-
* @param (string|mixed[])[] $ignoreErrors
36+
* @param (string|IgnoredErrorData)[] $ignoreErrors
2337
*/
2438
public function __construct(
2539
private FileHelper $fileHelper,
@@ -106,7 +120,7 @@ public function initialize(): IgnoredErrorHelperResult
106120
continue;
107121
}
108122

109-
$reportUnmatched = (bool) ($uniquedExpandedIgnoreErrors[$key]['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors);
123+
$reportUnmatched = $uniquedExpandedIgnoreErrors[$key]['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors;
110124
if (!$reportUnmatched) {
111125
$reportUnmatched = $ignoreError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors;
112126
}

src/Analyser/Ignore/IgnoredErrorHelperResult.php

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,39 @@
1313
use function is_string;
1414
use function sprintf;
1515

16+
/**
17+
* `IgnoredErrorHelper` may collapse several configured ignores into one
18+
* merged entry, so `message`/`rawMessage`/`identifier` are nullable here.
19+
* It also attaches `realPath` once the configured path is resolved. The
20+
* `messages`/`rawMessages`/`identifiers` keys remain in the inferred shape
21+
* even after expansion + unset (PHPStan does not strip optional keys via
22+
* negative isset on sealed shapes), so the type lists them explicitly here
23+
* — they are never read, only tolerated. `paths` is `array<int<0, max>,
24+
* string>` rather than `list<string>` because `process()` unsets matched
25+
* entries by index, breaking list-ness.
26+
*
27+
* @phpstan-type ExpandedIgnoredErrorData = array{
28+
* message?: string|null,
29+
* rawMessage?: string|null,
30+
* identifier?: string|null,
31+
* messages?: list<string>,
32+
* rawMessages?: list<string>,
33+
* identifiers?: list<string>,
34+
* path?: string,
35+
* paths?: array<int<0, max>, string>,
36+
* count?: int,
37+
* reportUnmatched?: bool,
38+
* realPath?: string,
39+
* }
40+
*/
1641
final class IgnoredErrorHelperResult
1742
{
1843

1944
/**
2045
* @param list<string> $errors
21-
* @param array<array<mixed>> $otherIgnoreErrors
22-
* @param array<string, array<array<mixed>>> $ignoreErrorsByFile
23-
* @param (string|mixed[])[] $ignoreErrors
46+
* @param array<array{index: int<0, max>, ignoreError: string|ExpandedIgnoredErrorData}> $otherIgnoreErrors
47+
* @param array<string, array<array{index: int<0, max>, ignoreError: string|ExpandedIgnoredErrorData}>> $ignoreErrorsByFile
48+
* @param (string|ExpandedIgnoredErrorData)[] $ignoreErrors
2449
*/
2550
public function __construct(
2651
private FileHelper $fileHelper,
@@ -55,7 +80,14 @@ public function process(
5580
$unmatchedIgnoredErrors = $this->ignoreErrors;
5681
$stringErrors = [];
5782

58-
$processIgnoreError = function (Error $error, int $i, $ignore) use (&$unmatchedIgnoredErrors, &$stringErrors): bool {
83+
// Per-entry runtime state for `count`-bounded ignores. Tracked in side
84+
// maps keyed by the same index so `$unmatchedIgnoredErrors` keeps the
85+
// `(string|ExpandedIgnoredErrorData)[]` shape across the closure's
86+
// offset writes — otherwise PHPStan widens it to `array<mixed>`.
87+
$realCounts = [];
88+
$matchedAt = [];
89+
90+
$processIgnoreError = function (Error $error, int $i, $ignore) use (&$unmatchedIgnoredErrors, &$stringErrors, &$realCounts, &$matchedAt): bool {
5991
$shouldBeIgnored = false;
6092
if (is_string($ignore)) {
6193
$shouldBeIgnored = IgnoredError::shouldIgnore($this->fileHelper, $error, ignoredErrorPattern: $ignore, ignoredErrorMessage: null, identifier: null, path: null);
@@ -67,13 +99,11 @@ public function process(
6799
$shouldBeIgnored = IgnoredError::shouldIgnore($this->fileHelper, $error, ignoredErrorPattern: $ignore['message'] ?? null, ignoredErrorMessage: $ignore['rawMessage'] ?? null, identifier: $ignore['identifier'] ?? null, path: $ignore['path']);
68100
if ($shouldBeIgnored) {
69101
if (isset($ignore['count'])) {
70-
$realCount = $unmatchedIgnoredErrors[$i]['realCount'] ?? 0;
71-
$realCount++;
72-
$unmatchedIgnoredErrors[$i]['realCount'] = $realCount;
102+
$realCount = ($realCounts[$i] ?? 0) + 1;
103+
$realCounts[$i] = $realCount;
73104

74-
if (!isset($unmatchedIgnoredErrors[$i]['file'])) {
75-
$unmatchedIgnoredErrors[$i]['file'] = $error->getFile();
76-
$unmatchedIgnoredErrors[$i]['line'] = $error->getLine();
105+
if (!isset($matchedAt[$i])) {
106+
$matchedAt[$i] = ['file' => $error->getFile(), 'line' => $error->getLine()];
77107
}
78108

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

172202
$errors = array_values($errors);
173203

174-
foreach ($unmatchedIgnoredErrors as $unmatchedIgnoredError) {
175-
if (!isset($unmatchedIgnoredError['count']) || !isset($unmatchedIgnoredError['realCount'])) {
204+
foreach ($unmatchedIgnoredErrors as $i => $unmatchedIgnoredError) {
205+
if (!is_array($unmatchedIgnoredError) || !isset($unmatchedIgnoredError['count']) || !isset($realCounts[$i])) {
176206
continue;
177207
}
178208

179-
if ($unmatchedIgnoredError['realCount'] <= $unmatchedIgnoredError['count']) {
209+
$realCount = $realCounts[$i];
210+
if ($realCount <= $unmatchedIgnoredError['count']) {
180211
continue;
181212
}
182213

214+
$matchedFile = $matchedAt[$i]['file'] ?? null;
215+
$matchedLine = $matchedAt[$i]['line'] ?? null;
216+
183217
$errors[] = (new Error(sprintf(
184218
'%s %s is expected to occur %d %s, but occurred %d %s.',
185219
IgnoredError::getIgnoredErrorLabel($unmatchedIgnoredError),
186220
IgnoredError::stringifyPattern($unmatchedIgnoredError),
187221
$unmatchedIgnoredError['count'],
188222
$unmatchedIgnoredError['count'] === 1 ? 'time' : 'times',
189-
$unmatchedIgnoredError['realCount'],
190-
$unmatchedIgnoredError['realCount'] === 1 ? 'time' : 'times',
191-
), $unmatchedIgnoredError['file'], $unmatchedIgnoredError['line'], false))->withIdentifier('ignore.count');
223+
$realCount,
224+
$realCount === 1 ? 'time' : 'times',
225+
), $matchedFile ?? '', $matchedLine, false))->withIdentifier('ignore.count');
192226
}
193227

194228
$analysedFilesKeys = array_fill_keys($analysedFiles, true);
195229

196230
if (!$hasInternalErrors) {
197-
foreach ($unmatchedIgnoredErrors as $unmatchedIgnoredError) {
198-
$reportUnmatched = $unmatchedIgnoredError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors;
231+
foreach ($unmatchedIgnoredErrors as $i => $unmatchedIgnoredError) {
232+
$reportUnmatched = is_array($unmatchedIgnoredError)
233+
? ($unmatchedIgnoredError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors)
234+
: $this->reportUnmatchedIgnoredErrors;
199235
if ($reportUnmatched === false) {
200236
continue;
201237
}
238+
$realCount = $realCounts[$i] ?? null;
202239
if (
203-
isset($unmatchedIgnoredError['count'], $unmatchedIgnoredError['realCount'])
240+
isset($unmatchedIgnoredError['count'])
241+
&& $realCount !== null
204242
&& (isset($unmatchedIgnoredError['realPath']) || !$onlyFiles)
205243
) {
206-
if ($unmatchedIgnoredError['realCount'] < $unmatchedIgnoredError['count']) {
244+
if ($realCount < $unmatchedIgnoredError['count']) {
245+
$matchedFile = $matchedAt[$i]['file'] ?? null;
246+
$matchedLine = $matchedAt[$i]['line'] ?? null;
247+
// $realCount is at least 1 (it was incremented in the closure)
248+
// and strictly less than count, so count is always >= 2.
207249
$errors[] = (new Error(sprintf(
208-
'%s %s is expected to occur %d %s, but occurred only %d %s.',
250+
'%s %s is expected to occur %d times, but occurred only %d %s.',
209251
IgnoredError::getIgnoredErrorLabel($unmatchedIgnoredError),
210252
IgnoredError::stringifyPattern($unmatchedIgnoredError),
211253
$unmatchedIgnoredError['count'],
212-
$unmatchedIgnoredError['count'] === 1 ? 'time' : 'times',
213-
$unmatchedIgnoredError['realCount'],
214-
$unmatchedIgnoredError['realCount'] === 1 ? 'time' : 'times',
215-
), $unmatchedIgnoredError['file'], $unmatchedIgnoredError['line'], false))->withIdentifier('ignore.count');
254+
$realCount,
255+
$realCount === 1 ? 'time' : 'times',
256+
), $matchedFile ?? '', $matchedLine, false))->withIdentifier('ignore.count');
216257
}
217258
} elseif (isset($unmatchedIgnoredError['realPath'])) {
218259
if (!array_key_exists($unmatchedIgnoredError['realPath'], $analysedFilesKeys)) {

0 commit comments

Comments
 (0)