Skip to content

Build/Test Tools: Derive element shapes for T[] hashes and read a Required. hash marker - #14457

Open
westonruter wants to merge 12 commits into
WordPress:trunkfrom
westonruter:phpstan/hash-notation-typed-arrays
Open

westonruter wants to merge 12 commits into
WordPress:trunkfrom
westonruter:phpstan/hash-notation-typed-arrays

Conversation

@westonruter

@westonruter westonruter commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

This improves how HashNotationVisitor translates hash notation into PHPStan shapes, in two ways.

1. Element shapes for T[] hashes

Core commonly declares a list of arrays as array[] and documents each element with a single ...$0 entry. 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, so array[] was treated as already more specific than the hash and left as array<array>. Every key read from an element, such as a block's attrs, was therefore mixed. That is what caused the Cannot access offset 'metadata' on mixed errors in wp_install_defaults() on #13076 (Core-65890).

A T[] member now takes the hash when it is a single ...$0 entry typed as that T, producing the same array<array-key, …> a bare array would. parse_blocks() now returns array<array{blockName: string|null, attrs: array, innerBlocks: array<array>, innerHTML: string, innerContent: array}>. A bare array entry is also accepted for an array[] element, which is how the array[][] of wp_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 bare array beside a T[] in a union, as in array|array[], keeps taking the shape, as it did before. Named keys beside a T[] 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 returning array[] (copied from parse_blocks()), but it returns a single block.
  • The @return hash of get_settings_errors() described each error's type as Optional. (copied from the $type parameter of add_settings_error()), but add_settings_error() always sets it, and settings_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 to serialize_blocks().

2. A Required. marker for hash keys

