Skip to content

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

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

westonruter wants to merge 27 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 and carrying a hash of its own, producing the same array<array-key, …> a bare array would. 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, so array{id: int} does not pass for array{ID: int}. The derived type always uses the declared spelling. 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 (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. Unlike Optional., it must be capitalized: prose mistaken for Required. would report an error at every call that leaves the key out, while prose mistaken for Optional. only loosens the type. 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.

Two existing Required. markers contradicted the code, and are corrected rather than honored:

  • wp_initialize_site() documented user_id as Required., while $args defaults to array() and user_id falls back to 0 via wp_parse_args(). It is now documented as defaulting to 0, in which case the site is created without an administrator.
  • wp_preload_resources documented href as Required., while an image may be preloaded by its imagesrcset alone. 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 the filter_block_kses() entry is reworded.

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

github-actions Bot commented Oct 18, 2026 •

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.

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

Props westonruter, lancewillett.

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


Props Bot commands

You can trigger Props Bot actions by commenting on this PR:

  • @propsbot add <username> to give props to a GitHub user. Requires write access.
  • @propsbot remove <username> to leave a user out of the props. Requires write access.
  • @propsbot refresh to refresh this list. Adding the props-bot label works too.

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>
@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5.5

#15264 marks the $min and $max keys of the oembed_min_max_width filter's @param hash as Required. (as the last sentence of each description), in anticipation of this PR being merged. Once it is, that hash will be read as array{ min: int, max: int } rather than with optional keys.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread tests/phpstan/HashNotationVisitor.php Resolved

@lancewillett lancewillett left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

westonruter and others added 4 commits October 31, 2026 23:00
`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
westonruter and others added 9 commits October 31, 2026 23:51
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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Template handling conflates case-sensitive template identifiers with class names and generates invalid object shapes for array-bounded templates.

2 open issues
1 resolved since last review

Review effort: Balanced

* 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 );
Comment on lines +857 to +859
} elseif ( $node instanceof IdentifierTypeNode && ! isset( $this->templates[ $node->name ] ) ) {
$node->name = strtolower( ltrim( $node->name, '\\' ) );
}

@lancewillett lancewillett left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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.

3 participants