Repository navigation
Resolve offsetSet() template types for ArrayAccess offset writes - #6903
Draft
westonruter wants to merge 8 commits into
Draft
westonruter wants to merge 8 commits into
westonruter wants to merge 8 commits into
Conversation
An offset write `$object[$offset] = $value` was checked against the
parameters of offsetSet() taken from getOnlyVariant(), so method-level
template types were never resolved. With an offsetSet() declared as
@template TOffset of key-of<T>
@PARAM TOffset $offset
@PARAM T[TOffset] $value
the accepted offset type stayed the unresolved TOffset and the accepted
value type T[TOffset], so every write was rejected, including valid
ones. The explicit $object->offsetSet($offset, $value) call was already
accepted, since a method call resolves the templates from its arguments.
ObjectType::setOffsetValueType() now selects the offsetSet() variant
from the written offset and value types, as a call does.
OffsetAccessValueAssignmentRule passes the written offset type, not
mixed, so T[TOffset] resolves to the value type for that key. When the
offset itself is rejected, the rule falls back to mixed, since
OffsetAccessAssignmentRule already reports that offset.
The native ObjectType in turbo-ext gets the same change.
Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
A plain read `$object[$offset]` is analysed as a simulated offsetGet()
call, so a method-level `@template TOffset of key-of<T>` with
`@return T[TOffset]` already resolved for it. Compound assignments
(`??=`, `+=`, `.=`) read the current value through
ObjectType::getOffsetValueType() instead, which took offsetGet()'s
return type from getOnlyVariant() with TOffset unresolved. For
`Options<array{name: string, age: int}>`, `$options['age'] ??= 5` read
the value as `int|string` and reported it as not accepted, and
`$options['age'] += 1` was inferred as an error type.
getOffsetValueType() now selects the offsetGet() variant from the
offset type, as setOffsetValueType() does for offsetSet().
In the native ObjectType, the getMethod() lookup and the variant
selection move into helpers that the offsetGet() and offsetSet() paths
share.
Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
Resolving the offsetGet()/offsetSet() variant against the offset and value types runs on every ArrayAccess read in a compound assignment and on every ArrayAccess write. Most ArrayAccess classes (ArrayObject, collections, option bags) declare no method-level template types, so the resolution only allocated a resolved variant equal to the original. When the method has a single variant without template or late-resolvable types, it is now used as is, as before. On a file of 6,000 writes and compound assignments to an ArrayObject and a plain ArrayAccess<int, int> class, analysis took about 15% more user CPU time than before the resolution was added. It now takes about 2% more, which is within the noise of that measurement. The native ObjectType gets the same fast path. Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com> Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
OffsetAccessValueAssignmentRule probed every write with setOffsetValueType($dimType, mixed) to decide which offset type to check the value against, and then called setOffsetValueType() again with the value. It now checks the write with the written offset first, and only if that is rejected does it probe the offset alone, falling back to a mixed offset when the offset itself is rejected. The outcome is the same, but an accepted write costs one setOffsetValueType() call instead of two. Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com> Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
With a union offset such as 'name'|'age', TOffset resolves to the union and T[TOffset] to the union of the value types for those keys, so a value of either key's type is accepted and a value of neither is reported. An offset that is only partly known, such as 'name'|'nope', is reported once by OffsetAccessAssignmentRule, and OffsetAccessValueAssignmentRule does not report its value as well. Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com> Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
For an append (`$object[] = $value`), OffsetAccessValueAssignmentRule checked the value against a mixed offset, while the write itself goes through ObjectType::setOffsetValueType() with a null offset. Since offsetSet()'s parameters are now resolved against the written offset, a value type depending on the offset, such as `@param ($offset is null ? int : string) $value`, resolved to `int|string` for the check and `$object[] = 'str'` was no longer reported. The rule now passes the append's null offset on to setOffsetValueType(), and falls back to a mixed offset only when the append itself is rejected, as for any other rejected offset. Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com> Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
The native ObjectType now selects the offsetGet()/offsetSet() variant from the offset and value types, but type-family.php only probed the offset operations on plain ObjectType(ArrayAccess) and ArrayObject, which never reach the template resolution. A fixture adds an ArrayAccess class whose offsetGet() and offsetSet() declare `@template TOffset of key-of<TArray>`, and one whose offsetSet() value type is conditional on the offset. getOffsetValueType(), hasOffsetValueType() and setOffsetValueType(), appends included, are observed on them with known, unknown and union keys, alongside a generic ArrayObject for the path without anything to resolve. Against a build of the native ObjectType without the variant selection, 96 of these observations differ from the PHP twin. Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com> Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
The private ObjectType::selectArrayAccessVariant() added for the ArrayAccess fast path is part of the declarations that turbo-ext/bin/generate-declarations.php derives from the PHP class, so turbo-ext/src/generated/ObjectType.h was stale and turbo-ext/bin/side-by-side.php reported it. Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com> Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An offset write
$object[$offset] = $valuewas checked against the parameters ofoffsetSet()taken fromgetOnlyVariant(), so method-level template types were never resolved. With anoffsetSet()declared like this:the accepted offset type stayed the unresolved
TOffset, and the accepted value type stayedTArray[TOffset]. As a result, every write was rejected, valid ones included (offsetAssign.dimTypeandoffsetAssign.valueType). An explicit$object->offsetSet($offset, $value)call was already accepted, since a method call resolves the templates from its arguments.Compound assignments had the same problem on the read side. A plain read
$object[$offset]is analysed as a simulatedoffsetGet()call, so it already resolved@return TArray[TOffset]. But??=,+=, and.=read the current value throughObjectType::getOffsetValueType(), which also usedgetOnlyVariant(). ForOptions<array{name: string, age: int}>,$options['age'] ??= 5was reported as "does not accept int|string", and$options['age'] += 1was inferred as an error type.Changes
ObjectType::setOffsetValueType()selects theoffsetSet()variant from the written offset and value types, andObjectType::getOffsetValueType()selects theoffsetGet()variant from the offset type, withParametersAcceptorSelector::selectFromTypes(), as a method call does.ArrayObject, most collections), that variant is used as is, without resolution. On a file of 6,000 writes and compound assignments to anArrayObjectand a plainArrayAccess<int, int>class, resolving every access cost about 15% more user CPU time. With this fast path, the difference is about 2% without the turbo extension and about 1% with it, which is within the noise of that measurement (6 interleaved runs each).OffsetAccessValueAssignmentRulechecks the value against the written offset (nullfor an append) instead ofmixed, soTArray[TOffset], or a conditional type such as($offset is null ? int : string), resolves for that key. When the offset itself is rejected, the rule falls back tomixed, sinceOffsetAccessAssignmentRulealready reports that offset. An accepted write costs onesetOffsetValueType()call.ObjectTypeinturbo-extgets the same changes.turbo-ext/tests/type-family.phpgains observations on a templatedArrayAccessfixture, an offset-conditional one, and a genericArrayObject. Against a build of the nativeObjectTypewithout these changes, 96 of them differ from the PHP twin.turbo-ext/src/generated/ObjectType.his regenerated for the new private method.Left unchanged:
ObjectType::hasOffsetValueType()still readsoffsetSet()throughgetOnlyVariant(). ForArrayAccess, it answers only no or maybe, and the bound of the unresolvedTOffset(key-of<TArray>) already gives the right "no". Resolving the template gave identical results forunset,isset,??, compound assignments, and union keys.AssignHandler::collectOffsetSetUsage()also still usesgetOnlyVariant(). WithunresolvedTemplateArgumentsenabled, an array-access write already infers the same template arguments as the equivalent explicitoffsetSet()call.Known limitation and a possible follow-up
setOffsetValueType()returns a singleErrorTypewhether the offset or the value is rejected. SoOffsetAccessValueAssignmentRuletells the two apart by probing: it checks the write, then the offset alone, and then the value against amixedoffset when the offset itself is rejected. As a result, when an offset is only partly accepted, a wrong value for its accepted part is not reported separately. For example, withOptions<array{name: string, age: int}>and'age'|'nope' $key,$options[$key] = 'foo'gets onlyCannot assign offset 'age'|'nope' …fromOffsetAccessAssignmentRule, not also a value error for'age'. The line is still reported once. Before this change, it got both errors, as did every valid write.A way to remove the probing would be for
ObjectTypeto expose the accepted offset and value types of the resolvedoffsetSet()variant. The rule could then check the value against the value type for the accepted part of the offset, with nomixedfallback, and a rejected write would cost one variant resolution instead of up to three. That would add API toObjectType(and its native twin), so it is left out of this PR. It could be added if wanted.Tests
The regression fixture starts with the issue's playground snippet verbatim. Further cases follow it: an unknown key, an append, a wrong value for a second key, an
array<string, mixed>map, compound assignments, and union keys.tests/PHPStan/Analyser/nsrt/bug-8549.phpcovers the inferred types of reads and compound assignments. Each new test fails without its source change and passes with it. The full test suite passes with and without the turbo extension loaded, as doesturbo-ext/tests/smoke.php.make lint-turboandmake sanitize-turbowere not run locally.Where this came up
This was found in Gutenberg, through WordPress/gutenberg#86110. In the
php-stubs/wordpress-stubspackage,WP_REST_Requestis declared@template T of array = array<string, mixed>, and itsoffsetSet()andset_param()both use theTOffset of key-of<T>/T[TOffset]pattern above. Gutenberg's attachments controller builds a request with array access:PHPStan reported both writes, although
Tisarray<string, mixed>and both are valid:The errors had been suppressed in Gutenberg's PHPStan baseline. That PR removes the baseline entries by rewriting the writes as
$response_request->set_param( 'context', 'edit' )and$response_request->set_param( '_fields', $request['_fields'] ). The workaround passes becauseset_param()is analysed as a method call, which resolvesTOffsetfrom its argument. Only the array-access path skipped that step. Array access onWP_REST_Requestis idiomatic across WordPress core and plugins, though, so rewriting call sites to avoid a false positive is not a general fix.With this change, the original array-access code passes as it is, so the baseline entries can be dropped without the rewrite. Array-access writes also get the same per-key checking a
set_param()call gets. WithWP_REST_Request<array{context: 'view'|'edit'|'embed', _fields?: string}>,$request['context'] = 'edit'is accepted, while$request['context'] = 'nonsense',$request['_fields'] = 1, and$request['nope'] = 'x'are reported. Before this change, all of these writes were reported, valid and invalid alike, so the errors carried no signal.Closes phpstan/phpstan#8549
🤖 Generated with Claude Code using Claude Opus 5.5 · high effort