Keys of a @param hash 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 @param key required, mirroring Optional.. Core already writes it both ways: as the first sentence in 29 entries across 13 files (abilities, block patterns, pattern categories, connectors, icons, wp_initialize_site(), the filesystem classes, and others), and as the last sentence in 5 (resource hints and preloads, prefetch, and the customizer's nav menu auto-drafts). Like Optional., it is matched case-insensitively, but 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. This is documented in tests/phpstan/README.md.

3. Follow-up to the WP_Widget_Categories typing in r64413

When the Categories widget's arguments were documented as hashes in #8071 (committed in r64413, Core-62762), the lack of a way to mark a @param key as required was raised for WP_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, and after_title are now marked as required. This removes the four deferred offsetAccess.notFound errors, taking class-wp-widget-categories.php from 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 @param shape is always open. The filtered arguments passed to wp_dropdown_categories() and wp_list_categories() cannot rule out an extra key with an unacceptable value, and update(), which starts from the open $old_instance, cannot satisfy its sealed @return shape. Closing a @param shape 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:

Errors
trunk 27,374
This branch 27,330
Net −44 (63 fixed, 19 introduced)

The introduced errors are not regressions in the changed code; they are existing issues that the sharper types make visible:

  • Return types a hash promises that the implementation cannot prove: has_meta() (from $wpdb->get_results()), get_settings_errors() (a global merged from a transient), get_allowed_block_template_part_areas() and get_default_block_template_types() (filtered values whose hook @param keys are optional), and parse_blocks() itself (the parser class comes from the block_parser_class filter).
  • Callers passing a plain array or mixed where a shape is now expected: serialize_blocks() in WP_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 in block-patterns.php, and WP_Customize_Nav_Menus::insert_auto_draft_post().
  • Limits of hash notation in resolve_pattern_blocks(): an open @param shape cannot flow into a sealed @return shape, $innerBlocks cannot be typed recursively, and ...$0 lists are array-key rather than list keyed.
  • WP_Block_Processor::extract_full_block_and_advance() builds innerHTML from get_html_content(), which may return null, and appends its own nullable result to innerBlocks.

Separately, wp_initialize_site() documents $user_id as Required., while $args defaults to array() and user_id falls back to 0 via wp_parse_args(), so the docblock and the code disagree. The resulting default-value error is baselined rather than counted above.

The baselines are regenerated: the two entries for the old extract_full_block_and_advance() return type are dropped, the filter_block_kses() entry is reworded, and the wp_initialize_site() default value is added.

Possible follow-ups

  • Required. on filter hook hashes. Many of the remaining offsetAccess.notFound errors on optional keys come from a filter's hook docblock documenting the filtered value as a @param hash, after which core reads the keys unguarded. The email filters are the largest cluster: for example, auto_core_update_email is read as wp_mail( $email['to'], …, $email['subject'], $email['body'], $email['headers'] ), and marking its four keys Required. removes all four errors there with none introduced. Similar clusters appear in pluggable.php, class-wp-recovery-mode-email-service.php, user.php, ms-functions.php, and functions.php (including upload_dir).
  • A way to close a @param shape. Several remaining errors, in resolve_pattern_blocks(), WP_Widget_Categories::update(), and the default_template_types and default_wp_template_part_areas filters, come from an open @param (or hook) shape flowing into a sealed @return shape. Marking those keys Required. only rewords the error.
  • A recursive @phpstan-type alias for parsed blocks, in place of the copies of the block hash, which would also type innerBlocks.

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

westonruter and others added 6 commits October 17, 2026 22:19
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>
@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The 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

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

`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>
@westonruter
westonruter marked this pull request as ready for review October 18, 2026 06:10
@github-actions

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props westonruter.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

westonruter and others added 4 commits October 18, 2026 15:45
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>
@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5.5

Code review

A 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

  1. A bare array entry under array[][] was not checked for depth. 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, and an entry with no hash dropped a level, so the derived type could contradict or widen the declared one. The entry's own hash must now describe the inner arrays in turn, checked recursively. Fixed in a06dd95.
  2. array|array[] unions lost their shape. A matching T[] member was counted as a bare member, so a union that also has a bare array had two of them and no shape was derived. At the top level the result collapses back to array[] either way, but inside a larger hash the failure propagated and left the whole enclosing hash untranslated (array instead of array{y?: array<array>, z: string, ...}), which trunk did translate. T[] members are now chosen only when there is no bare array or object. Fixed in 1b38fba.
  3. Required. was matched case-sensitively, unlike Optional.. A lowercase required. sentence left its key optional. It is now case-insensitive, while prose such as "Something is required." is still not read as a marker. Fixed in b106227.
  4. The README showed parse_blocks()'s derived shape as open. A @return hash derives a sealed shape, so the example now shows the shape that is actually derived, and describes the array[][] depth rule and the bare-array preference from items 1 and 2. Fixed in c1535e9.

Each fix was checked with \PHPStan\dumpType() against the cases the review raised.

No change needed

These are the limitations already described under "Impact" in the PR description:

  • resolve_pattern_blocks() errors on unchanged lines (src/wp-includes/blocks.php). An open @param shape cannot flow into a sealed @return shape, $innerBlocks is a plain array[] and so cannot be typed recursively, and ...$0 lists are keyed by array-key rather than list. As they are on lines the branch did not change, phpstan-diff does not report them, but they are counted in the full comparison.
  • wp_initialize_site() (src/wp-includes/ms-site.php). Its $user_id is documented as Required., while $args defaults to array() and user_id falls back to 0 via wp_parse_args(). The marker exposes that disagreement, and it is baselined in 0654a0f. Deciding whether the marker or the default is wrong is a separate change.
  • Required. applies to the existing core hashes that use it. This is intended: those markers now take effect, and the call sites they flag (for example WP_Customize_Nav_Menus::insert_auto_draft_post()) are existing contract violations made visible.

Worth considering in a follow-up

  • A @phpstan-type alias for parsed blocks. The parsed-block hash is now copied in parse_blocks(), serialize_blocks(), resolve_pattern_blocks() (twice), and WP_Block_Processor::extract_full_block_and_advance(), and the copies already differ by their Required. markers. A single recursive alias, such as Parsed_Block, would type innerBlocks once for every use, and would clear the resolve_pattern_blocks() errors above at the root.
  • WP_Block_Processor::extract_full_block_and_advance() returns ?array but never returns null. The docblock keeps array|null to match the native return type, but callers have to handle a null that cannot happen, and the recursive call appends the result to innerBlocks unchecked. Narrowing the signature to array is a change of its own.
  • A bug in resolve_pattern_blocks(), unrelated to typing. When a recursive pattern is removed with array_splice( $blocks, $i, 1 ), its null marker stays in $inner_content, so the parent's innerContent has one more null than it has innerBlocks. serialize_block() can then read innerBlocks past its end, raising an undefined array key warning. This deserves its own Trac ticket.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant