Repository navigation
Build/Test Tools: Derive element shapes for T[] hashes and read a Required. hash marker - #14457
westonruter wants to merge 27 commits into
Conversation
Core commonly declares a list of arrays as `array[]` and documents each element with a single `...$0` entry, as `parse_blocks()` does. The visitor only put shapes on a bare `array`, `object`, or class, so these were left as `array<array>`, and every key read from an element, such as a block's `attrs`, was `mixed`. A `T[]` member now takes the hash when it is a single `...$0` entry typed as that `T`, yielding the same `array<array-key, ...>` a bare `array` would. A bare `array` entry is also accepted for an `array[]` element, which is how the `array[][]` of `wp_print_font_faces()` is written. Named keys beside a `T[]` still describe the array itself, so those hashes are left alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rning one block The return was documented as `array[]` with a `...$0` hash, copied from `parse_blocks()`, but the method returns a single block rather than a list of them. Now that HashNotationVisitor derives element shapes for a `T[]` hash, that described every key of the returned block as another block. Document the block's keys directly instead. Regenerate the `return.type` baseline, from which the two entries for the old `array<array>|null` return type are dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both were documented as a bare `array`, so every block read from them was `mixed`, and the REST controllers that pass the result on to `serialize_blocks()` failed its block parameter. Document them with the same block hash as `parse_blocks()`, which HashNotationVisitor now derives element shapes from. At level 10 this removes 12 errors and introduces 5. The new ones arise because the keys of a `@param` hash are optional, so the blocks the function returns can no longer be shown to have every key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keys of a `@param` hash are optional by default, since the hash lists the keys core reads rather than all a caller must pass. That is wrong for a value whose keys are always set, such as a parsed block, and there was no way for the hash to say so. A `Required.` sentence opening or closing an entry's description now makes its key required, mirroring the `Optional.` marker. Core already writes it both ways, as the first sentence in the abilities and pattern registries, and as the last in the resource hints and the customizer's nav menu items. Only the whole sentence counts, so prose such as "Capability required for the panel" or "Required for multi-widgets" is not read as a marker. `Optional.` still takes precedence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A parsed block always has all five keys, but as `@param` hash keys they were optional, so the function could not show that the blocks it reads or returns still have them. Mark each with the new `Required.` sentence. This removes the three `offsetAccess.notFound` errors on the keys read from each block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
`WP_Widget_Categories::widget()` always receives `before_widget`, `after_widget`, `before_title`, and `after_title`, but as `@param` hash keys they were optional, so each read of them was reported as a possibly missing offset. Mark them with the new `Required.` sentence. This removes the four `offsetAccess.notFound` errors that were deferred as a known limitation when the widget's arguments were first documented as hashes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. Props Bot commandsYou can trigger Props Bot actions by commenting on this PR:
|
A bare `array ...$0` entry was accepted for any `array[]…[]` element, whatever its own hash held. Named keys there typed an array of arrays as a single shape, contradicting the declared type, and an entry without a hash dropped a level of it, widening the type. Both broke the visitor's promise to only ever narrow. The entry's own hash must now describe the elements of that `array[]` in turn, checked recursively, so a mismatched hash leaves the declared type as written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A `T[]` member whose hash describes its elements was counted as a bare member, so a union that also has a bare `array`, as in `array|array[]`, had two of them and no shape was derived at all. On its own that type collapses back to `array[]`, but inside a larger hash the failure propagates and leaves the whole enclosing hash untranslated, which trunk did translate. `T[]` members are now kept apart and only chosen when there is no bare `array` or `object`, so such unions are shaped as they were before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `Optional.` marker has always been matched case-insensitively, but the new `Required.` marker was not, so a lowercase `required.` sentence left its key optional. Only the whole sentence still counts, so prose such as "Something is required." is not read as a marker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The example showed the shape of the blocks `parse_blocks()` returns as open, with a trailing `...`, but a `@return` hash derives a sealed shape listing every key. Show the shape that is actually derived, and describe the depth check for `array[][]` and the preference for a bare `array` in a union beside a `T[]`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
🤖 Comment by Claude Opus 5.5 Code reviewA high-effort code review of this branch at 80c3b18 reported 10 findings. Four were fixed, three needed no change because they are limitations already described in the PR description, and three are left for follow-ups. Fixed
Each fix was checked with No change neededThese are the limitations already described under "Impact" in the PR description:
Worth considering in a follow-up
The full-core PHPStan totals in the PR description were measured before these fixes. Item 1 narrows where the visitor applies, so they may shift slightly. |
The `@return` hash of `get_settings_errors()` described `type` as "Optional.", copied from the `$type` parameter of `add_settings_error()`. But that function always sets `type`, defaulting it to 'error', and `settings_errors()` reads it without checking. Now that the hash is translated into a sealed shape, the marker made that read a possibly missing offset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
🤖 Comment by Claude Opus 5.5 #15264 marks the |
…into phpstan/hash-notation-typed-arrays
There was a problem hiding this comment.
🟡 Changes recommended
The type comparison lowercases case-sensitive shape keys and string literals, allowing mismatched entries to pass and replace the declared type with an incorrect one.
1 open issue
What changed in this PR
This PR improves WordPress’s PHPStan hash-notation support by deriving element shapes for matching T[] declarations and recognizing Required. markers. It also addresses typing limitations identified in the Dashboard Notes work and Categories widget updates.
Changes:
- Derive element shapes from compatible variadic hashes while preserving existing union handling.
- Recognize opening or closing
Required.sentences and document the convention. - Update block, widget, and settings-error docblocks, and adjust PHPStan baselines.
| File | Description |
|---|---|
tests/phpstan/README.md |
Documents element shapes and required-key markers. |
tests/phpstan/HashNotationVisitor.php |
Implements element-shape matching and required-key detection. |
tests/phpstan/baselines/parameter.defaultValue.neon |
Baselines the wp_initialize_site() default-value mismatch. |
tests/phpstan/baselines/argument.type.neon |
Updates the diagnostic for filter_block_kses(). |
src/wp-includes/widgets/class-wp-widget-categories.php |
Marks four display arguments required in the docblock. |
src/wp-includes/blocks.php |
Documents block shapes for resolve_pattern_blocks(). |
src/wp-admin/includes/template.php |
Corrects the settings-error type key’s optionality in documentation. |
phpstan.neon.dist |
Includes the new default-value baseline. |
Review effort: Balanced
lancewillett
left a comment
There was a problem hiding this comment.
Please preserve case-sensitive shape keys and string literals when matching element types. The current comparison accepts array{ID:int}[] with array{id:int} and emits the lowercase-key shape; it likewise turns 'A'[] into an array of 'a'. Compare compound and literal types without lowercasing their content, keeping case-insensitive matching only where PHP type names permit it.
I reproduced both cases through HashNotationVisitor::enterNode() on this PR.
AI review · gpt-6
`describes_elements()` lowercased both the `T` of a `T[]` member and the
type of its `...$0` entry before comparing them. That is right for PHP
type and class names, but shape keys and string literals are
case-sensitive, so `array{ID:int}[]` accepted an `array{id:int}` entry
and the derived type lost the declared `ID` key, and `'A'[]` became an
array of `'a'`.
Compare the types exactly, and fall back to a case-insensitive match only
when both are atomic names. A mismatched hash now leaves the declared
type in place.
Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: eb03d17c-2cd6-47c3-b5ca-1ba8e17bfe08
The previous commit kept shape keys and string literals case-sensitive by
allowing a case-insensitive match only between atomic names. That broke
compound element types that differ only in the case of a name inside
them, such as `WP_Post[][]` with a `wp_post[]` entry, or
`array<int, WP_Post>[]` with `array<int, wp_post>`, which matched before.
Parse both types with PHPStan's PHPDoc type parser instead, and compare
them printed in a normalized form. Names are lowercased and stripped of
a leading backslash, while shape keys, string literals, and template
types keep their case. Parsing also removes differences of spacing and
of redundant parentheses, so `array{ID: int}[]` now matches an
`array{ID:int}` entry, `(int|string)[]` matches `int|string`, and
`\WP_Post[]` matches `WP_Post`.
The types derived for `src/` are unchanged.
Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: eb03d17c-2cd6-47c3-b5ca-1ba8e17bfe08
An element hash only has to match the declared `T` of a `T[]` member, which it may do in another spelling, but the derived type was built from the hash's own. `WP_Post[]` with a `wp_post` entry gave `array<array-key, wp_post>`, naming the class in the wrong case. Take the element type from the declaration instead, so that example now gives `array<array-key, WP_Post>`. Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com> Claude-Code-Session-Id: eb03d17c-2cd6-47c3-b5ca-1ba8e17bfe08
Keeping the declared spelling of an element type printed a union as
`(string | array)`, which `split_union()` reads as a single member. The
bare `array` to shape was then never found, so a hash such as
`(string|array)[]` with a `...$0` entry typed `string|array` stopped
getting a shape at all, where it got
`array<array-key, string|array{id: int, ...}>` before.
Write a union back as its members joined by `|`.
Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com>
Claude-Code-Session-Id: eb03d17c-2cd6-47c3-b5ca-1ba8e17bfe08
A `T[]` member whose `...$0` entry has no hash of its own was still taken as the target, so `@return string[]|false` documented that way gained `@phpstan-return array<array-key, string>|false`, which says nothing the declared type does not. Only treat a `T[]` member as described by its elements when the entry has a hash of its own to narrow them with. Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com> Claude-Code-Session-Id: eb03d17c-2cd6-47c3-b5ca-1ba8e17bfe08
Only the answer of `parses_as_type()` was cached, so the same small set of type strings was lexed and parsed again for every hash. A `T[]` member was parsed by `describes_elements()`, again on recursion, and a third time by `substitute()` to write the element type back. Each comparison also built a new traverser to normalize both types. Cache the parsed nodes in `parse_type()`, and the normalized forms in `normalize_type()`, keyed by the template types in scope. Replace `describes_elements()` with `described_element()`, which returns the declared element type it matched, so `substitute()` reuses it rather than parsing the member again, and no longer asks whether the target is an element twice. The types derived are unchanged. Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com> Claude-Code-Session-Id: eb03d17c-2cd6-47c3-b5ca-1ba8e17bfe08
A description that is nothing but `Required.` already matches the first alternative, which takes the sentence at the start, so the `^` branch of the second one never decided anything. Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com> Claude-Code-Session-Id: eb03d17c-2cd6-47c3-b5ca-1ba8e17bfe08
The docblock of `normalize_type()` said a case-insensitive match on shape keys would make the derived type lose the declared key, and that a lowercased template would be resolved as a class. Since the derived type now keeps the declaration's spelling, neither can happen: the comparison only decides whether a hash describes the declared element type. Say that, and explain the case-sensitive parts as what they are, a different type that contradicts the declaration. Also note that only the docblock's own templates are known, not those of the class. Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com> Claude-Code-Session-Id: eb03d17c-2cd6-47c3-b5ca-1ba8e17bfe08
The `Required.` marker on `href` now makes it a required key of the derived shapes, but neither filter needs one. `wp_preload_resources` accepts an image preloaded by its `imagesrcset` instead, which made the branch handling it unreachable to PHPStan. `wp_resource_hints` skips an array entry without `href`, so documenting it as required hid that branch the same way. Describe what each does without the key instead. Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com> Claude-Code-Session-Id: eb03d17c-2cd6-47c3-b5ca-1ba8e17bfe08
The `Required.` marker made `user_id` a required key of the derived `$args` shape, which contradicted the `array()` default of that same parameter and needed a new `parameter.defaultValue` baseline. The function does not require it: `wp_parse_args()` defaults it to 0, and `wp_insert_site()` documents it as optional and passes it on only when given. Document that default, and that the site is then created without an administrator. The baseline is no longer needed, so it is deleted along with its `includes` entry. Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com> Claude-Code-Session-Id: eb03d17c-2cd6-47c3-b5ca-1ba8e17bfe08
Unlike a preload, which can name an image by its `imagesrcset`, a resource hint has no alternative to `href`. An entry without one is skipped, so leaving it out is always a mistake in the filter, which the required key lets static analysis catch. Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com> Claude-Code-Session-Id: eb03d17c-2cd6-47c3-b5ca-1ba8e17bfe08
The marker was matched in any case, so a description ending in prose such as "i.e. required." would have made its key required, and every call leaving that key out an error. A marker written in lowercase and missed instead only leaves the key optional, as it was before the marker existed, so the narrower match is the safer one. `Optional.` is still matched in any case, since prose taken for it only loosens the type. Every marker in core is already capitalized, so the types derived for `src/` are unchanged. Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com> Claude-Code-Session-Id: eb03d17c-2cd6-47c3-b5ca-1ba8e17bfe08
| * same: a single `...$0` entry documenting the repeated value as a `T`, as | ||
| * the blocks `parse_blocks()` returns are. | ||
| */ | ||
| $element = $this->described_element( $member, $entries ); |
| } elseif ( $node instanceof IdentifierTypeNode && ! isset( $this->templates[ $node->name ] ) ) { | ||
| $node->name = strtolower( ltrim( $node->name, '\\' ) ); | ||
| } |
lancewillett
left a comment
There was a problem hiding this comment.
The previous case-preservation issue is fixed, and the full PHPStan run passed. Both new template findings nevertheless reproduce through the visitor: base leaves these T[] hashes unchanged, while this PR can derive an object shape for an array-bounded template. The latest normalization also newly equates a local template t with the class \t.
I found no affected existing Core consumer; these are bounded tooling counterexamples, not production failures. Please conservatively leave unresolved template element hashes untranslated. No bounds inference, broader template support, or new test matrix is needed here.
AI review · gpt-6

This improves how
HashNotationVisitortranslates hash notation into PHPStan shapes, in two ways.1. Element shapes for
T[]hashesCore commonly declares a list of arrays as
array[]and documents each element with a single...$0entry.parse_blocks()is the canonical example:* @return array[] { * Array of block structures. * * @type array ...$0 { * @type string|null $blockName Name of block. * @type array $attrs Attributes from block comment delimiters. * … * } * }The visitor only derived shapes for a bare
array,object, or class, soarray[]was treated as already more specific than the hash and left asarray<array>. Every key read from an element, such as a block'sattrs, was thereforemixed. That is what caused theCannot access offset 'metadata' on mixederrors inwp_install_defaults()on #13076 (Core-65890).A
T[]member now takes the hash when it is a single...$0entry typed as thatTand carrying a hash of its own, producing the samearray<array-key, …>a barearraywould. An entry without a hash of its own is skipped, since its derived type would only restate the declared one. The two types are compared after parsing them with PHPStan's PHPDoc type parser, so spacing and redundant parentheses do not matter, and class and keyword names match in any case. Shape keys, string literals, and template types are case-sensitive, soarray{id: int}does not pass forarray{ID: int}. The derived type always uses the declared spelling.parse_blocks()now returnsarray<array{blockName: string|null, attrs: array, innerBlocks: array<array>, innerHTML: string, innerContent: array}>. A barearrayentry is also accepted for anarray[]element, which is how thearray[][]ofwp_print_font_faces()is written, but only when its own hash goes on to describe the inner arrays the same way, so a mismatched hash cannot contradict or widen the declared type. A barearraybeside aT[]in a union, as inarray|array[], keeps taking the shape, as it did before. Named keys beside aT[]still describe the array itself rather than its elements, as the 250+string[]hashes keyed by name do, so those are left alone.This change exposed two docblock bugs, which are corrected:
WP_Block_Processor::extract_full_block_and_advance()was documented as returningarray[](copied fromparse_blocks()), but it returns a single block.@returnhash ofget_settings_errors()described each error'stypeasOptional.(copied from the$typeparameter ofadd_settings_error()), butadd_settings_error()always sets it, andsettings_errors()reads it unguarded.resolve_pattern_blocks()is also documented with the block hash, as both the REST block patterns and templates controllers pass its result toserialize_blocks().2. A
Required.marker for hash keysKeys of a
@paramhash are optional by default, since a hash lists the keys core reads rather than all a caller must pass. That is wrong for a value whose keys are always set, such as a parsed block, and there was no way to say so. This has come up repeatedly.A
Required.sentence opening or closing an entry's description now makes a@paramkey required, mirroringOptional.. Core already writes it both ways: as the first sentence (abilities, block patterns, pattern categories, connectors, icons, font collections, the filesystem classes, and others), and as the last (resource hints, prefetch, and the customizer's nav menu auto-drafts). Only the whole sentence counts, so prose such as "Capability required for the panel" or "Required for multi-widgets, …" is not read as a marker. UnlikeOptional., it must be capitalized: prose mistaken forRequired.would report an error at every call that leaves the key out, while prose mistaken forOptional.only loosens the type.Optional.still takes precedence. This is documented intests/phpstan/README.md.3. Follow-up to the
WP_Widget_Categoriestyping in r64413When the Categories widget's arguments were documented as hashes in #8071 (committed in r64413, Core-62762), the lack of a way to mark a
@paramkey as required was raised forWP_Widget_Categories::widget(), whose display arguments are always passed. The PHPStan impact comment on that PR then deferred the resulting errors as a known limitation, to be "addressed in the tooling separately than worked around here."With the
Required.marker,before_widget,after_widget,before_title, andafter_titleare now marked as required. This removes the four deferredoffsetAccess.notFounderrors, takingclass-wp-widget-categories.phpfrom 11 errors to 7 at level 10, with none introduced.The other three errors deferred in that comment remain, as they come from a different limitation: a
@paramshape is always open. The filtered arguments passed towp_dropdown_categories()andwp_list_categories()cannot rule out an extra key with an unacceptable value, andupdate(), which starts from the open$old_instance, cannot satisfy its sealed@returnshape. Closing a@paramshape would need a convention of its own.Impact
Level-10 PHPStan totals, cold result cache, before and after. These were measured at c1535e9; the
get_settings_errors()correction in 099446c removes one more error:trunkThe introduced errors are not regressions in the changed code; they are existing issues that the sharper types make visible:
has_meta()(from$wpdb->get_results()),get_settings_errors()(a global merged from a transient),get_allowed_block_template_part_areas()andget_default_block_template_types()(filtered values whose hook@paramkeys are optional), andparse_blocks()itself (the parser class comes from theblock_parser_classfilter).arrayormixedwhere a shape is now expected:serialize_blocks()inWP_Classic_To_Block_Menu_Converter,wp_print_font_faces()from style variations,render_block()after a block is nulled out, the pattern and category registries inblock-patterns.php, andWP_Customize_Nav_Menus::insert_auto_draft_post().resolve_pattern_blocks(): an open@paramshape cannot flow into a sealed@returnshape,$innerBlockscannot be typed recursively, and...$0lists arearray-keyrather thanlistkeyed.WP_Block_Processor::extract_full_block_and_advance()buildsinnerHTMLfromget_html_content(), which may returnnull, and appends its own nullable result toinnerBlocks.Two existing
Required.markers contradicted the code, and are corrected rather than honored:wp_initialize_site()documenteduser_idasRequired., while$argsdefaults toarray()anduser_idfalls back to0viawp_parse_args(). It is now documented as defaulting to 0, in which case the site is created without an administrator.wp_preload_resourcesdocumentedhrefasRequired., while an image may be preloaded by itsimagesrcsetalone. It is now documented as optional in that case.These corrections and the later refinements to element matching came after the totals above were measured, and the totals have not been re-measured since. The types derived for
src/change only for these two docblocks.The baselines are regenerated: the two entries for the old
extract_full_block_and_advance()return type are dropped, and thefilter_block_kses()entry is reworded.Possible follow-ups
Required.on filter hook hashes. Many of the remainingoffsetAccess.notFounderrors on optional keys come from a filter's hook docblock documenting the filtered value as a@paramhash, after which core reads the keys unguarded. The email filters are the largest cluster: for example,auto_core_update_emailis read aswp_mail( $email['to'], …, $email['subject'], $email['body'], $email['headers'] ), and marking its four keysRequired.removes all four errors there with none introduced. Similar clusters appear inpluggable.php,class-wp-recovery-mode-email-service.php,user.php,ms-functions.php, andfunctions.php(includingupload_dir).@paramshape. Several remaining errors, inresolve_pattern_blocks(),WP_Widget_Categories::update(), and thedefault_template_typesanddefault_wp_template_part_areasfilters, come from an open@param(or hook) shape flowing into a sealed@returnshape. Marking those keysRequired.only rewords the error.@phpstan-typealias for parsed blocks, in place of the copies of the block hash, which would also typeinnerBlocks.Trac ticket: https://core.trac.wordpress.org/ticket/65817
Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5.5
Used for: Implementing the visitor changes and docblock fixes, surveying core's hash notation usage, running and triaging the PHPStan comparisons, and drafting this description; the approach (including the
Required.convention) was directed by me, and the changes were reviewed by me.This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.
🤖 Generated with Claude Code