Prevent "Unresolvable type" errors when mapping constant arrays#5936
Conversation
…:mapValueType - In bleeding edge, a sealed constant array carries an `unsealed` descriptor of `[never, never]` (explicit never) meaning "no extra elements". `mapValueType` applied the callback to that sentinel value type, which for `array_map` (and the str_replace family, both routed through `mapValueType`) resolves the callback against a `never` argument and yields an `ErrorType`. - The `ErrorType` was hidden inside the sealed array's unsealed slot, so `UnresolvableTypeHelper` (which traverses the whole type) reported false-positive `function.unresolvableReturnType` / `argument.unresolvableType` errors even though the visible inferred type was correct. - Guard `mapValueType` to leave the unsealed sentinel untouched when the array is sealed (`isUnsealed()->no()`), only mapping the unsealed value type for genuinely (or maybe) unsealed arrays. - Probed the sibling transforms `traverse` / `traverseSimultaneously`, nested `array_map`, `str_replace`, generic identity pass-through, array spread and `+` union: all leaks originate from the single `mapValueType` chokepoint and are fixed at the source; the sibling traversals only propagate types and never plant the `ErrorType`.
staabm
left a comment
There was a problem hiding this comment.
do we have similar bugs in other areas where we check explictly for $this->unsealed === null but do not check $this->isUnsealed()->no()?
if so, come up with test-cases and fixes
Audit of every ConstantArrayType method that reads or transforms the `[never, never]` unsealed sentinel under bleeding edge: only mapValueType applies a value-projecting callback (array_map's user closure body) that can turn the never sentinel into an ErrorType. The sibling transforms (traverse, traverseSimultaneously, generalizeValues, filterArrayRemovingFalsey, changeKeyCaseArray) only map never to never, and array-rebuilding operations (array_reverse, array_values, array_merge, spread) drop and recreate the sentinel, so none of them plant an ErrorType. These regression tests lock in that behavior across the whole operation family (array_map nesting, str_replace, generic pass-through, +, spread, array_filter, array_reverse, array_values, array_merge, array_change_key_case). They fail without the mapValueType guard and pass with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Pushed. Here's my response to the reviewer's question. Addressing the review
I audited every site in Conclusion:
The empirical test was decisive: with the fix reverted, array-rebuilding ops ( What I addedSince the PR description said several analogous cases were manually probed but only
I confirmed these tests fail without the |
|
|
||
| $newUnsealed = $this->unsealed === null | ||
| ? null | ||
| $newUnsealed = $this->unsealed === null || $this->isUnsealed()->no() |
There was a problem hiding this comment.
would it make sense to prevent writing a type into $this->unsealed which is considered->isUnsealed()->no() instead ?
There was a problem hiding this comment.
I've addressed the reviewer's suggestion. Here's a summary:
What the reviewer asked
would it make sense to prevent writing a type into
$this->unsealedwhich is considered->isUnsealed()->no()instead?
Rather than guarding the single mapValueType call site, the reviewer suggested preventing the bad write at the source.
What I changed
src/Type/Constant/ConstantArrayType.php:
-
Constructor normalization (the new fix): Whenever a
ConstantArrayTypeis constructed with a sealed unsealed descriptor — i.e. the unsealed key is the explicitneversentinel, exactly theisUnsealed()->no()condition — the constructor now forces the unsealed value slot back tonew NeverType(true). Since the value slot of a sealed array represents "no extra elements," it is never a real element type, so any projected/transformed type a caller tries to plant there (e.g. theErrorTypefrom mappingnever->value) is discarded. -
Reverted the
mapValueTypeper-call-site guard back to its simpler original form.recreate()routes every transform throughnew self(...), so the constructor normalization now protects all transform sites (mapValueType,traverse,generalizeValues, etc.) centrally — a strictly more robust answer to staabm's earlier "do other areas have the same bug?" question.
Verification
- Confirmed the constructor normalization is load-bearing: with it neutralized (and the old guard already removed), the bug resurfaces with 8 bogus
unresolvableTypeerrors across the sibling cases; with it in place, all green. Bug14844Test(bothtestBug14844andtestBug14844Siblings) — pass.nsrt/bug-14844.phptype inference — passes (array{'foo'}).ConstantArrayTypeTest(182), fullNodeScopeResolverTest(1662), alltests/PHPStan/Type/(2961) — green.make phpstan— no errors.
Committed as d81b07fde and pushed.
Centralize protection of the sealed `[never, never]` unsealed sentinel: whenever a ConstantArrayType is sealed (its unsealed key is the explicit `never`), force the unsealed value slot back to the explicit-never sentinel. This prevents any caller from planting a projected/transformed type (e.g. an ErrorType produced by mapping the sentinel as if it were a real element) into the slot, where UnresolvableTypeHelper would later flag it as a bogus "contains unresolvable type" error. This supersedes the per-call-site guard in mapValueType, which is now reverted to its simpler form since recreate() routes every transform through the constructor normalization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
add one more test in Bug14844Test for https://phpstan.org/r/caf9ff47-8bf2-4d60-9786-8ce9c1273fa7 (which proves the initial bug is not related to enums only)
Map a class-constant fetch (`$type::FOO`) through array_map's callback instead of an enum-case property fetch. It routes through the same mapValueType chokepoint and must not plant an ErrorType in the sealed `[never, never]` unsealed sentinel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Done. I addressed staabm's review by adding the requested test for the playground. What I didThe playground is a class-constant variant of the same sealed-sentinel bug: instead of mapping an enum-case property fetch ( Changes
Verification
|
Summary
With bleeding edge enabled, calling a generic function whose return type is inferred
from an arrow function returning
array_map(fn (Someenum $t) => $t->value, Someenum::cases())produced false-positive errors:
even though the inferred type (
array{'foo'}) was correct. This PR stopsarray_map(and other
mapValueTypecallers) from planting anErrorTypeinside the sealedarray's internal
unsealeddescriptor.Changes
src/Type/Constant/ConstantArrayType.php:mapValueType()no longer applies thecallback to the
unsealedvalue type when the array is sealed (isUnsealed()->no()),i.e. when that slot is the explicit-never sentinel. It is left untouched for sealed
arrays and still mapped for genuinely/maybe-unsealed arrays.
tests/PHPStan/Rules/Functions/Bug14844Test.php(+ datadata/bug-14844.php) runningCallToFunctionParametersRuleunder bleeding edge andasserting no errors.
tests/PHPStan/Analyser/nsrt/bug-14844.phppinning the inferred type to
array{'foo'}.Root cause
In bleeding edge a sealed
ConstantArrayTypecarries anunsealeddescriptor of[never, never](explicit never) representing "this array has no extra elements". Thevalue-type slot of that sentinel is not a real element type.
ConstantArrayType::mapValueType()mapped the unsealed value type through the suppliedcallback. For
array_mapthe callback resolves the user callback against the array'svalue type; given the
neversentinel it evaluatesnever->value(an enum-caseproperty fetch on
never), which produces anErrorType. ThatErrorTypewas storedinside the resulting array's
unsealedslot — invisible indescribe()but visited byUnresolvableTypeHelper, which walks the entire type tree and flags anyErrorType,hence the bogus "contains unresolvable type" reports.
The pattern is "transforming the sealed sentinel as if it were a real element type". The
single affected location is
mapValueType, which is the chokepoint for every value-mapoperation (
array_mapviaArrayMapFunctionReturnTypeExtension, the str_replace familyvia
ReplaceFunctionsDynamicReturnTypeExtension, etc.).Test
Bug14844Test::testBug14844reproduces the reported false positives under bleedingedge and asserts the call analyses cleanly. It fails before the fix with the two
unresolvableTypeerrors and passes after.nsrt/bug-14844.phpasserts the inferred type isarray{'foo'}.mapValueTypeguard, no separate bug):str_replaceover a mapped enum-case array,nested
array_map, passing the sealed array through a generic identity function,array spread
[...$arr], and+array union. The sibling transformstraverse/traverseSimultaneouslyonly propagate existing types and never plantthe
ErrorType, so they need no change.Fixes phpstan/phpstan#14844