From a7a414f881082d7774b745c4f94a176aba5c7930 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 17 Oct 2026 22:19:43 -0700 Subject: [PATCH 01/12] Derive element shapes for `T[]` hashes in HashNotationVisitor 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`, 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` 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 --- tests/phpstan/HashNotationVisitor.php | 53 +++++++++++++++++++++++++-- tests/phpstan/README.md | 2 +- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/tests/phpstan/HashNotationVisitor.php b/tests/phpstan/HashNotationVisitor.php index 3e786bc67db7e..fc6b8c3d65974 100644 --- a/tests/phpstan/HashNotationVisitor.php +++ b/tests/phpstan/HashNotationVisitor.php @@ -57,7 +57,9 @@ * or `object`, or a class, on its own or as one member of a union such as * `string|array` or `array|WP_Error`, where the bare member is the one the * hash is about. A type that is already more specific than the hash, such as - * `array`, is left as written. A shape derived for a + * `array`, is left as written, except for a `T[]` whose + * hash is a single `...$0` entry typed as that `T`, which describes the + * elements the way a hash on a bare `array` would. A shape derived for a * named class is intersected with it, as `stdClass&object{...}`, because an * object shape is structural on its own and one derived from a bare `object` * would not be assignable to a property declared `stdClass`. @@ -600,8 +602,21 @@ private function substitute( string $declared, array $entries, bool $for_param ) $bare = array(); $shapeable = array(); + $elements = array(); foreach ( $members as $position => $member ) { + /* + * A `T[]` member describes its elements, so it takes a hash that does the + * same: a single `...$0` entry documenting the repeated value as a `T`, as + * the blocks `parse_blocks()` returns are. It is then as bare as an `array`, + * since the hash is all there is to say about its keys. + */ + if ( $this->describes_elements( $member, $entries ) ) { + $bare[] = $position; + $elements[] = $position; + continue; + } + if ( ! $this->is_shapeable( $member ) ) { continue; } @@ -631,8 +646,9 @@ private function substitute( string $declared, array $entries, bool $for_param ) $member = $members[ $target ]; $normalized = strtolower( $member ); - $named = 'array' !== $normalized && 'object' !== $normalized; - $shape = $this->resolve_container( $entries, $for_param, 'array' === $normalized, $named ); + $is_array = 'array' === $normalized || in_array( $target, $elements, true ); + $named = ! $is_array && 'object' !== $normalized; + $shape = $this->resolve_container( $entries, $for_param, $is_array, $named ); if ( null === $shape ) { return null; } @@ -683,6 +699,37 @@ private function is_shapeable( string $member ): bool { return preg_match( '#^\\\\?[A-Za-z_][A-Za-z0-9_]*(?:\\\\[A-Za-z_][A-Za-z0-9_]*)*$#', $member ) === 1; } + /** + * Reports whether a hash documents the elements of a `T[]` member of a union type. + * + * Only a single `...$0` entry whose own type is that `T` qualifies. Named keys + * beside a `T[]` describe the array itself, as the `string[]` of a list of + * labels keyed by name does, and an entry of any other type would contradict + * the declared element type rather than narrow it. The one exception is a bare + * `array` entry for an `array[]` element, which is how the `array[][]` of + * `wp_print_font_faces()` is written: its own hash then says what the inner + * arrays hold, so the element is still only narrowed. + * + * @param string $member One member of a union type. + * @param list $entries Entries of the hash describing it. + * @return bool + */ + private function describes_elements( string $member, array $entries ): bool { + if ( ! str_ends_with( $member, '[]' ) || 1 !== count( $entries ) ) { + return false; + } + + $entry = $entries[0]; + if ( ! is_array( $entry ) || true !== ( $entry['variadic'] ?? null ) || ! is_string( $entry['type'] ?? null ) ) { + return false; + } + + $element = strtolower( substr( $member, 0, -2 ) ); + $type = strtolower( $entry['type'] ); + + return $element === $type || ( 'array' === $type && preg_match( '#^array(?:\[\])+$#', $element ) === 1 ); + } + /** * Builds the shape for one hash level. * diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index c9ff5762d9f51..052f0653e4b60 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -79,7 +79,7 @@ PHPStan reads that hash as free text, so the value stays a plain `array` and not A hash whose translation would be a guess is left alone, and the value keeps whatever type it has today. The visitor therefore only ever narrows a type, and never contradicts one: - A `@phpstan-param` or `@phpstan-return` written by hand always wins. Hash notation cannot express everything a type can — a function returning either of two shapes, or one whose return carries keys beyond those it documents, as `get_avatar_data()` returns the processed `$args` too — so a shape that has been tuned in the source is never overwritten by the derived one. Only the tag it was written for: a `@phpstan-param` for one parameter says nothing about the next, and a conditional `@phpstan-return` naming a parameter is not a shape for it. -- The declared type has to name something a shape can be put on: a bare `array` or `object`, or a class, on its own or as one member of a union such as `string|array` or `array|WP_Error` — in a union the bare member is the one the hash is about, since a `WP_Error` carries no keys. A type that is already more specific than the hash, such as `array`, is left as written. +- The declared type has to name something a shape can be put on: a bare `array` or `object`, or a class, on its own or as one member of a union such as `string|array` or `array|WP_Error` — in a union the bare member is the one the hash is about, since a `WP_Error` carries no keys. A type that is already more specific than the hash, such as `array`, is left as written. The exception is a `T[]` whose hash is a single `...$0` entry typed as that `T`: the hash then describes the elements, as it does for the blocks `parse_blocks()` returns, so `array[]` becomes `array`. Named keys beside a `T[]` describe the array itself rather than its elements, so a `string[]` with named keys keeps its type. - The hash has to be well formed: every `{` closed by a `}` on a line of its own, and every `@type` carrying a `$name` and a type that PHPStan's own type parser reads as one. Asking the parser rather than describing what a type may contain is what keeps `?string`, `(string|null)[]` and `callable(): void` from being turned away as malformed. - A hash on a bare `object` that would have to stay open is skipped, because PHPStan's object shapes have a fixed member list and no `...`, and sealing one would report every member the hash does not name. A hash on a named class is kept: `stdClass&object{...}` is not sealed, since the intersection leaves the class to say what else may be read and a `stdClass` accepts anything. That is what lets the theme data `WP_Theme_Install_List_Table::single_row()` documents keep its shape while the wordpress.org API is still free to add to it. - A parameter taken by reference is skipped, because PHPStan checks a by-reference argument in both directions, and a shape there would be a contract every caller's variable has to satisfy before the call rather than a description of what the function reads. From 48505d148dcb4128b5d3fb0e99e92132aa6fcbda Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 17 Oct 2026 22:28:23 -0700 Subject: [PATCH 02/12] Document WP_Block_Processor::extract_full_block_and_advance() as returning 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|null` return type are dropped. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/class-wp-block-processor.php | 24 ++++++++------------ tests/phpstan/baselines/return.type.neon | 10 -------- 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/src/wp-includes/class-wp-block-processor.php b/src/wp-includes/class-wp-block-processor.php index b62bb1c5110d9..8389646f8d7f6 100644 --- a/src/wp-includes/class-wp-block-processor.php +++ b/src/wp-includes/class-wp-block-processor.php @@ -1277,20 +1277,16 @@ public function get_depth(): int { * * @since 6.9.0 * - * @return array[]|null { - * Array of block structures. - * - * @type array ...$0 { - * An associative array of a single parsed block object. See WP_Block_Parser_Block. - * - * @type string|null $blockName Name of block. - * @type array $attrs Attributes from block comment delimiters. - * @type array[] $innerBlocks List of inner blocks. An array of arrays that - * have the same structure as this one. - * @type string $innerHTML HTML from inside block comment delimiters. - * @type array $innerContent List of string fragments and null markers where - * inner blocks were found. - * } + * @return array|null { + * An associative array of a single parsed block object. See WP_Block_Parser_Block. + * + * @type string|null $blockName Name of block. + * @type array $attrs Attributes from block comment delimiters. + * @type array[] $innerBlocks List of inner blocks. An array of arrays that + * have the same structure as this one. + * @type string $innerHTML HTML from inside block comment delimiters. + * @type array $innerContent List of string fragments and null markers where + * inner blocks were found. * } */ public function extract_full_block_and_advance(): ?array { diff --git a/tests/phpstan/baselines/return.type.neon b/tests/phpstan/baselines/return.type.neon index 8122cecd33514..db2b0be300568 100644 --- a/tests/phpstan/baselines/return.type.neon +++ b/tests/phpstan/baselines/return.type.neon @@ -33,16 +33,6 @@ parameters: identifier: return.type count: 1 path: ../../../src/wp-includes/blocks.php - - - message: '#^Method WP_Block_Processor\:\:extract_full_block_and_advance\(\) should return array\\|null but returns array\\.$#' - identifier: return.type - count: 1 - path: ../../../src/wp-includes/class-wp-block-processor.php - - - message: '#^Method WP_Block_Processor\:\:extract_full_block_and_advance\(\) should return array\\|null but returns array\\|string\|null\>\.$#' - identifier: return.type - count: 1 - path: ../../../src/wp-includes/class-wp-block-processor.php - message: '#^Method WP_Image_Editor_Imagick\:\:set_imagick_time_limit\(\) should return int\|null but returns float\.$#' identifier: return.type From 84e119be118238627914df689b97708d5eb93dd6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 17 Oct 2026 22:38:09 -0700 Subject: [PATCH 03/12] Document the block lists resolve_pattern_blocks() takes and returns 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 --- src/wp-includes/blocks.php | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index 626e7b7f14f4a..ea140f4e2373c 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -1928,8 +1928,36 @@ function traverse_and_serialize_block( $block, $pre_callback = null, $post_callb * @since 6.6.0 * @since 7.0.0 Adds metadata to attributes of single-pattern container blocks. * - * @param array $blocks An array blocks. - * @return array An array of blocks with patterns replaced by their content. + * @param array[] $blocks { + * An array of blocks. + * + * @type array ...$0 { + * An associative array of a single parsed block object. See WP_Block_Parser_Block. + * + * @type string|null $blockName Name of block. + * @type array $attrs Attributes from block comment delimiters. + * @type array[] $innerBlocks List of inner blocks. An array of arrays that + * have the same structure as this one. + * @type string $innerHTML HTML from inside block comment delimiters. + * @type array $innerContent List of string fragments and null markers where + * inner blocks were found. + * } + * } + * @return array[] { + * An array of blocks with patterns replaced by their content. + * + * @type array ...$0 { + * An associative array of a single parsed block object. See WP_Block_Parser_Block. + * + * @type string|null $blockName Name of block. + * @type array $attrs Attributes from block comment delimiters. + * @type array[] $innerBlocks List of inner blocks. An array of arrays that + * have the same structure as this one. + * @type string $innerHTML HTML from inside block comment delimiters. + * @type array $innerContent List of string fragments and null markers where + * inner blocks were found. + * } + * } */ function resolve_pattern_blocks( $blocks ) { static $inner_content; From 745e61d8ab6f531b6861727200f0e2afce285d0e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 17 Oct 2026 22:42:58 -0700 Subject: [PATCH 04/12] Read a `Required.` sentence as marking a hash key required 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 --- tests/phpstan/HashNotationVisitor.php | 29 +++++++++++++++++++++++++-- tests/phpstan/README.md | 15 +++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/tests/phpstan/HashNotationVisitor.php b/tests/phpstan/HashNotationVisitor.php index fc6b8c3d65974..3369e55dc8f69 100644 --- a/tests/phpstan/HashNotationVisitor.php +++ b/tests/phpstan/HashNotationVisitor.php @@ -81,8 +81,9 @@ * they describe a value core itself builds. Numbered keys such as `$0`, `$1` * used for positional arguments are required in either case, because they are * passed in order. A description opening with `Optional.` overrides all of - * that, and is the only thing that does; the word elsewhere in a description is - * prose, not a marker. + * that, and a `Required.` sentence opening or closing it makes a `@param` key + * required, for a value such as a parsed block whose keys are always set. Those + * are the only markers; either word elsewhere in a description is prose. * * The shape of a `@param` hash is left open, with a trailing `...`, because the * hash lists the keys core reads rather than the only keys a caller may pass. @@ -841,6 +842,15 @@ private function is_optional( array $entry, bool $for_param ): bool { return true; } + /* + * Core marks the keys a caller must pass with a `Required.` sentence. It is + * redundant in a `@return` hash, whose keys are required already, but says + * what the default cannot in a `@param` hash. + */ + if ( $this->is_required( $entry['description'] ) ) { + return false; + } + /* * A `@return` hash describes a value core builds, so its keys are * present unless the description says otherwise. `Default ...` is not @@ -858,6 +868,21 @@ private function is_optional( array $entry, bool $for_param ): bool { return true; } + /** + * Reports whether a description marks its key as required. + * + * The marker is a `Required.` sentence, written either first, as `Optional.` + * is, or last, as some of core's hashes have it. Only the whole sentence + * counts: the word inside one is prose, as in a "Capability required for the + * panel", or the "Required for multi-widgets" of a key that is not. + * + * @param string $description Description of the entry. + * @return bool + */ + private function is_required( string $description ): bool { + return preg_match( '#^\s*Required\.(?:\s|$)|(?:^|[.!?]\s+)Required\.\s*$#', $description ) === 1; + } + /** * Formats a key for use in a shape, quoting it when it is not an identifier. * diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index 052f0653e4b60..738dc0f58410e 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -84,7 +84,20 @@ A hash whose translation would be a guess is left alone, and the value keeps wha - A hash on a bare `object` that would have to stay open is skipped, because PHPStan's object shapes have a fixed member list and no `...`, and sealing one would report every member the hash does not name. A hash on a named class is kept: `stdClass&object{...}` is not sealed, since the intersection leaves the class to say what else may be read and a `stdClass` accepts anything. That is what lets the theme data `WP_Theme_Install_List_Table::single_row()` documents keep its shape while the wordpress.org API is still free to add to it. - A parameter taken by reference is skipped, because PHPStan checks a by-reference argument in both directions, and a shape there would be a contract every caller's variable has to satisfy before the call rather than a description of what the function reads. -Keys of a `@param` hash are optional, at every level, and the shape is left open with a trailing `...`, because the hash lists the keys core reads rather than the only keys a caller may pass. Keys of a `@return` hash are required and the shape is sealed, since they describe a value core itself builds. Numbered keys such as `$0` and `$1` are required in either case, because positional arguments are passed in order. A description opening with `Optional.` overrides all of that, and is the only thing that does: the word further into a description is prose, as in the "Optional self closing slash" of a match array whose every group is always set. +Keys of a `@param` hash are optional, at every level, and the shape is left open with a trailing `...`, because the hash lists the keys core reads rather than the only keys a caller may pass. Keys of a `@return` hash are required and the shape is sealed, since they describe a value core itself builds. Numbered keys such as `$0` and `$1` are required in either case, because positional arguments are passed in order. A description opening with `Optional.` overrides all of that, making the key optional. A `Required.` sentence, opening or closing the description, makes a `@param` key required, which is what a value such as a parsed block needs, since its keys are always set: + +```php + * @param array[] $blocks { + * An array of blocks. + * + * @type array ...$0 { + * @type string|null $blockName Required. Name of block. + * @type array $attrs Required. Attributes from block comment delimiters. + * } + * } +``` + +Those two sentences are the only markers. Either word elsewhere in a description is prose, as in the "Optional self closing slash" of a match array whose every group is always set, or a "Capability required for the panel". Reading a key that a `@return` hash does not document is therefore reported rather than silently typed as `mixed` — unless the hash says otherwise itself, by listing a `...$N` entry beside its named keys, which is how core writes "and the rest" for the positional hashes of `wp_maybe_grant_site_health_caps()` and `WP_Meta_Query`. From d335166b69444be32fac9b0f80d4102d5148787e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 17 Oct 2026 22:43:49 -0700 Subject: [PATCH 05/12] Mark the block keys resolve_pattern_blocks() takes as required 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 --- src/wp-includes/blocks.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index ea140f4e2373c..39e5622a1db55 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -1934,12 +1934,12 @@ function traverse_and_serialize_block( $block, $pre_callback = null, $post_callb * @type array ...$0 { * An associative array of a single parsed block object. See WP_Block_Parser_Block. * - * @type string|null $blockName Name of block. - * @type array $attrs Attributes from block comment delimiters. - * @type array[] $innerBlocks List of inner blocks. An array of arrays that + * @type string|null $blockName Required. Name of block. + * @type array $attrs Required. Attributes from block comment delimiters. + * @type array[] $innerBlocks Required. List of inner blocks. An array of arrays that * have the same structure as this one. - * @type string $innerHTML HTML from inside block comment delimiters. - * @type array $innerContent List of string fragments and null markers where + * @type string $innerHTML Required. HTML from inside block comment delimiters. + * @type array $innerContent Required. List of string fragments and null markers where * inner blocks were found. * } * } From 0654a0fc381d467e373f2a213caacf2fa3488ca2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 17 Oct 2026 22:52:09 -0700 Subject: [PATCH 06/12] Regenerate baselines --- tests/phpstan/baselines/argument.type.neon | 2 +- tests/phpstan/baselines/parameter.defaultValue.neon | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index 57a2da36d7c2f..353aa58b39683 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -664,7 +664,7 @@ parameters: count: 1 path: ../../../src/wp-includes/block-supports/layout.php - - message: '#^Parameter \#1 \$block of function filter_block_kses expects WP_Block_Parser_Block, array given\.$#' + message: '#^Parameter \#1 \$block of function filter_block_kses expects WP_Block_Parser_Block, array\ given\.$#' identifier: argument.type count: 1 path: ../../../src/wp-includes/blocks.php diff --git a/tests/phpstan/baselines/parameter.defaultValue.neon b/tests/phpstan/baselines/parameter.defaultValue.neon index 34b6b678fc447..c80e9287b727c 100644 --- a/tests/phpstan/baselines/parameter.defaultValue.neon +++ b/tests/phpstan/baselines/parameter.defaultValue.neon @@ -98,6 +98,11 @@ parameters: identifier: parameter.defaultValue count: 1 path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#2 \$args \(array\{\}\) of function wp_initialize_site\(\) is incompatible with type array\{user_id\: int, title\?\: string, options\?\: array, meta\?\: array, \.\.\.\}\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-includes/ms-site.php - message: '#^Default value of the parameter \#3 \$deprecated \(''''\) of function unregister_setting\(\) is incompatible with type callable\(\)\: mixed\.$#' identifier: parameter.defaultValue From 80c3b18db9514362be52f6312b3126220832f2fa Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 17 Oct 2026 23:08:30 -0700 Subject: [PATCH 07/12] Mark the Categories widget's display arguments as required `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 --- src/wp-includes/widgets/class-wp-widget-categories.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/widgets/class-wp-widget-categories.php b/src/wp-includes/widgets/class-wp-widget-categories.php index e26d607c4d12d..d506212777980 100644 --- a/src/wp-includes/widgets/class-wp-widget-categories.php +++ b/src/wp-includes/widgets/class-wp-widget-categories.php @@ -41,10 +41,10 @@ public function __construct() { * @param array $args { * Display arguments. * - * @type string $before_widget HTML markup to prepend to each widget in the sidebar. - * @type string $after_widget HTML markup to append to each widget in the sidebar. - * @type string $before_title HTML markup to prepend to the widget title when displayed. - * @type string $after_title HTML markup to append to the widget title when displayed. + * @type string $before_widget Required. HTML markup to prepend to each widget in the sidebar. + * @type string $after_widget Required. HTML markup to append to each widget in the sidebar. + * @type string $before_title Required. HTML markup to prepend to the widget title when displayed. + * @type string $after_title Required. HTML markup to append to the widget title when displayed. * } * @param array $instance { * Settings for the current Categories widget instance. From a06dd957ec3bec200071f7d245add6ad8274c8bc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 18 Oct 2026 15:45:40 -0700 Subject: [PATCH 08/12] Check the nesting depth of a bare `array` entry for an `array[]` element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/phpstan/HashNotationVisitor.php | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/phpstan/HashNotationVisitor.php b/tests/phpstan/HashNotationVisitor.php index 3369e55dc8f69..79fa5b1979aad 100644 --- a/tests/phpstan/HashNotationVisitor.php +++ b/tests/phpstan/HashNotationVisitor.php @@ -708,8 +708,10 @@ private function is_shapeable( string $member ): bool { * labels keyed by name does, and an entry of any other type would contradict * the declared element type rather than narrow it. The one exception is a bare * `array` entry for an `array[]` element, which is how the `array[][]` of - * `wp_print_font_faces()` is written: its own hash then says what the inner - * arrays hold, so the element is still only narrowed. + * `wp_print_font_faces()` is written. Its own hash must then describe the + * elements of that `array[]` in turn, so the inner arrays are still only + * narrowed: named keys there, or no hash at all, would type an array of + * arrays as a single shape, or drop a level of it. * * @param string $member One member of a union type. * @param list $entries Entries of the hash describing it. @@ -728,7 +730,17 @@ private function describes_elements( string $member, array $entries ): bool { $element = strtolower( substr( $member, 0, -2 ) ); $type = strtolower( $entry['type'] ); - return $element === $type || ( 'array' === $type && preg_match( '#^array(?:\[\])+$#', $element ) === 1 ); + if ( $element === $type ) { + return true; + } + + if ( 'array' !== $type || preg_match( '#^array(?:\[\])+$#', $element ) !== 1 ) { + return false; + } + + $children = $entry['children'] ?? null; + + return is_array( $children ) && $this->describes_elements( $element, array_values( $children ) ); } /** From 1b38fbaa560df0fb7664de523bdaf62b0ab3bf62 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 18 Oct 2026 15:47:03 -0700 Subject: [PATCH 09/12] Prefer a bare `array` over a `T[]` member when choosing a shape's target 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 --- tests/phpstan/HashNotationVisitor.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/phpstan/HashNotationVisitor.php b/tests/phpstan/HashNotationVisitor.php index 79fa5b1979aad..d025f82aae28a 100644 --- a/tests/phpstan/HashNotationVisitor.php +++ b/tests/phpstan/HashNotationVisitor.php @@ -609,11 +609,9 @@ private function substitute( string $declared, array $entries, bool $for_param ) /* * A `T[]` member describes its elements, so it takes a hash that does the * same: a single `...$0` entry documenting the repeated value as a `T`, as - * the blocks `parse_blocks()` returns are. It is then as bare as an `array`, - * since the hash is all there is to say about its keys. + * the blocks `parse_blocks()` returns are. */ if ( $this->describes_elements( $member, $entries ) ) { - $bare[] = $position; $elements[] = $position; continue; } @@ -636,10 +634,15 @@ private function substitute( string $declared, array $entries, bool $for_param ) * `WP_Error` carries no keys. So the bare member takes the shape whenever * there is exactly one. Two of them, or two classes and no bare member, * would leave it a guess which one the hash is about. + * + * A `T[]` member comes next, so a bare `array` beside it keeps the shape it + * had before `T[]` members could take one, as in `array|array[]`. */ if ( 1 === count( $bare ) ) { $target = $bare[0]; - } elseif ( array() === $bare && 1 === count( $shapeable ) ) { + } elseif ( array() === $bare && 1 === count( $elements ) ) { + $target = $elements[0]; + } elseif ( array() === $bare && array() === $elements && 1 === count( $shapeable ) ) { $target = $shapeable[0]; } else { return null; From b1062271e10f2f524c91fbe949d30db338df1804 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 18 Oct 2026 15:47:38 -0700 Subject: [PATCH 10/12] Match the `Required.` marker case-insensitively, as `Optional.` is 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 --- tests/phpstan/HashNotationVisitor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpstan/HashNotationVisitor.php b/tests/phpstan/HashNotationVisitor.php index d025f82aae28a..e33018c14e249 100644 --- a/tests/phpstan/HashNotationVisitor.php +++ b/tests/phpstan/HashNotationVisitor.php @@ -895,7 +895,7 @@ private function is_optional( array $entry, bool $for_param ): bool { * @return bool */ private function is_required( string $description ): bool { - return preg_match( '#^\s*Required\.(?:\s|$)|(?:^|[.!?]\s+)Required\.\s*$#', $description ) === 1; + return preg_match( '#^\s*Required\.(?:\s|$)|(?:^|[.!?]\s+)Required\.\s*$#i', $description ) === 1; } /** From c1535e950badabb56c7b269d4afee90c435bbf25 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 18 Oct 2026 15:48:06 -0700 Subject: [PATCH 11/12] Correct the README's derived shape for `parse_blocks()` 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 --- tests/phpstan/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index 738dc0f58410e..0bdd4717fbe04 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -79,7 +79,7 @@ PHPStan reads that hash as free text, so the value stays a plain `array` and not A hash whose translation would be a guess is left alone, and the value keeps whatever type it has today. The visitor therefore only ever narrows a type, and never contradicts one: - A `@phpstan-param` or `@phpstan-return` written by hand always wins. Hash notation cannot express everything a type can — a function returning either of two shapes, or one whose return carries keys beyond those it documents, as `get_avatar_data()` returns the processed `$args` too — so a shape that has been tuned in the source is never overwritten by the derived one. Only the tag it was written for: a `@phpstan-param` for one parameter says nothing about the next, and a conditional `@phpstan-return` naming a parameter is not a shape for it. -- The declared type has to name something a shape can be put on: a bare `array` or `object`, or a class, on its own or as one member of a union such as `string|array` or `array|WP_Error` — in a union the bare member is the one the hash is about, since a `WP_Error` carries no keys. A type that is already more specific than the hash, such as `array`, is left as written. The exception is a `T[]` whose hash is a single `...$0` entry typed as that `T`: the hash then describes the elements, as it does for the blocks `parse_blocks()` returns, so `array[]` becomes `array`. Named keys beside a `T[]` describe the array itself rather than its elements, so a `string[]` with named keys keeps its type. +- The declared type has to name something a shape can be put on: a bare `array` or `object`, or a class, on its own or as one member of a union such as `string|array` or `array|WP_Error` — in a union the bare member is the one the hash is about, since a `WP_Error` carries no keys. A type that is already more specific than the hash, such as `array`, is left as written. The exception is a `T[]` whose hash is a single `...$0` entry typed as that `T`: the hash then describes the elements, as it does for the blocks `parse_blocks()` returns, so its `array[]` becomes `array`, sealed like any other `@return` shape. An `array[][]` may document its outer elements as a bare `array`, as `wp_print_font_faces()` does, but only when their own hash goes on to describe the inner arrays the same way. A bare `array` beside the `T[]`, as in `array|array[]`, takes the shape instead. Named keys beside a `T[]` describe the array itself rather than its elements, so a `string[]` with named keys keeps its type. - The hash has to be well formed: every `{` closed by a `}` on a line of its own, and every `@type` carrying a `$name` and a type that PHPStan's own type parser reads as one. Asking the parser rather than describing what a type may contain is what keeps `?string`, `(string|null)[]` and `callable(): void` from being turned away as malformed. - A hash on a bare `object` that would have to stay open is skipped, because PHPStan's object shapes have a fixed member list and no `...`, and sealing one would report every member the hash does not name. A hash on a named class is kept: `stdClass&object{...}` is not sealed, since the intersection leaves the class to say what else may be read and a `stdClass` accepts anything. That is what lets the theme data `WP_Theme_Install_List_Table::single_row()` documents keep its shape while the wordpress.org API is still free to add to it. - A parameter taken by reference is skipped, because PHPStan checks a by-reference argument in both directions, and a shape there would be a contract every caller's variable has to satisfy before the call rather than a description of what the function reads. From 099446c855d1586c3315e6e7bd16e0f3e79cd8f7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 18 Oct 2026 16:07:34 -0700 Subject: [PATCH 12/12] Document the `type` of each settings error as always set 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 --- src/wp-admin/includes/template.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/template.php b/src/wp-admin/includes/template.php index 973e7486db141..9e996fc37c155 100644 --- a/src/wp-admin/includes/template.php +++ b/src/wp-admin/includes/template.php @@ -1907,7 +1907,7 @@ function add_settings_error( $setting, $code, $message, $type = 'error' ) { * @type string $code Slug-name to identify the error. Used as part of 'id' attribute in HTML output. * @type string $message The formatted message text to display to the user (will be shown inside styled * `
` and `

` tags). - * @type string $type Optional. Message type, controls HTML class. Possible values include 'error', + * @type string $type Message type, controls HTML class. Possible values include 'error', * 'success', 'warning', 'info'. Default 'error'. * } * }