From c438c74775ae249cc6963371f40553a84ad2a5df Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 31 Oct 2026 19:58:31 -0700 Subject: [PATCH 1/7] Narrow types related to tag queries --- .../html-api/class-wp-html-tag-processor.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index c39368e9a40c1..edd8301f2f309 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -407,6 +407,13 @@ * @since 6.5.0 Pauses processor when input ends in an incomplete syntax token. * Introduces "special" elements which act like void elements, e.g. TITLE, STYLE. * Allows scanning through all tokens and processing modifiable text, where applicable. + * + * @phpstan-type Query array{ + * tag_name?: non-falsy-string, + * match_offset?: int<1, max>, + * class_name?: non-empty-string, + * tag_closers?: 'visit'|'skip', + * }|non-falsy-string|null */ class WP_HTML_Tag_Processor { /** @@ -443,7 +450,8 @@ class WP_HTML_Tag_Processor { * The last query passed to next_tag(). * * @since 6.2.0 - * @var array|null + * @var array|string|null + * @phpstan-var Query */ private $last_query; @@ -452,6 +460,7 @@ class WP_HTML_Tag_Processor { * * @since 6.2.0 * @var string|null + * @phpstan-var non-falsy-string|null */ private $sought_tag_name; @@ -460,6 +469,7 @@ class WP_HTML_Tag_Processor { * * @since 6.2.0 * @var string|null + * @phpstan-var non-empty-string|null */ private $sought_class_name; @@ -468,6 +478,7 @@ class WP_HTML_Tag_Processor { * * @since 6.2.0 * @var int|null + * @phpstan-var int<1, max>|null */ private $sought_match_offset; @@ -895,6 +906,7 @@ public function change_parsing_namespace( string $new_namespace ): bool { * } * @return bool Whether a tag was matched. * + * @phpstan-param Query $query * @phpstan-impure */ public function next_tag( $query = null ): bool { @@ -4988,6 +5000,7 @@ public function get_updated_html(): string { * @type string|null $class_name Tag must contain this class name to match. * @type string $tag_closers "visit" or "skip": whether to stop on tag closers, e.g. . * } + * @phpstan-param Query $query */ private function parse_query( $query ) { if ( null !== $query && $query === $this->last_query ) { From 270ed4096ce5d1f06a419258bb4c3e58ad044f61 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 31 Oct 2026 20:03:24 -0700 Subject: [PATCH 2/7] Allow null values in the tag query shape The `next_tag()` docs say `tag_name`, `match_offset`, `class_name`, and `tag_closers` may each be `null`, where a `null` `tag_name` means "any tag". `parse_query()` handles that with `isset()`. Without `|null` in the shape, PHPStan rejected documented calls such as the one in `WP_Interactivity_API_Directives_Processor::next_balanced_tag_closer_tag()`, which passes `get_tag()` (`string|null`) as `tag_name`. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 2c14c65e-32a4-4332-8e55-c2d31a16a529 --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index edd8301f2f309..66aefa8085125 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -409,10 +409,10 @@ * Allows scanning through all tokens and processing modifiable text, where applicable. * * @phpstan-type Query array{ - * tag_name?: non-falsy-string, - * match_offset?: int<1, max>, - * class_name?: non-empty-string, - * tag_closers?: 'visit'|'skip', + * tag_name?: non-falsy-string|null, + * match_offset?: int<1, max>|null, + * class_name?: non-empty-string|null, + * tag_closers?: 'visit'|'skip'|null, * }|non-falsy-string|null */ class WP_HTML_Tag_Processor { From a76e200ed8a2712d273df66b779d84d043de235a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 31 Oct 2026 20:03:31 -0700 Subject: [PATCH 3/7] Narrow types related to tag queries in WP_HTML_Processor This mirrors the `Query` shape in `WP_HTML_Tag_Processor` and adds the `breadcrumbs` key, which only the HTML Processor supports. Type aliases are not inherited by subclasses, so the shape is defined again here. It is a superset of the parent's shape, so the parameter stays contravariant. `matches_breadcrumbs()` gets the same narrowed element type for its crumbs. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 2c14c65e-32a4-4332-8e55-c2d31a16a529 --- src/wp-includes/html-api/class-wp-html-processor.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 4f771535edaa7..626aae0c89b57 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -143,6 +143,14 @@ * @see WP_HTML_Tag_Processor * @link https://html.spec.whatwg.org/ * @phpstan-consistent-constructor + * + * @phpstan-type Query array{ + * tag_name?: non-falsy-string|null, + * match_offset?: int<1, max>|null, + * class_name?: non-empty-string|null, + * tag_closers?: 'visit'|'skip'|null, + * breadcrumbs?: array, + * }|non-falsy-string|null */ class WP_HTML_Processor extends WP_HTML_Tag_Processor { /** @@ -687,6 +695,8 @@ public function get_unsupported_exception() { * May also contain the wildcard `*` which matches a single element, e.g. `array( 'SECTION', '*' )`. * } * @return bool Whether a tag was matched. + * + * @phpstan-param Query $query */ public function next_tag( $query = null ): bool { $visit_closers = isset( $query['tag_closers'] ) && 'visit' === $query['tag_closers']; @@ -930,6 +940,8 @@ private function is_virtual(): bool { * @param string[] $breadcrumbs DOM sub-path at which element is found, e.g. `array( 'FIGURE', 'IMG' )`. * May also contain the wildcard `*` which matches a single element, e.g. `array( 'SECTION', '*' )`. * @return bool Whether the currently-matched tag is found at the given nested structure. + * + * @phpstan-param array $breadcrumbs */ public function matches_breadcrumbs( $breadcrumbs ): bool { // Everything matches when there are zero constraints. From b407b9b00530cdcc2ec4e92713978337cd0828b5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 31 Oct 2026 20:06:45 -0700 Subject: [PATCH 4/7] Indent Query type aliases like other shapes in core Shape keys are indented four spaces after the asterisk, and the closing brace goes right after the asterisk, as in the `ScriptModule` and `Connector` aliases. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 2c14c65e-32a4-4332-8e55-c2d31a16a529 --- src/wp-includes/html-api/class-wp-html-processor.php | 12 ++++++------ .../html-api/class-wp-html-tag-processor.php | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 626aae0c89b57..2cbbdaea193d5 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -145,12 +145,12 @@ * @phpstan-consistent-constructor * * @phpstan-type Query array{ - * tag_name?: non-falsy-string|null, - * match_offset?: int<1, max>|null, - * class_name?: non-empty-string|null, - * tag_closers?: 'visit'|'skip'|null, - * breadcrumbs?: array, - * }|non-falsy-string|null + * tag_name?: non-falsy-string|null, + * match_offset?: int<1, max>|null, + * class_name?: non-empty-string|null, + * tag_closers?: 'visit'|'skip'|null, + * breadcrumbs?: array, + * }|non-falsy-string|null */ class WP_HTML_Processor extends WP_HTML_Tag_Processor { /** diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index 66aefa8085125..de5e4bf11662d 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -409,11 +409,11 @@ * Allows scanning through all tokens and processing modifiable text, where applicable. * * @phpstan-type Query array{ - * tag_name?: non-falsy-string|null, - * match_offset?: int<1, max>|null, - * class_name?: non-empty-string|null, - * tag_closers?: 'visit'|'skip'|null, - * }|non-falsy-string|null + * tag_name?: non-falsy-string|null, + * match_offset?: int<1, max>|null, + * class_name?: non-empty-string|null, + * tag_closers?: 'visit'|'skip'|null, + * }|non-falsy-string|null */ class WP_HTML_Tag_Processor { /** From a913432c05879b79ca3cf4e04b32d4011e1c9e47 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 31 Oct 2026 20:10:28 -0700 Subject: [PATCH 5/7] Accept non-empty-string tag names in tag queries A tag name can never be `'0'`, but requiring `non-falsy-string` for query input asks callers to prove more than they usually can. A `'' !== $tag` check narrows to `non-empty-string`, not to `non-falsy-string`. A `'0'` query is harmless: like any unknown tag name, it simply matches nothing. This also matches `class_name` and the attribute names elsewhere in the HTML API, which are `non-empty-string`. The `tag_name` key, the bare-string query form, the `breadcrumbs` key, `$sought_tag_name`, and the `matches_breadcrumbs()` parameter all change. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 2c14c65e-32a4-4332-8e55-c2d31a16a529 --- src/wp-includes/html-api/class-wp-html-processor.php | 8 ++++---- src/wp-includes/html-api/class-wp-html-tag-processor.php | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 2cbbdaea193d5..28eec9ac0eccb 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -145,12 +145,12 @@ * @phpstan-consistent-constructor * * @phpstan-type Query array{ - * tag_name?: non-falsy-string|null, + * tag_name?: non-empty-string|null, * match_offset?: int<1, max>|null, * class_name?: non-empty-string|null, * tag_closers?: 'visit'|'skip'|null, - * breadcrumbs?: array, - * }|non-falsy-string|null + * breadcrumbs?: array, + * }|non-empty-string|null */ class WP_HTML_Processor extends WP_HTML_Tag_Processor { /** @@ -941,7 +941,7 @@ private function is_virtual(): bool { * May also contain the wildcard `*` which matches a single element, e.g. `array( 'SECTION', '*' )`. * @return bool Whether the currently-matched tag is found at the given nested structure. * - * @phpstan-param array $breadcrumbs + * @phpstan-param array $breadcrumbs */ public function matches_breadcrumbs( $breadcrumbs ): bool { // Everything matches when there are zero constraints. diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index de5e4bf11662d..fa20b343b3648 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -409,11 +409,11 @@ * Allows scanning through all tokens and processing modifiable text, where applicable. * * @phpstan-type Query array{ - * tag_name?: non-falsy-string|null, + * tag_name?: non-empty-string|null, * match_offset?: int<1, max>|null, * class_name?: non-empty-string|null, * tag_closers?: 'visit'|'skip'|null, - * }|non-falsy-string|null + * }|non-empty-string|null */ class WP_HTML_Tag_Processor { /** @@ -460,7 +460,7 @@ class WP_HTML_Tag_Processor { * * @since 6.2.0 * @var string|null - * @phpstan-var non-falsy-string|null + * @phpstan-var non-empty-string|null */ private $sought_tag_name; From 55806790a1918149497834d3637e7dbf6cb847d0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 31 Oct 2026 20:13:46 -0700 Subject: [PATCH 6/7] Narrow get_tag() and get_token_name() to non-falsy-string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every name the parser reports starts with a letter, `_`, `:`, or `#`, so it is never empty and never `'0'`. PHPStan cannot prove this from the `substr()` in `WP_HTML_Tag_Processor::get_tag()`, so an inline `@var` asserts it there, with the reason. Everything else follows from that: * `get_token_name()` returns either `get_tag()` or a fixed `#…` name, in both processors. * `WP_HTML_Processor::get_tag()` returns a virtual token's `node_name`, so `WP_HTML_Token::$node_name` and its constructor parameter are narrowed too, as is `insert_virtual_node()`, whose callers pass literal tag names. A `next_tag()` call can now pass `get_tag()` as its `tag_name`, as `WP_Interactivity_API_Directives_Processor::next_balanced_tag_closer_tag()` does. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 2c14c65e-32a4-4332-8e55-c2d31a16a529 --- src/wp-includes/html-api/class-wp-html-processor.php | 6 ++++++ .../html-api/class-wp-html-tag-processor.php | 12 ++++++++++++ src/wp-includes/html-api/class-wp-html-token.php | 3 +++ 3 files changed, 21 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 28eec9ac0eccb..235069965538d 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -5166,6 +5166,8 @@ public function get_namespace(): string { * @since 6.4.0 * * @return string|null Name of currently matched tag in input HTML, or `null` if none found. + * + * @phpstan-return non-falsy-string|null */ public function get_tag(): ?string { if ( null !== $this->last_error ) { @@ -5231,6 +5233,8 @@ public function has_self_closing_flag(): bool { * @since 6.6.0 Subclassed for the HTML Processor. * * @return string|null Name of the matched token. + * + * @phpstan-return non-falsy-string|null */ public function get_token_name(): ?string { return $this->is_virtual() @@ -6288,6 +6292,8 @@ private function insert_foreign_element( WP_HTML_Token $token, bool $only_add_to * @param string|null $bookmark_name Optional. Name to give bookmark for created virtual node. * Defaults to auto-creating a bookmark name. * @return WP_HTML_Token Newly-created virtual token. + * + * @phpstan-param non-falsy-string $token_name */ private function insert_virtual_node( $token_name, $bookmark_name = null ): WP_HTML_Token { $here = $this->bookmarks[ $this->state->current_token->bookmark_name ]; diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index fa20b343b3648..03f28a658937d 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -3046,12 +3046,22 @@ public function get_namespace(): string { * @since 6.2.0 * * @return string|null Name of currently matched tag in input HTML, or `null` if none found. + * + * @phpstan-return non-falsy-string|null */ public function get_tag(): ?string { if ( null === $this->tag_name_starts_at ) { return null; } + /** + * The name is never empty and never "0": a tag name starts with an ASCII + * letter, a processing instruction target with a letter or "_", and the + * target of a comment which looks like a processing instruction with a + * letter, ":", or "_". + * + * @var non-falsy-string $tag_name + */ $tag_name = str_replace( "\x00", "\u{FFFD}", substr( $this->html, $this->tag_name_starts_at, $this->tag_name_length ) ); if ( self::STATE_MATCHED_TAG === $this->parser_state ) { @@ -3581,6 +3591,8 @@ public function get_token_type(): ?string { * @since 6.5.0 * * @return string|null Name of the matched token. + * + * @phpstan-return non-falsy-string|null */ public function get_token_name(): ?string { switch ( $this->parser_state ) { diff --git a/src/wp-includes/html-api/class-wp-html-token.php b/src/wp-includes/html-api/class-wp-html-token.php index 01701d02ec9d1..926a12cf8200f 100644 --- a/src/wp-includes/html-api/class-wp-html-token.php +++ b/src/wp-includes/html-api/class-wp-html-token.php @@ -43,6 +43,7 @@ class WP_HTML_Token { * @see WP_HTML_Processor::get_tag() * * @var string + * @phpstan-var non-falsy-string */ public $node_name = null; @@ -96,6 +97,8 @@ class WP_HTML_Token { * @param string $node_name Name of node token represents; if uppercase, an HTML element; if lowercase, a special value like "marker". * @param bool $has_self_closing_flag Whether the source token contains the self-closing flag, regardless of whether it's valid. * @param callable|null $on_destroy Optional. Function to call when destroying token, useful for releasing the bookmark. + * + * @phpstan-param non-falsy-string $node_name */ public function __construct( ?string $bookmark_name, string $node_name, bool $has_self_closing_flag, ?callable $on_destroy = null ) { $this->bookmark_name = $bookmark_name; From 8c9fb0b67b8570e0a4ef6430e089bf8ba3a99370 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 31 Oct 2026 20:16:01 -0700 Subject: [PATCH 7/7] Narrow get_qualified_tag_name() and get_breadcrumbs() `get_qualified_tag_name()` returns `get_tag()`, its lowercase form, or a literal SVG or MathML name, so it is never empty and never `'0'`. The breadcrumbs are only ever built from `WP_HTML_Token::$node_name` values and `'HTML'`, and are only changed with `[]`, `array_pop()`, and `array_slice()`, so they form a list of non-falsy names. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 2c14c65e-32a4-4332-8e55-c2d31a16a529 --- src/wp-includes/html-api/class-wp-html-processor.php | 3 +++ src/wp-includes/html-api/class-wp-html-tag-processor.php | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 235069965538d..dd4469d5277d9 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -245,6 +245,7 @@ class WP_HTML_Processor extends WP_HTML_Tag_Processor { * @since 6.7.0 * * @var string[] + * @phpstan-var list */ private $breadcrumbs = array(); @@ -1213,6 +1214,8 @@ public function step( $node_to_process = self::PROCESS_NEXT_NODE ): bool { * @since 6.4.0 * * @return string[] Array of tag names representing path to matched node. + * + * @phpstan-return list */ public function get_breadcrumbs(): array { return $this->breadcrumbs; diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index 03f28a658937d..cb92daa3d5a8c 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -3093,6 +3093,8 @@ public function get_tag(): ?string { * @since 6.7.0 * * @return string|null Name of current tag name. + * + * @phpstan-return non-falsy-string|null */ public function get_qualified_tag_name(): ?string { $tag_name = $this->get_tag();