From 559da7c864a64df7b2ff7df588cfdae4ffd86f9b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 08:27:12 +0000 Subject: [PATCH 01/17] Build/Test Tools: Teach PHPStan WordPress hash notation. Core documents the contents of an array argument with a nested list of `@type` tags. PHPStan reads that hash as free text, so the value stays a plain `array` and nothing inside it is typed, and a shape that should be visible to the analysis has to be written a second time as a `@phpstan-param` or `@phpstan-return` beside the hash that already describes it. `HashNotationVisitor` translates the hash into the array shapes PHPStan understands, so the documentation core already writes serves the reader and the analysis alike. Across `src/wp-admin`, `src/wp-includes` and the bundled themes it derives 394 tags from the 465 hashes it can see. A hash whose translation would be a guess is left alone, so the visitor only ever narrows a type and never contradicts one. A `@phpstan-param` or `@phpstan-return` written by hand always wins; the declared type has to name a bare `array`; the hash has to be well formed; and a parameter taken by reference is skipped, since PHPStan checks those in both directions and a shape there would constrain every caller's variable rather than describe what the function reads. Keys of a `@param` hash are optional and its shape is left open, 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 its shape is sealed, since they describe a value core itself builds, unless the description marks one `Optional.` Two kinds of hash are left for later. A `@var` hash on a property is inherited by every subclass and has to accept its own default, so a shape there would say more than the hash does. An `object` hash, such as the one on `get_taxonomy_labels()`, would need the docblock to name the class rather than `object`, because PHPStan's object shapes are structural and one derived for a `stdClass` is no longer assignable to a property declared `stdClass`. The translation follows the one php-stubs/wordpress-stubs performs when generating stubs, which is how the WordPress flavor of PHPDoc reaches PHPStan today for plugins and themes. Co-authored-by: Pascal Birchler Co-Authored-By: Claude --- tests/phpstan/HashNotationVisitor.php | 635 ++++++++++++++++++++++++++ tests/phpstan/README.md | 33 ++ tests/phpstan/base.neon | 9 + 3 files changed, 677 insertions(+) create mode 100644 tests/phpstan/HashNotationVisitor.php diff --git a/tests/phpstan/HashNotationVisitor.php b/tests/phpstan/HashNotationVisitor.php new file mode 100644 index 0000000000000..f175d55cb63f9 --- /dev/null +++ b/tests/phpstan/HashNotationVisitor.php @@ -0,0 +1,635 @@ +`, is left as written. An + * `object` hash is left alone as well: PHPStan's object shapes are + * structural, so one derived for a value core builds as a `stdClass` would + * no longer be assignable to a property declared `stdClass`. + * - The hash has to be well formed: every `{` closed by a `}` on a line of its + * own, and every `@type` carrying a type and a `$name`. Anything else, and + * the whole tag is skipped. + * - A parameter taken by reference is skipped. PHPStan checks a by-reference + * argument in both directions, so a shape there is a contract every caller's + * variable has to satisfy before the call, which is not what the hash says. + * + * Keys of a `@param` hash are optional, at every level, because a caller may + * pass any subset of them, and a shape whose keys were required would report + * every partial array as an error. Keys of a `@return` hash are required, since + * they describe a value core itself builds, unless the description marks one + * `Optional.` Numbered keys such as `$0`, `$1` used for positional arguments + * are required in either case. + * + * 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. + * A sealed shape would report reading or testing for any other key as an error, + * and would contradict the conditional return types core writes by hand. The + * shape of a `@return` hash is sealed, so reading a key core does not document + * is reported rather than silently typed as `mixed`. + * + * @link https://developer.wordpress.org/coding-standards/inline-documentation-standards/php/#1-1-parameters-that-are-arrays Hash notation in the documentation standards. + * @link https://github.com/php-stubs/wordpress-stubs/blob/master/src/Visitor.php The equivalent translation php-stubs/wordpress-stubs performs when generating stubs, MIT license. + * + * Registered as `phpstan.parser.richParserNodeVisitor` in `base.neon`. + */ +final class HashNotationVisitor extends NodeVisitorAbstract { + + /** + * Docblock tags whose description may carry a hash. + * + * `@var` is left out. A property declaration is inherited by every subclass + * and has to accept its own default, so a shape there would say more than + * the hash does: that no subclass may widen the property, and that the + * declared default already has the shape. + */ + private const HASH_TAGS = array( 'param', 'return' ); + + /** + * Translates the hashes in a node's docblock into `@phpstan-*` shapes. + * + * @param Node $node The node being entered. + * @return null + */ + public function enterNode( Node $node ): ?Node { + if ( ! $node instanceof Node\FunctionLike ) { + return null; + } + + $doc = $node->getDocComment(); + if ( null === $doc ) { + return null; + } + + $text = $doc->getText(); + if ( ! str_contains( $text, '@type ' ) ) { + return null; + } + + $additions = $this->build_additions( $text, $this->by_reference_parameters( $node ) ); + if ( array() === $additions ) { + return null; + } + + $lines = array(); + foreach ( $additions as $addition ) { + $lines[] = ' * ' . $addition; + } + + // Insert the derived tags just before the closing `*/`. + $merged = preg_replace( '#\s*\*/\s*$#', "\n" . implode( "\n", $lines ) . "\n */", $text, 1 ); + if ( ! is_string( $merged ) ) { + return null; + } + + $node->setDocComment( new Doc( $merged, $doc->getStartLine(), $doc->getStartFilePos() ) ); + + return null; + } + + /** + * Collects the parameters a function takes by reference. + * + * @param Node\FunctionLike $node Node the docblock is attached to. + * @return array Set of parameter names, without the `$`. + */ + private function by_reference_parameters( Node\FunctionLike $node ): array { + $names = array(); + foreach ( $node->getParams() as $param ) { + if ( $param->byRef && $param->var instanceof Node\Expr\Variable && is_string( $param->var->name ) ) { + $names[ $param->var->name ] = true; + } + } + + return $names; + } + + /** + * Builds the `@phpstan-*` tags derived from every hash in a docblock. + * + * @param string $text Raw docblock text including the `/**` markers. + * @param array $by_reference Parameters the function takes by reference. + * @return list Tag lines, without the leading ` * `. + */ + private function build_additions( string $text, array $by_reference ): array { + $additions = array(); + + foreach ( $this->split_tags( $text ) as $tag ) { + if ( ! in_array( $tag['name'], self::HASH_TAGS, true ) ) { + continue; + } + + $header = rtrim( $tag['header'] ); + if ( ! str_ends_with( $header, '{' ) ) { + continue; + } + + $head = rtrim( substr( $header, 0, -1 ) ); + $split = $this->split_type( $head ); + if ( null === $split ) { + continue; + } + + list( $declared, $remainder ) = $split; + + $variable = null; + if ( preg_match( '#^\$([A-Za-z0-9_]+)#', $remainder, $matches ) === 1 ) { + $variable = $matches[1]; + } + + // A `@param` hash without a variable name documents nothing PHPStan can attach a type to. + if ( 'param' === $tag['name'] && null === $variable ) { + continue; + } + + /* + * A by-reference parameter is checked in both directions, so a shape + * derived for one would have to be reached by every caller's variable + * before the call. The hash describes what the function reads, not a + * contract on the caller's variable, so it is left out. + */ + if ( 'param' === $tag['name'] && isset( $by_reference[ (string) $variable ] ) ) { + continue; + } + + if ( $this->has_phpstan_counterpart( $text, $tag['name'], $variable ) ) { + continue; + } + + $index = 0; + $entries = $this->parse_entries( $tag['body'], $index ); + if ( null === $entries || array() === $entries ) { + continue; + } + + $type = $this->substitute( $declared, $entries, 'param' === $tag['name'] ); + if ( null === $type ) { + continue; + } + + $additions[] = sprintf( + '@phpstan-%s %s%s', + $tag['name'], + $type, + null !== $variable && 'return' !== $tag['name'] ? ' $' . $variable : '' + ); + } + + return $additions; + } + + /** + * Splits a docblock into its tags. + * + * The docblock furniture is removed first, so a line reads as it would in a + * plain text file: `@param array $args {` for a tag, and the hash body + * indented below it. + * + * @param string $text Raw docblock text including the `/**` markers. + * @return list}> + */ + private function split_tags( string $text ): array { + $body = preg_replace( '#^\s*/\*\*#', '', $text, 1 ); + $body = preg_replace( '#\*/\s*$#', '', (string) $body, 1 ); + + $tags = array(); + $current = null; + + foreach ( preg_split( '#\R#', (string) $body ) ?: array() as $line ) { + $line = (string) preg_replace( '#^\s*\*[ ]?#', '', $line, 1 ); + + if ( preg_match( '#^@([a-zA-Z][a-zA-Z0-9_-]*)[ \t]*(.*)$#', $line, $matches ) === 1 ) { + $tags[] = array( + 'name' => strtolower( $matches[1] ), + 'header' => $matches[2], + 'body' => array(), + ); + $current = count( $tags ) - 1; + continue; + } + + if ( null !== $current ) { + $tags[ $current ]['body'][] = $line; + } + } + + return $tags; + } + + /** + * Reports whether the docblock already documents this tag for PHPStan. + * + * @param string $text Raw docblock text. + * @param string $tag Tag name, one of `param`, `return` or `var`. + * @param string|null $variable Variable the tag documents, without the `$`. + * @return bool + */ + private function has_phpstan_counterpart( string $text, string $tag, ?string $variable ): bool { + if ( 'return' === $tag ) { + return str_contains( $text, '@phpstan-return' ); + } + + if ( null === $variable ) { + return str_contains( $text, '@phpstan-' . $tag ); + } + + /* + * A hand-written shape often spans several lines, so the variable it + * documents can be far from the tag that opens it. Matching the tag and + * the variable without requiring them to be adjacent keeps a multi-line + * `@phpstan-param array{ ... } $args` recognized. + */ + return preg_match( + '#@phpstan-' . $tag . '\s.*?\$' . preg_quote( $variable, '#' ) . '\b#s', + $text + ) === 1; + } + + /** + * Parses the `@type` entries of one hash level. + * + * Nesting is tracked through the braces rather than through indentation, + * because core aligns a hash under the description column of the tag that + * opens it, and that column moves with the longest parameter name. + * + * @param list $lines Body lines of the tag, with docblock furniture removed. + * @param int $index Current position in `$lines`, advanced as entries are read. + * @return list}>|null + * Entries of this level, or null if the hash is malformed. + */ + private function parse_entries( array $lines, int &$index ): ?array { + $entries = array(); + $last = null; + $count = count( $lines ); + + while ( $index < $count ) { + $line = trim( $lines[ $index ] ); + ++$index; + + if ( '}' === $line ) { + return $entries; + } + + if ( str_starts_with( $line, '@type ' ) ) { + $entry = $this->parse_entry( substr( $line, 6 ) ); + if ( null === $entry ) { + return null; + } + + if ( $entry['opens'] ) { + $children = $this->parse_entries( $lines, $index ); + if ( null === $children || array() === $children ) { + return null; + } + $entry['children'] = $children; + } + + unset( $entry['opens'] ); + $entries[] = $entry; + $last = count( $entries ) - 1; + continue; + } + + // A tag other than `@type` inside a hash means the hash was never closed. + if ( str_starts_with( $line, '@' ) ) { + return null; + } + + if ( '' !== $line && null !== $last ) { + $entries[ $last ]['description'] .= ' ' . $line; + } + } + + return null; + } + + /** + * Parses one `@type` entry. + * + * @param string $rest Everything after `@type `. + * @return array{type: string, name: string, variadic: bool, description: string, children: list, opens: bool}|null + */ + private function parse_entry( string $rest ): ?array { + $rest = rtrim( $rest ); + $opens = false; + + if ( str_ends_with( $rest, '{' ) ) { + $opens = true; + $rest = rtrim( substr( $rest, 0, -1 ) ); + } + + $split = $this->split_type( $rest ); + if ( null === $split ) { + return null; + } + + list( $type, $remainder ) = $split; + + // Core keys are not always identifiers: `$mime-type` and `$post-trashed` are both documented. + if ( preg_match( '#^(\.\.\.)?\$([A-Za-z0-9_-]+)[ \t]*(.*)$#', $remainder, $matches ) !== 1 ) { + return null; + } + + return array( + 'type' => $type, + 'name' => $matches[2], + 'variadic' => '' !== $matches[1], + 'description' => $matches[3], + 'children' => array(), + 'opens' => $opens, + ); + } + + /** + * Splits a leading type off a string, keeping bracketed groups together. + * + * `array $deps` splits into `array` and + * `$deps`, rather than at the space inside the angle brackets. + * + * @param string $text Text beginning with a type. + * @return array{0: string, 1: string}|null Type and remainder, or null if there is no type. + */ + private function split_type( string $text ): ?array { + $text = ltrim( $text ); + $length = strlen( $text ); + $depth = 0; + $offset = $length; + + for ( $position = 0; $position < $length; $position++ ) { + $character = $text[ $position ]; + + if ( '<' === $character || '{' === $character || '(' === $character || '[' === $character ) { + ++$depth; + } elseif ( '>' === $character || '}' === $character || ')' === $character || ']' === $character ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + } elseif ( 0 === $depth && ( ' ' === $character || "\t" === $character ) ) { + $offset = $position; + break; + } + } + + if ( 0 !== $depth ) { + return null; + } + + $type = substr( $text, 0, $offset ); + if ( '' === $type ) { + return null; + } + + return array( $type, ltrim( substr( $text, $offset ) ) ); + } + + /** + * Replaces the bare `array` member of a type with a shape. + * + * @param string $declared Type as written in the docblock. + * @param list $entries Entries of the hash describing it. + * @param bool $for_param Whether the hash documents a `@param`. + * @return string|null The type with the shape substituted in, or null if it cannot be. + */ + private function substitute( string $declared, array $entries, bool $for_param ): ?string { + $members = $this->split_union( $declared ); + if ( null === $members ) { + return null; + } + + $target = null; + foreach ( $members as $position => $member ) { + if ( 'array' !== $member ) { + continue; + } + // Two bare members would leave it ambiguous which one the hash describes. + if ( null !== $target ) { + return null; + } + $target = $position; + } + + if ( null === $target ) { + return null; + } + + $shape = $this->resolve_container( $entries, $for_param ); + if ( null === $shape ) { + return null; + } + + $members[ $target ] = $shape; + + return implode( '|', $members ); + } + + /** + * Builds the shape for one hash level. + * + * @param list $entries Entries of this level. + * @param bool $for_param Whether the hash documents a `@param`. + * @return string|null + */ + private function resolve_container( array $entries, bool $for_param ): ?string { + /* + * A single `...$0` entry describes a repeated value rather than a key. + * The hash says nothing about the keys it repeats under, and core uses + * both numbered and named ones, so the keys stay `array-key`. + */ + if ( 1 === count( $entries ) && $entries[0]['variadic'] ) { + $inner = $this->resolve_entry_type( $entries[0], $for_param ); + + return null === $inner ? null : sprintf( 'array', $inner ); + } + + $members = array(); + foreach ( $entries as $entry ) { + if ( $entry['variadic'] ) { + return null; + } + + $type = $this->resolve_entry_type( $entry, $for_param ); + if ( null === $type ) { + return null; + } + + $members[] = sprintf( + '%s%s: %s', + $this->format_key( $entry['name'] ), + $this->is_optional( $entry, $for_param ) ? '?' : '', + $type + ); + } + + if ( array() === $members ) { + return null; + } + + /* + * A `@param` hash lists the keys core reads, not the only keys a caller + * may pass, so its shape stays open with a trailing `...`. Without it + * the shape would be sealed, and reading or testing for an undocumented + * key would be reported as an error at every call site that adds one. + */ + return sprintf( 'array{%s%s}', implode( ', ', $members ), $for_param ? ', ...' : '' ); + } + + /** + * Resolves the type of one entry, descending into its own hash if it has one. + * + * @param array{type: string, children: list} $entry Entry to resolve. + * @param bool $for_param Whether the hash documents a `@param`. + * @return string|null + */ + private function resolve_entry_type( array $entry, bool $for_param ): ?string { + if ( array() === $entry['children'] ) { + return $this->validate_type( $entry['type'] ); + } + + return $this->substitute( $entry['type'], $entry['children'], $for_param ); + } + + /** + * Reports whether a key is optional. + * + * @param array{name: string, description: string} $entry Entry to inspect. + * @param bool $for_param Whether the hash documents a `@param`. + * @return bool + */ + private function is_optional( array $entry, bool $for_param ): bool { + /* + * A `@return` hash describes a value core builds, so its keys are + * present unless the description says otherwise. `Default ...` is not + * that: a key documented with a default is still always set. + */ + if ( ! $for_param ) { + return preg_match( '#\bOptional\b#i', $entry['description'] ) === 1; + } + + // Numbered keys document positional arguments, which are always present. + if ( preg_match( '#^[0-9]+$#', $entry['name'] ) === 1 ) { + return false; + } + + return true; + } + + /** + * Formats a key for use in a shape, quoting it when it is not an identifier. + * + * @param string $name Key name, without the `$`. + * @return string + */ + private function format_key( string $name ): string { + if ( preg_match( '#^(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+)$#', $name ) === 1 ) { + return $name; + } + + return "'" . str_replace( "'", "\\'", $name ) . "'"; + } + + /** + * Returns a type only if it is shaped like one. + * + * Guards against prose that has drifted into the type column of a `@type` + * tag, which would otherwise be emitted as a type PHPStan cannot parse. + * + * @param string $type Type as written in the docblock. + * @return string|null + */ + private function validate_type( string $type ): ?string { + return $this->split_union( $type ) === null ? null : $type; + } + + /** + * Splits a union type into its members, ignoring `|` inside brackets. + * + * @param string $type Type as written in the docblock. + * @return list|null Members, or null if the type is not well formed. + */ + private function split_union( string $type ): ?array { + $type = trim( $type ); + if ( preg_match( '#^[A-Za-z0-9_\\\\|<>{},:\'"\[\]\#\-\. ]+$#', $type ) !== 1 ) { + return null; + } + + $members = array(); + $member = ''; + $depth = 0; + $length = strlen( $type ); + + for ( $position = 0; $position < $length; $position++ ) { + $character = $type[ $position ]; + + if ( '<' === $character || '{' === $character || '(' === $character || '[' === $character ) { + ++$depth; + } elseif ( '>' === $character || '}' === $character || ')' === $character || ']' === $character ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + } elseif ( '|' === $character && 0 === $depth ) { + if ( '' === $member ) { + return null; + } + $members[] = $member; + $member = ''; + continue; + } + + $member .= $character; + } + + if ( 0 !== $depth || '' === $member ) { + return null; + } + + $members[] = $member; + + return $members; + } +} diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index edf96fefdc093..759c505c4c991 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -59,6 +59,39 @@ This directory also contains extensions that teach PHPStan conventions specific Core documents the globals a function uses with `@global Type $varname`. `GlobalDocBlockVisitor` bridges that convention to PHPStan's variable type resolution, so those globals are typed rather than `mixed` inside the function. +### Hash notation + +Core documents the contents of an array argument with a nested list of `@type` tags, [hash notation](https://developer.wordpress.org/coding-standards/inline-documentation-standards/php/#1-1-parameters-that-are-arrays): + +```php +/** + * @param array $args { + * Optional. An array of arguments. + * + * @type string $post_type Post type. Default 'post'. + * @type int $post_author Post author ID. + * } + */ +``` + +PHPStan reads that hash as free text, so the value stays a plain `array` and nothing inside it is typed. `HashNotationVisitor` translates it into the array shape PHPStan understands, which for the example above is `array{post_type?: string, post_author?: int, ...}`, so the same documentation serves the reader and the analysis rather than each shape having to be written a second time as a `@phpstan-param`. + +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, for example — so a shape that has been tuned in the source is never overwritten by the derived one. +- The declared type has to name a bare `array`, on its own or as one member of a union such as `string|array`. A type that is already more specific than the hash, such as `array`, is left as written. +- The hash has to be well formed: every `{` closed by a `}` on a line of its own, and every `@type` carrying a type and a `$name`. +- 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 — unless the description marks one `Optional.`, which the visitor honors. Reading a key that a `@return` hash does not document is therefore reported rather than silently typed as `mixed`. + +Two kinds of hash are outside what the visitor covers today: + +- **`@var` hashes on properties.** A property declaration is inherited by every subclass and has to accept its own default, so a shape there would say more than the hash does: that no subclass may widen the property, and that the declared default already has the shape. +- **`object` hashes**, such as the one on `get_taxonomy_labels()`. PHPStan's object shapes are structural, so a shape derived for a value core builds as a `stdClass` is no longer assignable to a property declared `stdClass`. Covering these needs the docblocks to name the class rather than `object`, so that the shape can be intersected with it. + +Hashes are also written on hook docblocks, where core documents `apply_filters()` and `do_action()`. Those are not attached to a function, so they are outside what this visitor sees, and the value a filter passes stays typed by [the hook extensions below](#hook-documentation). + ### Hook documentation The remaining extensions read the docblock documenting a hook where the hook is fired, which is where WordPress documents its hooks. They cover `apply_filters()`, `do_action()` and their `_deprecated` and `_ref_array` variants. diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index ab07051c9ad7a..f34f0d7021ddd 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -12,6 +12,14 @@ services: tags: - phpstan.parser.richParserNodeVisitor + # Bridges WordPress core's hash notation, the nested `@type` list documenting the + # contents of an array or object, to PHPStan's array and object shapes. + # See tests/phpstan/HashNotationVisitor.php. + - + class: WordPress\PHPStan\HashNotationVisitor + tags: + - phpstan.parser.richParserNodeVisitor + # Attaches the docblock documenting a hook to the hook's call, so that the return # type extension and the rules below can all read it. - @@ -150,6 +158,7 @@ parameters: - ../../src/wp-trackback.php - ../../src/xmlrpc.php - GlobalDocBlockVisitor.php + - HashNotationVisitor.php - HookDocsVisitor.php - HookDocBlock.php - ApplyFiltersDynamicFunctionReturnTypeExtension.php From f088f8d1c8f5264643f363b87dd8ac60b4a3b59b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 08:51:18 +0000 Subject: [PATCH 02/17] Docs: Correct the hashes PHPStan now reads as types. With hash notation translated into array shapes, the analysis checks these hashes against the code for the first time, and reports where the two disagree: - `WP_Http::processHeaders()` documents a `newheaders` key. The array it returns has `headers`. - `wp_edit_attachments_query()` documents `post_mime_types` and `avail_post_mime_types` keys. It returns the two values positionally, so they are `$0` and `$1`, as `wpdb::parse_db_host()` already writes them. - `WP_List_Table::get_views_links()` documents `url`, `label` and `current` as keys of `$link_data`. They are keys of each link in it, which its own `@return` line says: "Keys match the `$link_data` input array." Every caller passes a keyed array of links. - `wp_check_php_version()` sets `is_lower_than_future_minimum` on every array it returns, and both callers read it, but it is not documented. - `wpdb::parse_db_host()` documents the port as `string|null`, right below the line of code that casts it with `absint()` and the comment saying "Port cannot be a string; must be null or an integer." - `wp_xmlrpc_server::wp_editPage()` documents its content argument as a string. It is the content struct, which the method writes `post_type` into before passing it on. - `WP_Http::request()` documents `headers` as a `CaseInsensitiveDictionary`. A non-blocking request returns an empty array for it. - `wp_upload_bits()` documents `file`, `url` and `type` alongside `error`. Only `error` is set when the upload fails. Two returns cannot be described by a hash at all, because they are one shape or another rather than one shape with optional keys, so they gain a `@phpstan-return` beside the hash, as `wp_upload_dir()` and `_wp_handle_upload()` already have: `wp_font_dir()`, which returns what `wp_upload_dir()` returns, and `get_avatar_data()`, whose returned array also carries every argument passed to it, as its own description says. What remains are call sites passing an argument the documented shape does not accept, which is recorded in the baselines rather than resolved here. Each is a hash and a caller disagreeing about a key, and worth its own look. Co-authored-by: Pascal Birchler Co-Authored-By: Claude --- src/wp-admin/includes/class-wp-list-table.php | 12 +- src/wp-admin/includes/misc.php | 14 ++- src/wp-admin/includes/post.php | 6 +- src/wp-includes/class-wp-http.php | 15 +-- src/wp-includes/class-wp-xmlrpc-server.php | 2 +- src/wp-includes/class-wpdb.php | 2 +- src/wp-includes/fonts.php | 8 ++ src/wp-includes/functions.php | 6 +- src/wp-includes/link-template.php | 1 + tests/phpstan/baselines/argument.type.neon | 116 +++++++++++++++++- .../baselines/assign.propertyType.neon | 2 +- .../baselines/offsetAccess.notFound.neon | 2 +- 12 files changed, 156 insertions(+), 30 deletions(-) diff --git a/src/wp-admin/includes/class-wp-list-table.php b/src/wp-admin/includes/class-wp-list-table.php index 5e6bcdb0d237c..17ec0c28f5c1f 100644 --- a/src/wp-admin/includes/class-wp-list-table.php +++ b/src/wp-admin/includes/class-wp-list-table.php @@ -427,11 +427,15 @@ public function search_box( $text, $input_id ) { * @since 6.1.0 * * @param array $link_data { - * An array of link data. + * An array of link data, keyed by view. * - * @type string $url The link URL. - * @type string $label The link label. - * @type bool $current Optional. Whether this is the currently selected view. + * @type array ...$0 { + * Data for a single view link. + * + * @type string $url The link URL. + * @type string $label The link label. + * @type bool $current Optional. Whether this is the currently selected view. + * } * } * @return string[] An array of link markup. Keys match the `$link_data` input array. */ diff --git a/src/wp-admin/includes/misc.php b/src/wp-admin/includes/misc.php index f021aedb8a5fb..c1ee93a2849e5 100644 --- a/src/wp-admin/includes/misc.php +++ b/src/wp-admin/includes/misc.php @@ -1570,12 +1570,14 @@ function _wp_privacy_settings_filter_draft_page_titles( $title, $page ) { * @return array|false { * Array of PHP version data. False on failure. * - * @type string $recommended_version The PHP version recommended by WordPress. - * @type string $minimum_version The minimum required PHP version. - * @type bool $is_supported Whether the PHP version is actively supported. - * @type bool $is_secure Whether the PHP version receives security updates. - * @type bool $is_acceptable Whether the PHP version is still acceptable or warnings - * should be shown and an update recommended. + * @type string $recommended_version The PHP version recommended by WordPress. + * @type string $minimum_version The minimum required PHP version. + * @type bool $is_supported Whether the PHP version is actively supported. + * @type bool $is_secure Whether the PHP version receives security updates. + * @type bool $is_acceptable Whether the PHP version is still acceptable or warnings + * should be shown and an update recommended. + * @type bool $is_lower_than_future_minimum Whether the PHP version is lower than the minimum PHP + * version WordPress will require in a future release. * } */ function wp_check_php_version() { diff --git a/src/wp-admin/includes/post.php b/src/wp-admin/includes/post.php index 39d267b623037..26f543520e447 100644 --- a/src/wp-admin/includes/post.php +++ b/src/wp-admin/includes/post.php @@ -1393,10 +1393,10 @@ function wp_edit_attachments_query_vars( $q = false ) { * @param array|false $q Optional. Array of query variables to use to build the query. * Defaults to the `$_GET` superglobal. * @return array { - * Array containing the post mime types and available post mime types. + * Array containing the post mime types and the available post mime types, in that order. * - * @type array[] $post_mime_types Post mime types. - * @type string[] $avail_post_mime_types Available post mime types. + * @type array[] $0 Post mime types. + * @type string[] $1 Available post mime types. * } */ function wp_edit_attachments_query( $q = false ) { diff --git a/src/wp-includes/class-wp-http.php b/src/wp-includes/class-wp-http.php index 323ec83aeca43..572c452b6a1fc 100644 --- a/src/wp-includes/class-wp-http.php +++ b/src/wp-includes/class-wp-http.php @@ -153,17 +153,18 @@ class WP_Http { * @return array|WP_Error { * Array of response data, or a WP_Error instance upon error. * - * @type \WpOrg\Requests\Utility\CaseInsensitiveDictionary $headers Response headers keyed by name. - * @type string $body Response body. - * @type array $response { + * @type \WpOrg\Requests\Utility\CaseInsensitiveDictionary|array $headers Response headers keyed by name. + * An empty array for a non-blocking request. + * @type string $body Response body. + * @type array $response { * Array of HTTP response data. * * @type int|false $code HTTP response status code. * @type string|false $message HTTP response message. * } - * @type WP_Http_Cookie[] $cookies Array of cookies set by the server. - * @type string|null $filename Optional. Filename of the response. - * @type WP_HTTP_Requests_Response|null $http_response Response object. + * @type WP_Http_Cookie[] $cookies Array of cookies set by the server. + * @type string|null $filename Optional. Filename of the response. + * @type WP_HTTP_Requests_Response|null $http_response Response object. * } */ public function request( $url, $args = array() ) { @@ -715,7 +716,7 @@ public static function processResponse( $response ) { // phpcs:ignore WordPress. * @type int $code The response status code. Default 0. * @type string $message The response message. Default empty. * } - * @type array $newheaders The processed header data as a multidimensional array. + * @type array $headers The processed header data as a multidimensional array. * @type WP_Http_Cookie[] $cookies If the original headers contain the 'Set-Cookie' key, * an array containing `WP_Http_Cookie` objects is returned. * } diff --git a/src/wp-includes/class-wp-xmlrpc-server.php b/src/wp-includes/class-wp-xmlrpc-server.php index 1061dbd1831d2..11b3006506177 100644 --- a/src/wp-includes/class-wp-xmlrpc-server.php +++ b/src/wp-includes/class-wp-xmlrpc-server.php @@ -3190,7 +3190,7 @@ public function wp_deletePage( $args ) { * @type int $1 Page ID. * @type string $2 Username. * @type string $3 Password. - * @type string $4 Content. + * @type array $4 Content struct. * @type int $5 Publish flag. 0 for draft, 1 for publish. * } * @return array|IXR_Error diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index e9d7f986d5801..a676d395ee95c 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -2065,7 +2065,7 @@ public function db_connect( $allow_bail = true ) { * False if the host couldn't be parsed. * * @type string $0 Host name. - * @type string|null $1 Port. + * @type int|null $1 Port. * @type string|null $2 Socket. * @type bool $3 Whether it is an IPv6 address. * } diff --git a/src/wp-includes/fonts.php b/src/wp-includes/fonts.php index 1ffe9be96bb2a..572da46a2e1a6 100644 --- a/src/wp-includes/fonts.php +++ b/src/wp-includes/fonts.php @@ -142,6 +142,14 @@ function wp_get_font_dir() { * @type string $baseurl URL path without subdir. * @type string|false $error False or error message. * } + * @phpstan-return array{ + * path: non-empty-string, + * url: non-empty-string, + * subdir: non-empty-string, + * basedir: non-empty-string, + * baseurl: non-empty-string, + * } + * |array{ error: non-empty-string } */ function wp_font_dir( $create_dir = true ) { /* diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 924684499c324..ebb5898140faf 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -2915,9 +2915,9 @@ function _wp_check_existing_file_names( $filename, $files ) { * @return array { * Information about the newly-uploaded file. * - * @type string $file Filename of the newly-uploaded file. - * @type string $url URL of the uploaded file. - * @type string $type File type. + * @type string $file Optional. Filename of the newly-uploaded file. Not set if there has been an error. + * @type string $url Optional. URL of the uploaded file. Not set if there has been an error. + * @type string $type Optional. File type. Not set if there has been an error. * @type string|false $error Error message, if there has been an error. * } */ diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index 484a8c8f8591c..1002bd1705739 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -4411,6 +4411,7 @@ function is_avatar_comment_type( $comment_type ) { * false or not set if none was found. * @type string|false $url The URL of the avatar that was found, or false. * } + * @phpstan-return array{ found_avatar: bool, url: string|false, ... } */ function get_avatar_data( $id_or_email, $args = null ) { $args = wp_parse_args( diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index da71134bcaf34..5f5b2cac0d229 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -93,6 +93,26 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-admin/edit.php + - + message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''comment'', id\: numeric\-string, data\: string\|false, position\: ''\-1''\|int, supplemental\: array\{in_moderation\: mixed, i18n_comments_text\: string, i18n_moderation_text\: string, parent_approved\: numeric\-string, parent_post_id\: numeric\-string\}\|array\{in_moderation\: mixed, i18n_comments_text\: string, i18n_moderation_text\: string\}\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''edit_comment'', id\: non\-falsy\-string&numeric\-string, data\: string\|false, position\: ''\-1''\|int\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''link\-category'', id\: int, data\: non\-falsy\-string, position\: \-1\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''term'', position\: int\<0, max\>, supplemental\: non\-empty\-array\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php - message: '#^Parameter \#1 \$attachment of function wp_get_attachment_id3_keys expects WP_Post, stdClass given\.$#' identifier: argument.type @@ -153,6 +173,16 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-admin/includes/class-wp-comments-list-table.php + - + message: '#^Parameter \#1 \$args of function get_bookmarks expects array\{orderby\?\: string, order\?\: string, limit\?\: int, category\?\: string, category_name\?\: string, hide_invisible\?\: bool\|int, show_updated\?\: bool\|int, include\?\: string, \.\.\., \.\.\.\}\|string, array\{hide_invisible\: 0, hide_empty\: 0, category\?\: int, search\?\: non\-falsy\-string, orderby\?\: non\-falsy\-string, order\?\: non\-falsy\-string\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-links-list-table.php + - + message: '#^Parameter \#2 \$args of function wp_admin_notice expects array\{type\?\: string, dismissible\?\: bool, id\?\: string, additional_classes\?\: array\, attributes\?\: array\, paragraph_wrap\?\: bool, \.\.\.\}, array\{type\: ''error'', additional_classes\: ''inline''\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-ms-themes-list-table.php - message: '#^Parameter \#3 \$number of function _nx expects int, float given\.$#' identifier: argument.type @@ -369,7 +399,7 @@ parameters: count: 1 path: ../../../src/wp-admin/includes/template.php - - message: '#^Parameter \#1 \$update of method Language_Pack_Upgrader\:\:upgrade\(\) expects string\|false, stdClass given\.$#' + message: '#^Parameter \#1 \$update of method Language_Pack_Upgrader\:\:upgrade\(\) expects string\|false, object\{language\: string, version\: string, updated\: string, english_name\: string, native_name\: string, package\: string, iso\: array\, strings\: array\}&stdClass given\.$#' identifier: argument.type count: 1 path: ../../../src/wp-admin/includes/translation-install.php @@ -509,7 +539,7 @@ parameters: count: 1 path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php - - message: '#^Parameter \#3 \$args of function register_setting expects array, string given\.$#' + message: '#^Parameter \#3 \$args of function register_setting expects array\{type\?\: string, label\?\: string, description\?\: string, sanitize_callback\?\: callable\(\)\: mixed, show_in_rest\?\: array\|bool, default\?\: mixed, \.\.\.\}, ''twentyeleven_theme…'' given\.$#' identifier: argument.type count: 1 path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php @@ -583,6 +613,11 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-content/themes/twentynineteen/inc/helper-functions.php + - + message: '#^Parameter \#1 \$args of function comment_form expects array\{fields\?\: array\{author\?\: string, email\?\: string, url\?\: string, cookies\?\: string, \.\.\.\}, comment_field\?\: string, must_log_in\?\: string, logged_in_as\?\: string, comment_notes_before\?\: string, comment_notes_after\?\: string, action\?\: string, novalidate\?\: bool, \.\.\., \.\.\.\}, array\{title_reply\: null\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/inc/template-tags.php - message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' identifier: argument.type @@ -728,6 +763,11 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-content/themes/twentytwenty/functions.php + - + message: '#^Parameter \#1 \$args of function wp_list_pages expects array\{child_of\?\: int, authors\?\: string, date_format\?\: string, depth\?\: int, echo\?\: bool, exclude\?\: string, include\?\: array, link_after\?\: string, \.\.\., \.\.\.\}\|string, array\{match_menu_classes\: true, show_sub_menu_icons\: true, title_li\: false, walker\: TwentyTwenty_Walker_Page\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/header.php - message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' identifier: argument.type @@ -738,6 +778,26 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-content/themes/twentytwenty/template-parts/entry-author-bio.php + - + message: '#^Parameter \#1 \$args of function wp_nav_menu expects array\{menu\?\: int\|string\|WP_Term, menu_class\?\: string, menu_id\?\: string, container\?\: string, container_class\?\: string, container_id\?\: string, container_aria_label\?\: string, fallback_cb\?\: \(callable\(\)\: mixed\)\|false, \.\.\., \.\.\.\}, array\{theme_location\: ''social'', container\: '''', container_class\: '''', items_wrap\: ''%%3\$s'', menu_id\: '''', menu_class\: '''', depth\: 1, link_before\: ''\'', link_after\: ''\'', fallback_cb\: false\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/footer.php - message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' identifier: argument.type @@ -813,11 +873,26 @@ parameters: identifier: argument.type count: 6 path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#1 \$args of function get_pages expects array\{child_of\?\: int, sort_order\?\: string, sort_column\?\: string, hierarchical\?\: bool, exclude\?\: array\, include\?\: array\, meta_key\?\: string, meta_value\?\: string, \.\.\., \.\.\.\}\|string, array\{number\: 1, hierarchical\: 0\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#1 \$args of method WP_Customize_Manager\:\:get_changeset_posts\(\) expects array\{posts_per_page\?\: int, author\?\: int, post_status\?\: string, exclude_restore_dismissed\?\: bool, \.\.\.\}, array\{post_status\: array\, exclude_restore_dismissed\: false, author\: ''any'', posts_per_page\: 1, order\: ''DESC'', orderby\: ''date''\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php - message: '#^Parameter \#1 \$month of function wp_checkdate expects int, \(string\|false\) given\.$#' identifier: argument.type count: 1 path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#1 \$postarr of function wp_insert_post expects array\{ID\?\: int, post_author\?\: int, post_date\?\: string, post_date_gmt\?\: string, post_content\?\: string, post_content_filtered\?\: string, post_title\?\: string, post_excerpt\?\: string, \.\.\., \.\.\.\}, array\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php - message: '#^Parameter \#2 \$day of function wp_checkdate expects int, \(string\|false\) given\.$#' identifier: argument.type @@ -828,6 +903,11 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#3 \$args of class WP_Customize_Filter_Setting constructor expects array\{type\?\: string, capability\?\: string, theme_supports\?\: array\\|string, default\?\: string, transport\?\: string, validate_callback\?\: callable\(\)\: mixed, sanitize_callback\?\: callable\(\)\: mixed, sanitize_js_callback\?\: callable\(\)\: mixed, \.\.\., \.\.\.\}, array\{transport\: ''postMessage'', type\: ''option'', default\: array\{\}, sanitize_callback\: array\{\$this\(WP_Customize_Nav_Menus\), ''sanitize_nav_menus…''\}\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-nav-menus.php - message: '#^Parameter \#2 \$parent_query of method WP_Date_Query\:\:get_sql_for_clause\(\) expects array, string given\.$#' identifier: argument.type @@ -853,6 +933,11 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-includes/class-wp-duotone.php + - + message: '#^Parameter \#1 \$postarr of function wp_insert_post expects array\{ID\?\: int, post_author\?\: int, post_date\?\: string, post_date_gmt\?\: string, post_content\?\: string, post_content_filtered\?\: string, post_title\?\: string, post_excerpt\?\: string, \.\.\., \.\.\.\}, array\ given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/class-wp-embed.php - message: '#^Parameter \#3 \$priority of function _wp_filter_build_unique_id expects int, false given\.$#' identifier: argument.type @@ -948,6 +1033,11 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-includes/class-wp-xmlrpc-server.php + - + message: '#^Parameter \#1 \$postarr of function wp_insert_post expects array\{ID\?\: int, post_author\?\: int, post_date\?\: string, post_date_gmt\?\: string, post_content\?\: string, post_content_filtered\?\: string, post_title\?\: string, post_excerpt\?\: string, \.\.\., \.\.\.\}, array\{post_author\: int, post_date\: int\|string, post_date_gmt\: int\|string, post_content\: string, post_title\: string, post_category\: array\\|string, post_status\: ''draft''\|''publish''\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-xmlrpc-server.php - message: '#^Parameter \#1 \$term_id of method wp_xmlrpc_server\:\:get_term_custom_fields\(\) expects int, string given\.$#' identifier: argument.type @@ -1243,6 +1333,11 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-includes/nav-menu.php + - + message: '#^Parameter \#1 \$postarr of function wp_insert_post expects array\{ID\?\: int, post_author\?\: int, post_date\?\: string, post_date_gmt\?\: string, post_content\?\: string, post_content_filtered\?\: string, post_title\?\: string, post_excerpt\?\: string, \.\.\., \.\.\.\}, array\{menu_order\: mixed, ping_status\: 0, post_content\: mixed, post_excerpt\: mixed, post_parent\: int\|string\|WP_Error\|null, post_title\: mixed, post_type\: ''nav_menu_item'', post_date\?\: non\-falsy\-string, \.\.\.\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/nav-menu.php - message: '#^Parameter \#2 \$value of function setcookie expects string, int\<1, max\> given\.$#' identifier: argument.type @@ -1283,6 +1378,11 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#1 \$args of function get_pages expects array\{child_of\?\: int, sort_order\?\: string, sort_column\?\: string, hierarchical\?\: bool, exclude\?\: array\, include\?\: array\, meta_key\?\: string, meta_value\?\: string, \.\.\., \.\.\.\}\|string, non\-empty\-array given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post-template.php - message: '#^Parameter \#1 \$attachment of function is_attachment expects array\\|int\|string, WP_Post given\.$#' identifier: argument.type @@ -1391,7 +1491,12 @@ parameters: - message: '#^Parameter \#1 \$data_object of method WP_REST_Controller\:\:update_additional_fields_for_object\(\) expects object, array given\.$#' identifier: argument.type - count: 2 + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php + - + message: '#^Parameter \#1 \$data_object of method WP_REST_Controller\:\:update_additional_fields_for_object\(\) expects object, array\ given\.$#' + identifier: argument.type + count: 1 path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php - message: '#^Parameter \#1 \$comment_id of function get_comment_type expects int\|WP_Comment, string given\.$#' @@ -1583,6 +1688,11 @@ parameters: identifier: argument.type count: 2 path: ../../../src/wp-includes/user.php + - + message: '#^Parameter \#1 \$args of function register_sidebar expects array\{name\?\: string, id\?\: string, description\?\: string, class\?\: string, before_widget\?\: string, after_widget\?\: string, before_title\?\: string, after_title\?\: string, \.\.\., \.\.\.\}\|string, non\-empty\-array\\|string\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/widgets.php - message: '#^Parameter \#3 \$control_callback of function wp_register_widget_control expects callable\(\)\: mixed, '''' given\.$#' identifier: argument.type diff --git a/tests/phpstan/baselines/assign.propertyType.neon b/tests/phpstan/baselines/assign.propertyType.neon index f53f802b7d1da..b2f4452c16a03 100644 --- a/tests/phpstan/baselines/assign.propertyType.neon +++ b/tests/phpstan/baselines/assign.propertyType.neon @@ -154,7 +154,7 @@ parameters: count: 1 path: ../../../src/wp-includes/taxonomy.php - - message: '#^Static property WP_Widget_Media\:\:\$l10n_defaults \(array\\) does not accept array\\.$#' + message: '#^Static property WP_Widget_Media\:\:\$l10n_defaults \(array\\) does not accept array\\|string\>\.$#' identifier: assign.propertyType count: 1 path: ../../../src/wp-includes/widgets/class-wp-widget-media.php diff --git a/tests/phpstan/baselines/offsetAccess.notFound.neon b/tests/phpstan/baselines/offsetAccess.notFound.neon index a5e2eb0698cc8..4470319b88805 100644 --- a/tests/phpstan/baselines/offsetAccess.notFound.neon +++ b/tests/phpstan/baselines/offsetAccess.notFound.neon @@ -19,7 +19,7 @@ parameters: ignoreErrors: - - message: '#^Offset float does not exist on list\.$#' + message: '#^Offset float does not exist on list\\.$#' identifier: offsetAccess.notFound count: 1 path: ../../../src/wp-admin/includes/class-wp-site-health.php From 01605e7b29d03b61182550b78c19335c21874836 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 10:40:09 +0000 Subject: [PATCH 03/17] Build/Test Tools: Derive shapes from hashes on objects too. An object shape in PHPStan is structural, so one derived from a bare `@return object { ... }` describes the members and nothing else. That made it useless where core actually puts those values: `WP_Taxonomy::$labels` and `WP_Post_Type::$cap` are declared `stdClass`, which a bare `object{...}` is not, so assigning one was an error and the hashes had to be skipped. Naming the class in the docblock resolves it. A hash on a class produces an intersection, `stdClass&object{...}`, which is still the class and now also carries the members, so it is assignable to a property declared `stdClass` and reads of those members are typed. The three returns that build one with a cast say `stdClass` rather than `object` to match what they return: `get_taxonomy_labels()`, `get_post_type_capabilities()` and `wp_get_scheduled_event()`. An intersection inside a union is parenthesized, so `wp_get_scheduled_event()` reads `(stdClass&object{...})|false`. Co-authored-by: Pascal Birchler Co-Authored-By: Claude --- src/wp-includes/cron.php | 2 +- src/wp-includes/post.php | 2 +- src/wp-includes/taxonomy.php | 2 +- tests/phpstan/HashNotationVisitor.php | 102 +++++++++++++++--- tests/phpstan/README.md | 11 +- tests/phpstan/baselines/argument.type.neon | 2 +- .../baselines/offsetAccess.notFound.neon | 2 +- 7 files changed, 99 insertions(+), 24 deletions(-) diff --git a/src/wp-includes/cron.php b/src/wp-includes/cron.php index 3fb6a29cb8dc7..faa5b0fd6681e 100644 --- a/src/wp-includes/cron.php +++ b/src/wp-includes/cron.php @@ -765,7 +765,7 @@ function wp_unschedule_hook( $hook, $wp_error = false ) { * Default empty array. * @param int|null $timestamp Optional. Unix timestamp (UTC) of the event. If not specified, the next scheduled event * is returned. Default null. - * @return object|false { + * @return stdClass|false { * The event object. False if the event does not exist. * * @type string $hook Action hook to execute when the event is run. diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 2db73e9a20476..8b407bd4311bb 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -2011,7 +2011,7 @@ function unregister_post_type( $post_type ) { * @see map_meta_cap() * * @param object $args Post type registration arguments. - * @return object { + * @return stdClass { * Object with all the capabilities as member variables. * * @type string $edit_post Capability to edit a post. diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 29317f0a8bf9b..e5aa953275c9d 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -648,7 +648,7 @@ function unregister_taxonomy( $taxonomy ) { * @since 6.6.0 Added the `template_name` label. * * @param WP_Taxonomy $tax Taxonomy object. - * @return object { + * @return stdClass { * Taxonomy labels object. The first default value is for non-hierarchical taxonomies * (like tags) and the second one is for hierarchical taxonomies (like categories). * diff --git a/tests/phpstan/HashNotationVisitor.php b/tests/phpstan/HashNotationVisitor.php index f175d55cb63f9..4ec6789a70de3 100644 --- a/tests/phpstan/HashNotationVisitor.php +++ b/tests/phpstan/HashNotationVisitor.php @@ -1,7 +1,7 @@ `, is left as written. An - * `object` hash is left alone as well: PHPStan's object shapes are - * structural, so one derived for a value core builds as a `stdClass` would - * no longer be assignable to a property declared `stdClass`. + * - The declared type must 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`. A type that is already more specific than the hash, such as + * `array`, is left as written. 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`. * - The hash has to be well formed: every `{` closed by a `}` on a line of its * own, and every `@type` carrying a type and a `$name`. Anything else, and * the whole tag is skipped. @@ -443,10 +444,10 @@ private function substitute( string $declared, array $entries, bool $for_param ) $target = null; foreach ( $members as $position => $member ) { - if ( 'array' !== $member ) { + if ( ! $this->is_shapeable( $member ) ) { continue; } - // Two bare members would leave it ambiguous which one the hash describes. + // Two shapeable members would leave it ambiguous which one the hash describes. if ( null !== $target ) { return null; } @@ -457,30 +458,101 @@ private function substitute( string $declared, array $entries, bool $for_param ) return null; } - $shape = $this->resolve_container( $entries, $for_param ); + $member = $members[ $target ]; + $shape = $this->resolve_container( $entries, $for_param, 'array' === $member ); if ( null === $shape ) { return null; } + /* + * An object shape is structural, so one derived for a value core builds as a + * `stdClass` would no longer be assignable to a property declared `stdClass`. + * Naming the class in the docblock keeps both: the value stays that class, and + * its members are typed by the shape intersected with it. + */ + if ( 'array' !== $member && 'object' !== $member ) { + $shape = $member . '&' . $shape; + + // An intersection inside a union needs parentheses to parse. + if ( count( $members ) > 1 ) { + $shape = '(' . $shape . ')'; + } + } + $members[ $target ] = $shape; return implode( '|', $members ); } + /** + * Reports whether a member of a union type can carry a shape. + * + * `array` and `object` take one directly. A class name takes one through an + * intersection, so the value keeps the class it is documented as, which is + * what makes a `stdClass` hash usable where the class is expected. + * + * @param string $member One member of a union type. + * @return bool + */ + private function is_shapeable( string $member ): bool { + if ( 'array' === $member || 'object' === $member ) { + return true; + } + + // A name PHPDoc gives a meaning of its own is not a class, whatever its shape. + $keywords = array( + 'bool', + 'boolean', + 'callable', + 'double', + 'false', + 'float', + 'int', + 'integer', + 'iterable', + 'list', + 'mixed', + 'never', + 'null', + 'number', + 'numeric', + 'parent', + 'resource', + 'scalar', + 'self', + 'static', + 'string', + 'this', + 'true', + 'void', + ); + + if ( in_array( strtolower( $member ), $keywords, true ) ) { + return false; + } + + return preg_match( '#^\\\\?[A-Za-z_][A-Za-z0-9_]*(?:\\\\[A-Za-z_][A-Za-z0-9_]*)*$#', $member ) === 1; + } + /** * Builds the shape for one hash level. * * @param list $entries Entries of this level. * @param bool $for_param Whether the hash documents a `@param`. + * @param bool $is_array Whether the hash describes an array rather than an object. * @return string|null */ - private function resolve_container( array $entries, bool $for_param ): ?string { + private function resolve_container( array $entries, bool $for_param, bool $is_array ): ?string { /* * A single `...$0` entry describes a repeated value rather than a key. * The hash says nothing about the keys it repeats under, and core uses * both numbered and named ones, so the keys stay `array-key`. */ if ( 1 === count( $entries ) && $entries[0]['variadic'] ) { + if ( ! $is_array ) { + return null; + } + $inner = $this->resolve_entry_type( $entries[0], $for_param ); return null === $inner ? null : sprintf( 'array', $inner ); @@ -515,6 +587,10 @@ private function resolve_container( array $entries, bool $for_param ): ?string { * the shape would be sealed, and reading or testing for an undocumented * key would be reported as an error at every call site that adds one. */ + if ( ! $is_array ) { + return sprintf( 'object{%s}', implode( ', ', $members ) ); + } + return sprintf( 'array{%s%s}', implode( ', ', $members ), $for_param ? ', ...' : '' ); } diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index 759c505c4c991..6288acf674612 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -61,7 +61,7 @@ Core documents the globals a function uses with `@global Type $varname`. `Global ### Hash notation -Core documents the contents of an array argument with a nested list of `@type` tags, [hash notation](https://developer.wordpress.org/coding-standards/inline-documentation-standards/php/#1-1-parameters-that-are-arrays): +Core documents the contents of an array or object with a nested list of `@type` tags, [hash notation](https://developer.wordpress.org/coding-standards/inline-documentation-standards/php/#1-1-parameters-that-are-arrays): ```php /** @@ -74,21 +74,20 @@ Core documents the contents of an array argument with a nested list of `@type` t */ ``` -PHPStan reads that hash as free text, so the value stays a plain `array` and nothing inside it is typed. `HashNotationVisitor` translates it into the array shape PHPStan understands, which for the example above is `array{post_type?: string, post_author?: int, ...}`, so the same documentation serves the reader and the analysis rather than each shape having to be written a second time as a `@phpstan-param`. +PHPStan reads that hash as free text, so the value stays a plain `array` and nothing inside it is typed. `HashNotationVisitor` translates it into the array or object shape PHPStan understands, which for the example above is `array{post_type?: string, post_author?: int, ...}`, so the same documentation serves the reader and the analysis rather than each shape having to be written a second time as a `@phpstan-param`. 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, for example — so a shape that has been tuned in the source is never overwritten by the derived one. -- The declared type has to name a bare `array`, on its own or as one member of a union such as `string|array`. 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`. A type that is already more specific than the hash, such as `array`, is left as written. - The hash has to be well formed: every `{` closed by a `}` on a line of its own, and every `@type` carrying a type and a `$name`. - 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 — unless the description marks one `Optional.`, which the visitor honors. Reading a key that a `@return` hash does not document is therefore reported rather than silently typed as `mixed`. -Two kinds of hash are outside what the visitor covers today: +A hash on a class rather than on `array` or `object` produces an intersection, `stdClass&object{...}`, rather than a bare object shape. PHPStan's object shapes are structural, so a bare `object{...}` derived for a value core builds as a `stdClass` would no longer be assignable to a property declared `stdClass`. Intersecting keeps both: the value stays the class it is documented as, and its members are typed. This is why the returns that build one, such as `get_taxonomy_labels()`, document `stdClass` rather than `object`. -- **`@var` hashes on properties.** A property declaration is inherited by every subclass and has to accept its own default, so a shape there would say more than the hash does: that no subclass may widen the property, and that the declared default already has the shape. -- **`object` hashes**, such as the one on `get_taxonomy_labels()`. PHPStan's object shapes are structural, so a shape derived for a value core builds as a `stdClass` is no longer assignable to a property declared `stdClass`. Covering these needs the docblocks to name the class rather than `object`, so that the shape can be intersected with it. +One kind of hash is outside what the visitor covers today: **a `@var` hash on a property**. A property declaration is inherited by every subclass and has to accept its own default, so a shape there would say more than the hash does — that no subclass may widen the property, and that the declared default already has the shape. Hashes are also written on hook docblocks, where core documents `apply_filters()` and `do_action()`. Those are not attached to a function, so they are outside what this visitor sees, and the value a filter passes stays typed by [the hook extensions below](#hook-documentation). diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index 5f5b2cac0d229..ecf075d0efea0 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -94,7 +94,7 @@ parameters: count: 1 path: ../../../src/wp-admin/edit.php - - message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''comment'', id\: numeric\-string, data\: string\|false, position\: ''\-1''\|int, supplemental\: array\{in_moderation\: mixed, i18n_comments_text\: string, i18n_moderation_text\: string, parent_approved\: numeric\-string, parent_post_id\: numeric\-string\}\|array\{in_moderation\: mixed, i18n_comments_text\: string, i18n_moderation_text\: string\}\} given\.$#' + message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''comment'', id\: numeric\-string, data\: string\|false, position\: ''\-1''\|int, supplemental\: array\{in_moderation\: int, i18n_comments_text\: string, i18n_moderation_text\: string, parent_approved\: numeric\-string, parent_post_id\: numeric\-string\}\|array\{in_moderation\: int, i18n_comments_text\: string, i18n_moderation_text\: string\}\} given\.$#' identifier: argument.type count: 1 path: ../../../src/wp-admin/includes/ajax-actions.php diff --git a/tests/phpstan/baselines/offsetAccess.notFound.neon b/tests/phpstan/baselines/offsetAccess.notFound.neon index 4470319b88805..a5e2eb0698cc8 100644 --- a/tests/phpstan/baselines/offsetAccess.notFound.neon +++ b/tests/phpstan/baselines/offsetAccess.notFound.neon @@ -19,7 +19,7 @@ parameters: ignoreErrors: - - message: '#^Offset float does not exist on list\\.$#' + message: '#^Offset float does not exist on list\.$#' identifier: offsetAccess.notFound count: 1 path: ../../../src/wp-admin/includes/class-wp-site-health.php From 282eb833a4d01de4d79e90fb670fabb2ef49afb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 10:48:32 +0000 Subject: [PATCH 04/17] Build/Test Tools: Attach the rewritten docblock without a position. The docblock the visitor builds is longer than the one in the file, so the original start line and file position no longer describe where its text lives. `GlobalDocBlockVisitor` leaves both off for the same reason; this one was passing them through. No change to what is derived locally, and the analysis stays green. Co-authored-by: Pascal Birchler Co-Authored-By: Claude --- tests/phpstan/HashNotationVisitor.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/phpstan/HashNotationVisitor.php b/tests/phpstan/HashNotationVisitor.php index 4ec6789a70de3..39b980f87ef2e 100644 --- a/tests/phpstan/HashNotationVisitor.php +++ b/tests/phpstan/HashNotationVisitor.php @@ -127,7 +127,13 @@ public function enterNode( Node $node ): ?Node { return null; } - $node->setDocComment( new Doc( $merged, $doc->getStartLine(), $doc->getStartFilePos() ) ); + /* + * The rewritten docblock is longer than the one in the file, so it carries no + * position. Keeping the original start would point at a span of the source that + * no longer holds this text, which is the same reason GlobalDocBlockVisitor + * leaves it off. + */ + $node->setDocComment( new Doc( $merged ) ); return null; } From 7feda5613dc89b3e44861c07487bd1a20d9517d4 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Sat, 22 Aug 2026 12:45:54 +0000 Subject: [PATCH 05/17] Build/Test Tools: Key the PHPStan cache on the analysis configuration. The cache CI keeps for PHPStan holds more than the analysis results. PHPStan also stores what it read out of each source file there, the docblocks and signatures it found, keyed by that file's contents and nothing else. A parser node visitor changes what reading a file yields without changing the file, so a cache written before `HashNotationVisitor` existed answers with the docblocks core had before it, and no shape is derived from any hash. That is what the run on this branch was reporting. It restored the cache trunk's run on the base commit wrote, so every file this branch does not touch came back from that cache without a shape, the baselines written against those shapes matched nothing, and PHPStan reported them under `ignore.unmatched` and `ignore.count`. Reproduced by analysing the base commit with an empty `.cache` and then analysing this branch on top of the cache that left behind: the same reports, on the same lines, down to `register_setting` printing trunk's `expects array, string given`. With `.cache` cleared the branch is green, which is why it looked green locally. Keying the cache on `phpstan.neon.dist` and the sources in `tests/phpstan` keeps a run from restoring a cache that predates either. The baselines are left out of the key: they only decide which reported errors are ignored, PHPStan invalidates the results cache on a configuration change by itself, and including them would discard the whole cache every time one is regenerated. Nothing keys the cache for a local run, so `tests/phpstan/README.md` says to clear it by hand after changing anything in that directory. Co-authored-by: Pascal Birchler Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Are1pyfSoWBPabAmc4vPP1 --- .../reusable-phpstan-static-analysis-v1.yml | 17 ++++++++++++++--- tests/phpstan/README.md | 12 ++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reusable-phpstan-static-analysis-v1.yml b/.github/workflows/reusable-phpstan-static-analysis-v1.yml index 26a14ba8d890f..10a5ef3b384df 100644 --- a/.github/workflows/reusable-phpstan-static-analysis-v1.yml +++ b/.github/workflows/reusable-phpstan-static-analysis-v1.yml @@ -84,13 +84,24 @@ jobs: - name: Build WordPress run: npm run build:dev + # The directory holds more than the analysis results. PHPStan also stores what it read out of + # each source file there, the docblocks and signatures it found, keyed by that file's contents + # and nothing else. The extensions in `tests/phpstan` change what reading a file yields without + # changing the file: a parser node visitor rewrites a docblock in the syntax tree, and the bytes + # on disk stay as they were. A cache written before one of them changed therefore answers with + # what the old code saw, and the analysis silently runs against types no longer derived. + # + # Keying on the configuration and the extensions keeps a run from restoring a cache that + # predates either. The baselines are left out of the key: they only decide which reported errors + # are ignored, PHPStan invalidates the results cache on a configuration change by itself, and + # including them would discard the whole cache every time one is regenerated. - name: Cache PHP Static Analysis scan cache uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .cache # This is defined in the base.neon file. - key: "phpstan-result-cache-${{ github.run_id }}" + key: "phpstan-result-cache-${{ hashFiles('phpstan.neon.dist', 'tests/phpstan/*.neon', 'tests/phpstan/*.php') }}-${{ github.run_id }}" restore-keys: | - phpstan-result-cache- + phpstan-result-cache-${{ hashFiles('phpstan.neon.dist', 'tests/phpstan/*.neon', 'tests/phpstan/*.php') }}- - name: Run PHP static analysis tests id: phpstan @@ -193,7 +204,7 @@ jobs: if: ${{ !cancelled() }} with: path: .cache - key: "phpstan-result-cache-${{ github.run_id }}" + key: "phpstan-result-cache-${{ hashFiles('phpstan.neon.dist', 'tests/phpstan/*.neon', 'tests/phpstan/*.php') }}-${{ github.run_id }}" - name: Ensure version-controlled files are not modified or deleted run: git diff --exit-code diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index 6288acf674612..44e6a630f9e80 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -196,4 +196,16 @@ PHPStan can be resource-intensive, especially on large codebases like WordPress. PHPStan caches analysis results to speed up subsequent runs. You can see information about the results cache by running `analyse` with the `-vv` or `-vvv` flag. +### Clear the cache after changing anything in this directory + +The `.cache` directory holds more than the results. PHPStan also stores what it read out of each source file there, the docblocks and signatures it found, keyed by that file's contents and nothing else. The sources in this directory change what reading a file yields without changing the file: `HashNotationVisitor` rewrites a docblock in the syntax tree, and the bytes on disk stay as they were. + +A cache written before one of them changed therefore answers with what the old code saw. The run does not fail or warn; it reports against types that are no longer derived, so a visitor can look as though it does nothing, or as though it does less than it does. So clear the cache by hand after editing anything here: + +```bash +rm -rf .cache +``` + +The results cache alone is not the problem. PHPStan invalidates that itself when the configuration changes, and says which part of it no longer matches under `-vv`. What survives is the per-file reflection, which it has no way to know is stale. CI keys its cache on these files for the same reason; see `.github/workflows/reusable-phpstan-static-analysis-v1.yml`. + Sometimes, due to the lack of type information in legacy code, PHPStan may still struggle to analyze certain parts of the codebase. In such cases, you can use the `--debug` flag to disable caching and see which files are causing issues. From 1e212791d537f2f6aec979f643e95844b31ecd1a Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 28 Aug 2026 08:57:22 +0200 Subject: [PATCH 06/17] Update tests/phpstan/README.md Co-authored-by: Weston Ruter --- 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 44e6a630f9e80..68488f556579d 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -206,6 +206,6 @@ A cache written before one of them changed therefore answers with what the old c rm -rf .cache ``` -The results cache alone is not the problem. PHPStan invalidates that itself when the configuration changes, and says which part of it no longer matches under `-vv`. What survives is the per-file reflection, which it has no way to know is stale. CI keys its cache on these files for the same reason; see `.github/workflows/reusable-phpstan-static-analysis-v1.yml`. +The results cache alone is not the problem. PHPStan invalidates that itself when the configuration changes, and says which part of it no longer matches under `-vv`. What survives is the per-file reflection, which it has no way to know is stale. CI keys its cache on these files for the same reason; see [`.github/workflows/reusable-phpstan-static-analysis-v1.yml`](../../.github/workflows/reusable-phpstan-static-analysis-v1.yml). Sometimes, due to the lack of type information in legacy code, PHPStan may still struggle to analyze certain parts of the codebase. In such cases, you can use the `--debug` flag to disable caching and see which files are causing issues. From 87ccaf773689a4a0fbc0768c6c4d2bc2a752226d Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 28 Aug 2026 08:37:36 +0000 Subject: [PATCH 07/17] Docs: Correct the upload directory shape, and describe three more. Follow-up to review feedback on the pull request. `wp_upload_dir()` never returns the union its hand-written `@phpstan-return` describes. `_wp_upload_dir()` always sets `error` to `false`, and when the directory cannot be created `wp_upload_dir()` overwrites only that key, so `path`, `url`, `subdir`, `basedir` and `baseurl` are there either way. It is one shape whose `error` is `non-empty-string|false`, and whose `subdir` is empty when the year/month option is off, so a `string` rather than a `non-empty-string`. `wp_get_upload_dir()` carried the same tag with the same two mistakes. With that corrected, `wp_font_dir()` needs no tag of its own: it returns `wp_upload_dir()`'s value unchanged, and the shape its own hash describes is exactly what `wp_upload_dir()` now promises. The tag added for it here is gone again. `wp_upload_bits()` does return two shapes, so it gains the `@phpstan-return` its hash cannot express: the file it wrote, with `error` set to `false`, or an array carrying the message. That arm is left open with `...`, because two of the failures return `wp_upload_dir()`'s array with the message written into it, and its other keys come along; the contract is that `error` is there. Its `type` is `wp_check_filetype()`'s, which is `false` for a name matching no mime type, so the hash says `string|false`. `WP_Http::processHeaders()` returns the headers keyed by lowercased name, each value the header, or every value of a header sent more than once. That is `array`, which the hash now says instead of `array`. `get_post_mime_types()` returns groups keyed by mime type, each a three-item array of the group's plural name, the label for its "Manage" screen, and the count strings `_n_noop()` builds. Typing it, and the `post_mime_types` filter it returns through, lets `wp_edit_attachments_query()` describe the first of the two values it returns, rather than calling it `array[]`, which said the wrong thing about its keys as well. `wp_editPage()` hands its content struct to `mw_editPost()` and reads none of it, so its `@type` points at `mw_newPost()`, where core documents the keys the struct takes, rather than repeating them. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018CG1pXsNoUnbjzti49c1Zy --- src/wp-admin/includes/post.php | 4 ++-- src/wp-includes/class-wp-http.php | 8 ++++---- src/wp-includes/class-wp-xmlrpc-server.php | 2 +- src/wp-includes/fonts.php | 8 -------- src/wp-includes/functions.php | 13 ++++++++----- src/wp-includes/post.php | 9 +++++++-- tests/phpstan/README.md | 2 +- 7 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/wp-admin/includes/post.php b/src/wp-admin/includes/post.php index 26f543520e447..12a798bc9d820 100644 --- a/src/wp-admin/includes/post.php +++ b/src/wp-admin/includes/post.php @@ -1395,8 +1395,8 @@ function wp_edit_attachments_query_vars( $q = false ) { * @return array { * Array containing the post mime types and the available post mime types, in that order. * - * @type array[] $0 Post mime types. - * @type string[] $1 Available post mime types. + * @type array $0 Post mime types. See get_post_mime_types(). + * @type string[] $1 Available post mime types. * } */ function wp_edit_attachments_query( $q = false ) { diff --git a/src/wp-includes/class-wp-http.php b/src/wp-includes/class-wp-http.php index 572c452b6a1fc..13b82d95bfbd2 100644 --- a/src/wp-includes/class-wp-http.php +++ b/src/wp-includes/class-wp-http.php @@ -712,13 +712,13 @@ public static function processResponse( $response ) { // phpcs:ignore WordPress. * Processed string headers. If duplicate headers are encountered, * then a numbered array is returned as the value of that header-key. * - * @type array $response { + * @type array $response { * @type int $code The response status code. Default 0. * @type string $message The response message. Default empty. * } - * @type array $headers The processed header data as a multidimensional array. - * @type WP_Http_Cookie[] $cookies If the original headers contain the 'Set-Cookie' key, - * an array containing `WP_Http_Cookie` objects is returned. + * @type array $headers The processed header data, keyed by lowercased header name. + * @type WP_Http_Cookie[] $cookies If the original headers contain the 'Set-Cookie' key, + * an array containing `WP_Http_Cookie` objects is returned. * } */ public static function processHeaders( $headers, $url = '' ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid diff --git a/src/wp-includes/class-wp-xmlrpc-server.php b/src/wp-includes/class-wp-xmlrpc-server.php index 11b3006506177..015f31c7888bb 100644 --- a/src/wp-includes/class-wp-xmlrpc-server.php +++ b/src/wp-includes/class-wp-xmlrpc-server.php @@ -3190,7 +3190,7 @@ public function wp_deletePage( $args ) { * @type int $1 Page ID. * @type string $2 Username. * @type string $3 Password. - * @type array $4 Content struct. + * @type array $4 Content struct. See mw_newPost() for the recognized keys. * @type int $5 Publish flag. 0 for draft, 1 for publish. * } * @return array|IXR_Error diff --git a/src/wp-includes/fonts.php b/src/wp-includes/fonts.php index 572da46a2e1a6..1ffe9be96bb2a 100644 --- a/src/wp-includes/fonts.php +++ b/src/wp-includes/fonts.php @@ -142,14 +142,6 @@ function wp_get_font_dir() { * @type string $baseurl URL path without subdir. * @type string|false $error False or error message. * } - * @phpstan-return array{ - * path: non-empty-string, - * url: non-empty-string, - * subdir: non-empty-string, - * basedir: non-empty-string, - * baseurl: non-empty-string, - * } - * |array{ error: non-empty-string } */ function wp_font_dir( $create_dir = true ) { /* diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 2cd450a967f8a..951ec55a2db49 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -2346,11 +2346,11 @@ function win_is_writable( $path ) { * @phpstan-return array{ * path: non-empty-string, * url: non-empty-string, - * subdir: non-empty-string, + * subdir: string, * basedir: non-empty-string, * baseurl: non-empty-string, + * error: non-empty-string|false, * } - * |array{ error: non-empty-string } */ function wp_get_upload_dir() { return wp_upload_dir( null, false ); @@ -2395,11 +2395,11 @@ function wp_get_upload_dir() { * @phpstan-return array{ * path: non-empty-string, * url: non-empty-string, - * subdir: non-empty-string, + * subdir: string, * basedir: non-empty-string, * baseurl: non-empty-string, + * error: non-empty-string|false, * } - * |array{ error: non-empty-string } */ function wp_upload_dir( $time = null, $create_dir = true, $refresh_cache = false ) { static $cache = array(), $tested_paths = array(); @@ -2917,9 +2917,12 @@ function _wp_check_existing_file_names( $filename, $files ) { * * @type string $file Optional. Filename of the newly-uploaded file. Not set if there has been an error. * @type string $url Optional. URL of the uploaded file. Not set if there has been an error. - * @type string $type Optional. File type. Not set if there has been an error. + * @type string|false $type Optional. File type, or false if the file doesn't match a mime type. + * Not set if there has been an error. * @type string|false $error Error message, if there has been an error. * } + * @phpstan-return array{ file: non-empty-string, url: non-empty-string, type: string|false, error: false } + * |array{ error: non-empty-string, ... } */ function wp_upload_bits( $name, $deprecated, $bits, $time = null ) { if ( ! empty( $deprecated ) ) { diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 2965a73fab771..d664d7685be68 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -3601,7 +3601,11 @@ function wp_count_attachments( $mime_type = '' ) { * @since 2.9.0 * @since 5.3.0 Added the 'Documents', 'Spreadsheets', and 'Archives' mime type groups. * - * @return array List of post mime types. + * @return array List of post mime types, keyed by mime type group + * or by a comma-separated list of mime types. Each + * value is a three-item array: the plural name of the + * group, the label for its "Manage" screen, and the + * translatable count strings returned by _n_noop(). */ function get_post_mime_types() { $post_mime_types = array( // array( adj, noun ) @@ -3694,7 +3698,8 @@ function get_post_mime_types() { * * @since 2.5.0 * - * @param array $post_mime_types Default list of post mime types. + * @param array $post_mime_types Default list of post mime types. + * See get_post_mime_types(). */ return apply_filters( 'post_mime_types', $post_mime_types ); } diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index 68488f556579d..0c44e16a8abdc 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -78,7 +78,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, for example — so a shape that has been tuned in the source is never overwritten by the derived 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. - 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`. A type that is already more specific than the hash, such as `array`, is left as written. - The hash has to be well formed: every `{` closed by a `}` on a line of its own, and every `@type` carrying a type and a `$name`. - 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 4a9ad7ba92577232f75a1853a5190b27df14b51f Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 28 Aug 2026 10:07:40 +0000 Subject: [PATCH 08/17] Build/Test Tools: Read the hashes HashNotationVisitor was dropping. A hash the visitor skips is skipped silently: no tag is derived, nothing reports it, and the value keeps the type it had. Review found several, and one hash being translated that should not be. `has_phpstan_counterpart()` searched the whole docblock for a hand-written `@phpstan-param` and the variable, any distance apart. A `@phpstan-param` written for one parameter therefore covered the next, and a conditional `@phpstan-return` naming a parameter covered it too. `wp_die()` has both, so its eight-key `$args` hash was never read. The search now walks the tags `split_tags()` has already produced and stays inside the one that was written. Across every function docblock in `src`, that is the only tag it changes. Types are checked by PHPStan's own type parser rather than by a description of the characters a type may contain. That description had already drifted from the grammar it stands in for: it excluded `(` and `)`, which left the parentheses `split_union()` counts unreachable and dropped `Translation_Entry::__construct()` over a single `@type (string|null)[]`. It would have done the same to `?string` or a `callable(): void`. `Array` fell through the `array`/`object` test, which was case-sensitive while every other type name was matched case-insensitively, and came out as an intersection with a class that does not exist. Both comparisons are normalized now, and the keyword list is a constant. `Optional.` is read as the marker the documentation standard defines: the word opening the description, with its period. It was matching the word anywhere in a description built by joining continuation lines, which took it out of prose such as "receives optional mixed input". It now also overrides the numbered-key rule, so the twenty XML-RPC methods documenting `@type int $4 Optional. Publish flag.` describe an argument a caller may leave off, while prose that merely opens with the word, as the "Optional self closing slash" of a match array whose every group is always set, marks nothing. Three smaller reads in `parse_entries()`: a `@type` separated by a tab is recognized, matching what `split_tags()` already accepted; a line below a nested hash no longer joins the description of the entry that opened it, whose own description was read before its `{`; and the intro line of a nested hash becomes that entry's description, which is where an `Optional.` for a whole block is written. A `...$N` entry beside named keys is how core writes "and the rest". It was dropping the hash, and now leaves the shape open, which says the same thing: `wp_maybe_grant_site_health_caps()` and the three query classes derive again. An object hash that would have to stay open is skipped instead, because PHPStan's object shapes have no `...`: sealing the theme data `WP_Theme_Install_List_Table::single_row()` documents would have reported every member the wordpress.org API adds and the hash does not name. A union of a bare `array` and a class is no longer called ambiguous. A `WP_Error` carries no keys, so in `array|WP_Error`, core's most common return signature, the hash is about the array. Thirteen hashes derive because of it, `WP_Http::request()` among them. The replacement splicing the derived tags into the docblock is built by a callback, so a `$0` in a derived tag cannot be read as a backreference. `tests/phpstan/README.md` follows the rules as they now read, and gains the one other way a stale cache is reached: analysing a subset of the tree stores, for every file merely read on its behalf, reflection with no shape in it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018CG1pXsNoUnbjzti49c1Zy --- tests/phpstan/HashNotationVisitor.php | 371 +++++++++++++++++++------- tests/phpstan/README.md | 15 +- 2 files changed, 282 insertions(+), 104 deletions(-) diff --git a/tests/phpstan/HashNotationVisitor.php b/tests/phpstan/HashNotationVisitor.php index 39b980f87ef2e..0d5d9c7953276 100644 --- a/tests/phpstan/HashNotationVisitor.php +++ b/tests/phpstan/HashNotationVisitor.php @@ -13,6 +13,12 @@ use PhpParser\Comment\Doc; use PhpParser\Node; use PhpParser\NodeVisitorAbstract; +use PHPStan\PhpDocParser\Lexer\Lexer; +use PHPStan\PhpDocParser\Parser\ConstExprParser; +use PHPStan\PhpDocParser\Parser\ParserException; +use PHPStan\PhpDocParser\Parser\TokenIterator; +use PHPStan\PhpDocParser\Parser\TypeParser; +use PHPStan\PhpDocParser\ParserConfig; /** * Reads WordPress hash notation from `@param` and `@return` tags and injects @@ -44,17 +50,23 @@ * * - A `@phpstan-` counterpart already written by hand always wins; the tag it * covers is skipped, so a shape that has been tuned in the source is never - * overwritten by the derived one. + * overwritten by the derived one. Only that tag: a `@phpstan-param` written + * 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 must 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`. A type that is already more specific than the hash, 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 * 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`. * - The hash has to be well formed: every `{` closed by a `}` on a line of its - * own, and every `@type` carrying a type and a `$name`. Anything else, and - * the whole tag is skipped. + * own, and every `@type` carrying a `$name` and a type PHPStan's own type + * parser reads as one. Anything else, and the whole tag is skipped. + * - An object hash that would have to stay open is skipped: PHPStan's object + * shapes have a fixed member list and no `...`, so sealing one would report + * every member the hash does not name. * - A parameter taken by reference is skipped. PHPStan checks a by-reference * argument in both directions, so a shape there is a contract every caller's * variable has to satisfy before the call, which is not what the hash says. @@ -62,16 +74,19 @@ * Keys of a `@param` hash are optional, at every level, because a caller may * pass any subset of them, and a shape whose keys were required would report * every partial array as an error. Keys of a `@return` hash are required, since - * they describe a value core itself builds, unless the description marks one - * `Optional.` Numbered keys such as `$0`, `$1` used for positional arguments - * are required in either case. + * 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. * * 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. * A sealed shape would report reading or testing for any other key as an error, * and would contradict the conditional return types core writes by hand. The * shape of a `@return` hash is sealed, so reading a key core does not document - * is reported rather than silently typed as `mixed`. + * is reported rather than silently typed as `mixed` — unless the hash itself + * says otherwise, by listing a `...$N` entry beside its named keys. * * @link https://developer.wordpress.org/coding-standards/inline-documentation-standards/php/#1-1-parameters-that-are-arrays Hash notation in the documentation standards. * @link https://github.com/php-stubs/wordpress-stubs/blob/master/src/Visitor.php The equivalent translation php-stubs/wordpress-stubs performs when generating stubs, MIT license. @@ -90,6 +105,59 @@ final class HashNotationVisitor extends NodeVisitorAbstract { */ private const HASH_TAGS = array( 'param', 'return' ); + /** + * Names PHPDoc gives a meaning of its own, which are therefore not classes. + * + * `array` and `object` are left out: they take a shape directly rather than + * through an intersection, and `is_shapeable()` answers for them first. + */ + private const TYPE_KEYWORDS = array( + 'bool' => true, + 'boolean' => true, + 'callable' => true, + 'double' => true, + 'false' => true, + 'float' => true, + 'int' => true, + 'integer' => true, + 'iterable' => true, + 'list' => true, + 'mixed' => true, + 'never' => true, + 'null' => true, + 'number' => true, + 'numeric' => true, + 'parent' => true, + 'resource' => true, + 'scalar' => true, + 'self' => true, + 'static' => true, + 'string' => true, + 'this' => true, + 'true' => true, + 'void' => true, + ); + + /** + * Lexer for the type parser, built on first use. + */ + private ?Lexer $lexer = null; + + /** + * PHPStan's own PHPDoc type parser, built on first use. + */ + private ?TypeParser $type_parser = null; + + /** + * Answers `parses_as_type()` has already given, keyed by the type. + * + * Core writes a small vocabulary of types across a great many hashes, so the + * same handful of strings is asked about over and over. + * + * @var array + */ + private array $parsed = array(); + /** * Translates the hashes in a node's docblock into `@phpstan-*` shapes. * @@ -107,7 +175,7 @@ public function enterNode( Node $node ): ?Node { } $text = $doc->getText(); - if ( ! str_contains( $text, '@type ' ) ) { + if ( preg_match( '#@type[ \t]#', $text ) !== 1 ) { return null; } @@ -121,8 +189,21 @@ public function enterNode( Node $node ): ?Node { $lines[] = ' * ' . $addition; } - // Insert the derived tags just before the closing `*/`. - $merged = preg_replace( '#\s*\*/\s*$#', "\n" . implode( "\n", $lines ) . "\n */", $text, 1 ); + /* + * Insert the derived tags just before the docblock's closing marker. The + * replacement comes from a callback rather than being passed to + * preg_replace() as a string, because a derived tag ends in the variable + * it documents, and a `$0` there would be read as a backreference to the + * match rather than as the name it is. + */ + $merged = preg_replace_callback( + '#\s*\*/\s*$#', + static function () use ( $lines ): string { + return "\n" . implode( "\n", $lines ) . "\n */"; + }, + $text, + 1 + ); if ( ! is_string( $merged ) ) { return null; } @@ -164,8 +245,10 @@ private function by_reference_parameters( Node\FunctionLike $node ): array { */ private function build_additions( string $text, array $by_reference ): array { $additions = array(); + $tags = $this->split_tags( $text ); + $covered = $this->phpstan_counterparts( $tags ); - foreach ( $this->split_tags( $text ) as $tag ) { + foreach ( $tags as $tag ) { if ( ! in_array( $tag['name'], self::HASH_TAGS, true ) ) { continue; } @@ -199,11 +282,12 @@ private function build_additions( string $text, array $by_reference ): array { * before the call. The hash describes what the function reads, not a * contract on the caller's variable, so it is left out. */ - if ( 'param' === $tag['name'] && isset( $by_reference[ (string) $variable ] ) ) { + if ( 'param' === $tag['name'] && isset( $by_reference[ $variable ] ) ) { continue; } - if ( $this->has_phpstan_counterpart( $text, $tag['name'], $variable ) ) { + $key = 'return' === $tag['name'] ? 'return' : 'param $' . $variable; + if ( isset( $covered[ $key ] ) ) { continue; } @@ -222,7 +306,7 @@ private function build_additions( string $text, array $by_reference ): array { '@phpstan-%s %s%s', $tag['name'], $type, - null !== $variable && 'return' !== $tag['name'] ? ' $' . $variable : '' + 'return' === $tag['name'] ? '' : ' $' . $variable ); } @@ -268,32 +352,52 @@ private function split_tags( string $text ): array { } /** - * Reports whether the docblock already documents this tag for PHPStan. + * Collects what the docblock already documents for PHPStan by hand. * - * @param string $text Raw docblock text. - * @param string $tag Tag name, one of `param`, `return` or `var`. - * @param string|null $variable Variable the tag documents, without the `$`. - * @return bool + * A hand-written shape often spans several lines, so the variable a + * `@phpstan-param` documents can be far from the tag that opens it. The + * search stays inside that one tag rather than running over the whole + * docblock: a `@phpstan-param` written for one parameter says nothing about + * the next, and a conditional `@phpstan-return` naming a parameter is not a + * shape for it. + * + * @param list}> $tags Tags of the docblock. + * @return array Set keyed as `param $name`, or `return`. */ - private function has_phpstan_counterpart( string $text, string $tag, ?string $variable ): bool { - if ( 'return' === $tag ) { - return str_contains( $text, '@phpstan-return' ); - } + private function phpstan_counterparts( array $tags ): array { + $covered = array(); - if ( null === $variable ) { - return str_contains( $text, '@phpstan-' . $tag ); + foreach ( $tags as $tag ) { + if ( 'phpstan-return' === $tag['name'] ) { + $covered['return'] = true; + continue; + } + + if ( 'phpstan-param' !== $tag['name'] ) { + continue; + } + + $written = trim( $tag['header'] . ' ' . implode( ' ', $tag['body'] ) ); + $split = $this->split_type( $written ); + + if ( null !== $split && preg_match( '#^\$([A-Za-z0-9_]+)#', $split[1], $matches ) === 1 ) { + $covered[ 'param $' . $matches[1] ] = true; + continue; + } + + /* + * A tag whose type cannot be split covers every name it mentions. It + * was written by hand for a reason, and overwriting it would leave two + * shapes for the same parameter. + */ + if ( preg_match_all( '#\$([A-Za-z0-9_]+)#', $written, $matches ) > 0 ) { + foreach ( $matches[1] as $name ) { + $covered[ 'param $' . $name ] = true; + } + } } - /* - * A hand-written shape often spans several lines, so the variable it - * documents can be far from the tag that opens it. Matching the tag and - * the variable without requiring them to be adjacent keeps a multi-line - * `@phpstan-param array{ ... } $args` recognized. - */ - return preg_match( - '#@phpstan-' . $tag . '\s.*?\$' . preg_quote( $variable, '#' ) . '\b#s', - $text - ) === 1; + return $covered; } /** @@ -305,12 +409,14 @@ private function has_phpstan_counterpart( string $text, string $tag, ?string $va * * @param list $lines Body lines of the tag, with docblock furniture removed. * @param int $index Current position in `$lines`, advanced as entries are read. + * @param string $intro Set to the prose written above this level's first entry. * @return list}>|null * Entries of this level, or null if the hash is malformed. */ - private function parse_entries( array $lines, int &$index ): ?array { + private function parse_entries( array $lines, int &$index, string &$intro = '' ): ?array { $entries = array(); $last = null; + $intro = ''; $count = count( $lines ); while ( $index < $count ) { @@ -321,23 +427,39 @@ private function parse_entries( array $lines, int &$index ): ?array { return $entries; } - if ( str_starts_with( $line, '@type ' ) ) { - $entry = $this->parse_entry( substr( $line, 6 ) ); + if ( preg_match( '#^@type[ \t]+(.*)$#', $line, $matches ) === 1 ) { + $entry = $this->parse_entry( $matches[1] ); if ( null === $entry ) { return null; } if ( $entry['opens'] ) { - $children = $this->parse_entries( $lines, $index ); + $nested = ''; + $children = $this->parse_entries( $lines, $index, $nested ); if ( null === $children || array() === $children ) { return null; } + $entry['children'] = $children; + + /* + * An entry that opens a hash carries no description beside its + * `@type`, because the `{` ends the line. What describes it is the + * hash's own intro, which is where a marker such as `Optional.` + * for the whole block is written. + */ + $entry['description'] = trim( $entry['description'] . ' ' . $nested ); } unset( $entry['opens'] ); $entries[] = $entry; - $last = count( $entries ) - 1; + + /* + * A line below a nested hash describes the level that hash sits in + * rather than the entry that opened it, whose description was read + * before its `{`. It belongs to no key, so nothing collects it. + */ + $last = array() === $entry['children'] ? count( $entries ) - 1 : null; continue; } @@ -346,8 +468,14 @@ private function parse_entries( array $lines, int &$index ): ?array { return null; } - if ( '' !== $line && null !== $last ) { + if ( '' === $line ) { + continue; + } + + if ( null !== $last ) { $entries[ $last ]['description'] .= ' ' . $line; + } elseif ( array() === $entries ) { + $intro = '' === $intro ? $line : $intro . ' ' . $line; } } @@ -443,29 +571,49 @@ private function split_type( string $text ): ?array { * @return string|null The type with the shape substituted in, or null if it cannot be. */ private function substitute( string $declared, array $entries, bool $for_param ): ?string { + if ( ! $this->parses_as_type( $declared ) ) { + return null; + } + $members = $this->split_union( $declared ); if ( null === $members ) { return null; } - $target = null; + $bare = array(); + $shapeable = array(); + foreach ( $members as $position => $member ) { if ( ! $this->is_shapeable( $member ) ) { continue; } - // Two shapeable members would leave it ambiguous which one the hash describes. - if ( null !== $target ) { - return null; + + $shapeable[] = $position; + $normalized = strtolower( $member ); + + if ( 'array' === $normalized || 'object' === $normalized ) { + $bare[] = $position; } - $target = $position; } - if ( null === $target ) { + /* + * A class beside a bare `array` is what the function returns instead of the + * array, as `array|WP_Error` says, and never what the hash describes: a + * `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. + */ + if ( 1 === count( $bare ) ) { + $target = $bare[0]; + } elseif ( array() === $bare && 1 === count( $shapeable ) ) { + $target = $shapeable[0]; + } else { return null; } - $member = $members[ $target ]; - $shape = $this->resolve_container( $entries, $for_param, 'array' === $member ); + $member = $members[ $target ]; + $normalized = strtolower( $member ); + $shape = $this->resolve_container( $entries, $for_param, 'array' === $normalized ); if ( null === $shape ) { return null; } @@ -476,7 +624,7 @@ private function substitute( string $declared, array $entries, bool $for_param ) * Naming the class in the docblock keeps both: the value stays that class, and * its members are typed by the shape intersected with it. */ - if ( 'array' !== $member && 'object' !== $member ) { + if ( 'array' !== $normalized && 'object' !== $normalized ) { $shape = $member . '&' . $shape; // An intersection inside a union needs parentheses to parse. @@ -501,39 +649,15 @@ private function substitute( string $declared, array $entries, bool $for_param ) * @return bool */ private function is_shapeable( string $member ): bool { - if ( 'array' === $member || 'object' === $member ) { + $normalized = strtolower( $member ); + + // PHP type names are case-insensitive, and core writes `Array` in places. + if ( 'array' === $normalized || 'object' === $normalized ) { return true; } // A name PHPDoc gives a meaning of its own is not a class, whatever its shape. - $keywords = array( - 'bool', - 'boolean', - 'callable', - 'double', - 'false', - 'float', - 'int', - 'integer', - 'iterable', - 'list', - 'mixed', - 'never', - 'null', - 'number', - 'numeric', - 'parent', - 'resource', - 'scalar', - 'self', - 'static', - 'string', - 'this', - 'true', - 'void', - ); - - if ( in_array( strtolower( $member ), $keywords, true ) ) { + if ( isset( self::TYPE_KEYWORDS[ $normalized ] ) ) { return false; } @@ -565,9 +689,18 @@ private function resolve_container( array $entries, bool $for_param, bool $is_ar } $members = array(); + $open = $for_param; + foreach ( $entries as $entry ) { + /* + * A `...$N` entry beside named ones is core's way of writing "and the + * rest", as the positional hashes of `wp_maybe_grant_site_health_caps()` + * and `WP_Meta_Query` do. The named keys are kept and the shape is left + * open, which says the same thing about the keys it does not name. + */ if ( $entry['variadic'] ) { - return null; + $open = true; + continue; } $type = $this->resolve_entry_type( $entry, $for_param ); @@ -587,17 +720,21 @@ private function resolve_container( array $entries, bool $for_param, bool $is_ar return null; } + /* + * PHPStan's object shapes have no `...`, so an object hash that has to stay + * open cannot be expressed as one and is left alone rather than sealed. + */ + if ( ! $is_array ) { + return $open ? null : sprintf( 'object{%s}', implode( ', ', $members ) ); + } + /* * A `@param` hash lists the keys core reads, not the only keys a caller * may pass, so its shape stays open with a trailing `...`. Without it * the shape would be sealed, and reading or testing for an undocumented * key would be reported as an error at every call site that adds one. */ - if ( ! $is_array ) { - return sprintf( 'object{%s}', implode( ', ', $members ) ); - } - - return sprintf( 'array{%s%s}', implode( ', ', $members ), $for_param ? ', ...' : '' ); + return sprintf( 'array{%s%s}', implode( ', ', $members ), $open ? ', ...' : '' ); } /** @@ -609,7 +746,7 @@ private function resolve_container( array $entries, bool $for_param, bool $is_ar */ private function resolve_entry_type( array $entry, bool $for_param ): ?string { if ( array() === $entry['children'] ) { - return $this->validate_type( $entry['type'] ); + return $this->parses_as_type( $entry['type'] ) ? $entry['type'] : null; } return $this->substitute( $entry['type'], $entry['children'], $for_param ); @@ -623,16 +760,27 @@ private function resolve_entry_type( array $entry, bool $for_param ): ?string { * @return bool */ private function is_optional( array $entry, bool $for_param ): bool { + /* + * The documentation standard opens the description of an optional value + * with `Optional.`, so that sentence is the marker. Matching the bare word + * would take it out of prose that only mentions it: from a callback that + * "receives optional mixed input", or from the "Optional self closing + * slash" of a match array whose every group is always set. + */ + if ( preg_match( '#^\s*Optional\.#i', $entry['description'] ) === 1 ) { + return true; + } + /* * A `@return` hash describes a value core builds, so its keys are * present unless the description says otherwise. `Default ...` is not * that: a key documented with a default is still always set. */ if ( ! $for_param ) { - return preg_match( '#\bOptional\b#i', $entry['description'] ) === 1; + return false; } - // Numbered keys document positional arguments, which are always present. + // Numbered keys document positional arguments, which are passed in order. if ( preg_match( '#^[0-9]+$#', $entry['name'] ) === 1 ) { return false; } @@ -655,29 +803,54 @@ private function format_key( string $name ): string { } /** - * Returns a type only if it is shaped like one. + * Reports whether a string PHPStan would have to read as a type parses as one. * - * Guards against prose that has drifted into the type column of a `@type` - * tag, which would otherwise be emitted as a type PHPStan cannot parse. + * Guards against prose that has drifted into the type column of a `@type` tag, + * which would otherwise be emitted as a type PHPStan cannot parse. The question + * is answered by PHPStan's own type parser rather than by a description of what + * a type may contain, because such a description has to be kept in step with a + * grammar that keeps growing, and silently rejects the notation it has not + * caught up with: `?string`, `(string|null)[]` and `callable(): void` are all + * types core writes, or could. * * @param string $type Type as written in the docblock. - * @return string|null + * @return bool */ - private function validate_type( string $type ): ?string { - return $this->split_union( $type ) === null ? null : $type; + private function parses_as_type( string $type ): bool { + if ( isset( $this->parsed[ $type ] ) ) { + return $this->parsed[ $type ]; + } + + if ( null === $this->lexer || null === $this->type_parser ) { + $config = new ParserConfig( array() ); + $this->lexer = new Lexer( $config ); + $this->type_parser = new TypeParser( $config, new ConstExprParser( $config ) ); + } + + try { + $tokens = new TokenIterator( $this->lexer->tokenize( $type ) ); + $this->type_parser->parse( $tokens ); + + // Prose whose first word happens to parse leaves the rest of itself behind. + $this->parsed[ $type ] = $tokens->isCurrentTokenType( Lexer::TOKEN_END ); + } catch ( ParserException $exception ) { + $this->parsed[ $type ] = false; + } + + return $this->parsed[ $type ]; } /** * Splits a union type into its members, ignoring `|` inside brackets. * + * The type is expected to have been through `parses_as_type()` already; this + * only finds the `|` that separate its top-level members. + * * @param string $type Type as written in the docblock. - * @return list|null Members, or null if the type is not well formed. + * @return list|null Members, or null if the brackets are unbalanced. */ private function split_union( string $type ): ?array { $type = trim( $type ); - if ( preg_match( '#^[A-Za-z0-9_\\\\|<>{},:\'"\[\]\#\-\. ]+$#', $type ) !== 1 ) { - return null; - } $members = array(); $member = ''; diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index 0c44e16a8abdc..6c206dd5b5379 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -78,12 +78,15 @@ 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. -- 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`. A type that is already more specific than the hash, such as `array`, is left as written. -- The hash has to be well formed: every `{` closed by a `}` on a line of its own, and every `@type` carrying a type and a `$name`. +- 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 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. +- An object hash that would have to stay open is skipped. PHPStan's object shapes have a fixed member list and no `...`, so sealing one would report every member the hash does not name — which is what a hash on a value from an external API, such as the theme data `WP_Theme_Install_List_Table::single_row()` documents, would produce. - 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 — unless the description marks one `Optional.`, which the visitor honors. Reading a key that a `@return` hash does not document is therefore reported rather than silently typed as `mixed`. +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. + +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`. A hash on a class rather than on `array` or `object` produces an intersection, `stdClass&object{...}`, rather than a bare object shape. PHPStan's object shapes are structural, so a bare `object{...}` derived for a value core builds as a `stdClass` would no longer be assignable to a property declared `stdClass`. Intersecting keeps both: the value stays the class it is documented as, and its members are typed. This is why the returns that build one, such as `get_taxonomy_labels()`, document `stdClass` rather than `object`. @@ -206,6 +209,8 @@ A cache written before one of them changed therefore answers with what the old c rm -rf .cache ``` -The results cache alone is not the problem. PHPStan invalidates that itself when the configuration changes, and says which part of it no longer matches under `-vv`. What survives is the per-file reflection, which it has no way to know is stale. CI keys its cache on these files for the same reason; see [`.github/workflows/reusable-phpstan-static-analysis-v1.yml`](../../.github/workflows/reusable-phpstan-static-analysis-v1.yml). +The results cache alone is not the problem. PHPStan invalidates that itself when the configuration changes, and [`HookDocsResultCacheMetaExtension`](HookDocsResultCacheMetaExtension.php) already folds every file in this directory into its key, so it is discarded when one of them is edited. What survives either is the per-file reflection, which is keyed by the source file's own contents and has no way to know that reading it now yields something else. CI keys its cache on these files for the same reason; see [`.github/workflows/reusable-phpstan-static-analysis-v1.yml`](../../.github/workflows/reusable-phpstan-static-analysis-v1.yml). + +The same cache is worth clearing after analysing a subset of the tree. A file named on the command line is analysed, but a file merely *read* on its behalf is parsed without these extensions, and the reflection stored for it carries no derived shape. A later full run reads that back and reports against a type that is no longer what the docblock says, which is the same silence as above arriving from the other direction. Sometimes, due to the lack of type information in legacy code, PHPStan may still struggle to analyze certain parts of the codebase. In such cases, you can use the `--debug` flag to disable caching and see which files are causing issues. From f6261a92a5ffd36b1f9196848c79bdef7a8cc93f Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 28 Aug 2026 10:07:48 +0000 Subject: [PATCH 09/17] Build/Test Tools: Give the PHPStan cache a name of its own. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key added on this branch kept the old cache name and prefixed it with a hash of the analysis configuration, so every key it writes still begins with `phpstan-result-cache-` — which is what the workflow this branch replaces restores on. A branch that has not merged this commit runs the workflow it has, prefix-matches the caches written here, and analyses without the extensions against reflection derived with them. That is the failure this branch exists to close, reaching it from the other side. Renaming the cache leaves those runs restoring only their own. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018CG1pXsNoUnbjzti49c1Zy --- .../reusable-phpstan-static-analysis-v1.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reusable-phpstan-static-analysis-v1.yml b/.github/workflows/reusable-phpstan-static-analysis-v1.yml index 10a5ef3b384df..dba8158c2fc8a 100644 --- a/.github/workflows/reusable-phpstan-static-analysis-v1.yml +++ b/.github/workflows/reusable-phpstan-static-analysis-v1.yml @@ -95,13 +95,19 @@ jobs: # predates either. The baselines are left out of the key: they only decide which reported errors # are ignored, PHPStan invalidates the results cache on a configuration change by itself, and # including them would discard the whole cache every time one is regenerated. + # + # The cache is renamed rather than only prefixed, because the key this replaces restored on + # `phpstan-result-cache-`, which every key below still begins with. A branch that has not + # merged this commit yet runs the workflow it has, and that workflow would prefix-match the + # caches written here: it would analyse without the extensions against reflection derived with + # them. A name of its own leaves those runs restoring only their own caches. - name: Cache PHP Static Analysis scan cache uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .cache # This is defined in the base.neon file. - key: "phpstan-result-cache-${{ hashFiles('phpstan.neon.dist', 'tests/phpstan/*.neon', 'tests/phpstan/*.php') }}-${{ github.run_id }}" + key: "phpstan-analysis-cache-${{ hashFiles('phpstan.neon.dist', 'tests/phpstan/*.neon', 'tests/phpstan/*.php') }}-${{ github.run_id }}" restore-keys: | - phpstan-result-cache-${{ hashFiles('phpstan.neon.dist', 'tests/phpstan/*.neon', 'tests/phpstan/*.php') }}- + phpstan-analysis-cache-${{ hashFiles('phpstan.neon.dist', 'tests/phpstan/*.neon', 'tests/phpstan/*.php') }}- - name: Run PHP static analysis tests id: phpstan @@ -204,7 +210,7 @@ jobs: if: ${{ !cancelled() }} with: path: .cache - key: "phpstan-result-cache-${{ hashFiles('phpstan.neon.dist', 'tests/phpstan/*.neon', 'tests/phpstan/*.php') }}-${{ github.run_id }}" + key: "phpstan-analysis-cache-${{ hashFiles('phpstan.neon.dist', 'tests/phpstan/*.neon', 'tests/phpstan/*.php') }}-${{ github.run_id }}" - name: Ensure version-controlled files are not modified or deleted run: git diff --exit-code From 495d7a83cd48c4bec779f9704b044fc9e66ab211 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 28 Aug 2026 10:07:48 +0000 Subject: [PATCH 10/17] Docs: Correct the hashes the visitor now reads, and two calls they report. `wp_die()` documents `@type int $response`, but `wp_send_json()` passes null and four of the handlers test for it: "This is intentional. For backward-compatibility, support passing null here." The type is `int|null`. `WP_Date_Query::__construct()` documented `column`, `compare` and `relation` inside its list of clauses, one level below where the constructor reads them. `WP_Meta_Query` and `WP_Tax_Query` document the same keys at the top level, which is where all three read them. `get_taxonomy_labels()` sets `menu_name` on the defaults it builds and did not document it. That matters now the hash is the shape: a member it leaves out is a member reading is reported for. `wp_upload_bits()` returns the message a `wp_upload_bits` filter handed it, which may be an empty string, so the error arm of its shape is `string` rather than `non-empty-string`. Two calls the hashes report are wrong rather than mistyped, so they are fixed rather than baselined: - `WP_MS_Themes_List_Table::column_description()` passes `additional_classes` as a string. `wp_get_admin_notice()` reads that key only when it is an array, so the broken theme notice has never had the `inline` class it asks for. The sibling call in the same file passes an array. - Twenty Nineteen passes `title_reply` as null. `comment_form_title()` echoes it either way, so an empty string renders the same and is the documented type. The one baseline whose message the new shapes change is regenerated with it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018CG1pXsNoUnbjzti49c1Zy --- .../class-wp-ms-themes-list-table.php | 2 +- .../twentynineteen/inc/template-tags.php | 2 +- src/wp-includes/class-wp-date-query.php | 120 +++++++++--------- src/wp-includes/functions.php | 30 +++-- src/wp-includes/taxonomy.php | 1 + tests/phpstan/baselines/argument.type.neon | 10 -- .../baselines/offsetAccess.notFound.neon | 2 +- 7 files changed, 79 insertions(+), 88 deletions(-) diff --git a/src/wp-admin/includes/class-wp-ms-themes-list-table.php b/src/wp-admin/includes/class-wp-ms-themes-list-table.php index 81c35414d9053..eb49de155107c 100644 --- a/src/wp-admin/includes/class-wp-ms-themes-list-table.php +++ b/src/wp-admin/includes/class-wp-ms-themes-list-table.php @@ -724,7 +724,7 @@ public function column_description( $theme ) { $pre . $theme->errors()->get_error_message(), array( 'type' => 'error', - 'additional_classes' => 'inline', + 'additional_classes' => array( 'inline' ), ) ); } diff --git a/src/wp-content/themes/twentynineteen/inc/template-tags.php b/src/wp-content/themes/twentynineteen/inc/template-tags.php index 63e9f78e1b505..c884a71621f01 100644 --- a/src/wp-content/themes/twentynineteen/inc/template-tags.php +++ b/src/wp-content/themes/twentynineteen/inc/template-tags.php @@ -209,7 +209,7 @@ function twentynineteen_comment_form( $order ) { comment_form( array( - 'title_reply' => null, + 'title_reply' => '', ) ); } diff --git a/src/wp-includes/class-wp-date-query.php b/src/wp-includes/class-wp-date-query.php index 38edcc2503feb..063d1a2f3d8aa 100644 --- a/src/wp-includes/class-wp-date-query.php +++ b/src/wp-includes/class-wp-date-query.php @@ -73,71 +73,69 @@ class WP_Date_Query { * @param array $date_query { * Array of date query clauses. * - * @type array ...$0 { - * @type string $column Optional. The column to query against. If undefined, inherits the value of - * the `$default_column` parameter. See WP_Date_Query::validate_column() and - * the {@see 'date_query_valid_columns'} filter for the list of accepted values. - * Default 'post_date'. - * @type string $compare Optional. The comparison operator. Accepts '=', '!=', '>', '>=', '<', '<=', - * 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'. Default '='. - * @type string $relation Optional. The boolean relationship between the date queries. Accepts 'OR' or 'AND'. - * Default 'OR'. - * @type array ...$0 { - * Optional. An array of first-order clause parameters, or another fully-formed date query. + * @type string $column Optional. The column to query against. If undefined, inherits the value of + * the `$default_column` parameter. See WP_Date_Query::validate_column() and + * the {@see 'date_query_valid_columns'} filter for the list of accepted values. + * Default 'post_date'. + * @type string $compare Optional. The comparison operator. Accepts '=', '!=', '>', '>=', '<', '<=', + * 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'. Default '='. + * @type string $relation Optional. The boolean relationship between the date queries. Accepts 'OR' or 'AND'. + * Default 'OR'. + * @type array ...$0 { + * Optional. An array of first-order clause parameters, or another fully-formed date query. * - * @type string|array $before { - * Optional. Date to retrieve posts before. Accepts `strtotime()`-compatible string, - * or array of 'year', 'month', 'day' values. + * @type string|array $before { + * Optional. Date to retrieve posts before. Accepts `strtotime()`-compatible string, + * or array of 'year', 'month', 'day' values. * - * @type string $year The four-digit year. Default empty. Accepts any four-digit year. - * @type string $month Optional when passing array. The month of the year. - * Default (string:empty)|(array:1). Accepts numbers 1-12. - * @type string $day Optional when passing array. The day of the month. - * Default (string:empty)|(array:1). Accepts numbers 1-31. - * } - * @type string|array $after { - * Optional. Date to retrieve posts after. Accepts `strtotime()`-compatible string, - * or array of 'year', 'month', 'day' values. + * @type string $year The four-digit year. Default empty. Accepts any four-digit year. + * @type string $month Optional when passing array. The month of the year. + * Default (string:empty)|(array:1). Accepts numbers 1-12. + * @type string $day Optional when passing array. The day of the month. + * Default (string:empty)|(array:1). Accepts numbers 1-31. + * } + * @type string|array $after { + * Optional. Date to retrieve posts after. Accepts `strtotime()`-compatible string, + * or array of 'year', 'month', 'day' values. * - * @type string $year The four-digit year. Accepts any four-digit year. Default empty. - * @type string $month Optional when passing array. The month of the year. Accepts numbers 1-12. - * Default (string:empty)|(array:12). - * @type string $day Optional when passing array. The day of the month. Accepts numbers 1-31. - * Default (string:empty)|(array:last day of month). - * } - * @type string $column Optional. Used to add a clause comparing a column other than - * the column specified in the top-level `$column` parameter. - * See WP_Date_Query::validate_column() and - * the {@see 'date_query_valid_columns'} filter for the list - * of accepted values. Default is the value of top-level `$column`. - * @type string $compare Optional. The comparison operator. Accepts '=', '!=', '>', '>=', - * '<', '<=', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'. Comparisons - * support arrays in some time-related parameters. Default '='. - * @type bool $inclusive Optional. Include results from dates specified in 'before' or - * 'after'. Default false. - * @type int|int[] $year Optional. The four-digit year number. Accepts any four-digit year - * or an array of years if `$compare` supports it. Default empty. - * @type int|int[] $month Optional. The two-digit month number. Accepts numbers 1-12 or an - * array of valid numbers if `$compare` supports it. Default empty. - * @type int|int[] $week Optional. The week number of the year. Accepts numbers 1-53 or an - * array of valid numbers if `$compare` supports it. Default empty. - * @type int|int[] $dayofyear Optional. The day number of the year. Accepts numbers 1-366 or an - * array of valid numbers if `$compare` supports it. - * @type int|int[] $day Optional. The day of the month. Accepts numbers 1-31 or an array - * of valid numbers if `$compare` supports it. Default empty. - * @type int|int[] $dayofweek Optional. The day number of the week. Accepts numbers 1-7 (1 is - * Sunday) or an array of valid numbers if `$compare` supports it. - * Default empty. - * @type int|int[] $dayofweek_iso Optional. The day number of the week (ISO). Accepts numbers 1-7 - * (1 is Monday) or an array of valid numbers if `$compare` supports it. - * Default empty. - * @type int|int[] $hour Optional. The hour of the day. Accepts numbers 0-23 or an array - * of valid numbers if `$compare` supports it. Default empty. - * @type int|int[] $minute Optional. The minute of the hour. Accepts numbers 0-59 or an array - * of valid numbers if `$compare` supports it. Default empty. - * @type int|int[] $second Optional. The second of the minute. Accepts numbers 0-59 or an - * array of valid numbers if `$compare` supports it. Default empty. + * @type string $year The four-digit year. Accepts any four-digit year. Default empty. + * @type string $month Optional when passing array. The month of the year. Accepts numbers 1-12. + * Default (string:empty)|(array:12). + * @type string $day Optional when passing array. The day of the month. Accepts numbers 1-31. + * Default (string:empty)|(array:last day of month). * } + * @type string $column Optional. Used to add a clause comparing a column other than + * the column specified in the top-level `$column` parameter. + * See WP_Date_Query::validate_column() and + * the {@see 'date_query_valid_columns'} filter for the list + * of accepted values. Default is the value of top-level `$column`. + * @type string $compare Optional. The comparison operator. Accepts '=', '!=', '>', '>=', + * '<', '<=', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'. Comparisons + * support arrays in some time-related parameters. Default '='. + * @type bool $inclusive Optional. Include results from dates specified in 'before' or + * 'after'. Default false. + * @type int|int[] $year Optional. The four-digit year number. Accepts any four-digit year + * or an array of years if `$compare` supports it. Default empty. + * @type int|int[] $month Optional. The two-digit month number. Accepts numbers 1-12 or an + * array of valid numbers if `$compare` supports it. Default empty. + * @type int|int[] $week Optional. The week number of the year. Accepts numbers 1-53 or an + * array of valid numbers if `$compare` supports it. Default empty. + * @type int|int[] $dayofyear Optional. The day number of the year. Accepts numbers 1-366 or an + * array of valid numbers if `$compare` supports it. + * @type int|int[] $day Optional. The day of the month. Accepts numbers 1-31 or an array + * of valid numbers if `$compare` supports it. Default empty. + * @type int|int[] $dayofweek Optional. The day number of the week. Accepts numbers 1-7 (1 is + * Sunday) or an array of valid numbers if `$compare` supports it. + * Default empty. + * @type int|int[] $dayofweek_iso Optional. The day number of the week (ISO). Accepts numbers 1-7 + * (1 is Monday) or an array of valid numbers if `$compare` supports it. + * Default empty. + * @type int|int[] $hour Optional. The hour of the day. Accepts numbers 0-23 or an array + * of valid numbers if `$compare` supports it. Default empty. + * @type int|int[] $minute Optional. The minute of the hour. Accepts numbers 0-59 or an array + * of valid numbers if `$compare` supports it. Default empty. + * @type int|int[] $second Optional. The second of the minute. Accepts numbers 0-59 or an + * array of valid numbers if `$compare` supports it. Default empty. * } * } * @param string $default_column Optional. Default column to query against. See WP_Date_Query::validate_column() diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 951ec55a2db49..ab3938be44934 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -2922,7 +2922,7 @@ function _wp_check_existing_file_names( $filename, $files ) { * @type string|false $error Error message, if there has been an error. * } * @phpstan-return array{ file: non-empty-string, url: non-empty-string, type: string|false, error: false } - * |array{ error: non-empty-string, ... } + * |array{ error: string, ... } */ function wp_upload_bits( $name, $deprecated, $bits, $time = null ) { if ( ! empty( $deprecated ) ) { @@ -3801,19 +3801,21 @@ function wp_nonce_ays( $action ) { * Optional. Arguments to control behavior. If `$args` is an integer, then it is treated * as the response code. Default empty array. * - * @type int $response The HTTP response code. Default 200 for Ajax requests, 500 otherwise. - * @type string $link_url A URL to include a link to. Only works in combination with $link_text. - * Default empty string. - * @type string $link_text A label for the link to include. Only works in combination with $link_url. - * Default empty string. - * @type bool $back_link Whether to include a link to go back. Default false. - * @type string $text_direction The text direction. This is only useful internally, when WordPress is still - * loading and the site's locale is not set up yet. Accepts 'rtl' and 'ltr'. - * Default is the value of is_rtl(). - * @type string $charset Character set of the HTML output. Default 'utf-8'. - * @type string $code Error code to use. Default is 'wp_die', or the main error code if $message - * is a WP_Error. - * @type bool $exit Whether to exit the process after completion. Default true. + * @type int|null $response The HTTP response code, or null to send no status header. The Ajax, JSON, + * JSONP and XML handlers all accept null, for backward compatibility. + * Default 200 for Ajax requests, 500 otherwise. + * @type string $link_url A URL to include a link to. Only works in combination with $link_text. + * Default empty string. + * @type string $link_text A label for the link to include. Only works in combination with $link_url. + * Default empty string. + * @type bool $back_link Whether to include a link to go back. Default false. + * @type string $text_direction The text direction. This is only useful internally, when WordPress is still + * loading and the site's locale is not set up yet. Accepts 'rtl' and 'ltr'. + * Default is the value of is_rtl(). + * @type string $charset Character set of the HTML output. Default 'utf-8'. + * @type string $code Error code to use. Default is 'wp_die', or the main error code if $message + * is a WP_Error. + * @type bool $exit Whether to exit the process after completion. Default true. * } * @return void Never returns if `$args['exit']` is true (the default), otherwise returns void. * @phpstan-param string|WP_Error|int<-1, max> $message diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index e5aa953275c9d..1eeccdae2d8f5 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -655,6 +655,7 @@ function unregister_taxonomy( $taxonomy ) { * @type string $name General name for the taxonomy, usually plural. The same * as and overridden by `$tax->label`. Default 'Tags'/'Categories'. * @type string $singular_name Name for one object of this taxonomy. Default 'Tag'/'Category'. + * @type string $menu_name Label for the menu name. Default is the same as `name`. * @type string $search_items Default 'Search Tags'/'Search Categories'. * @type string $popular_items This label is only used for non-hierarchical taxonomies. * Default 'Popular Tags'. diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index 98cfed9459500..ca776f4ded260 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -173,11 +173,6 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-admin/includes/class-wp-links-list-table.php - - - message: '#^Parameter \#2 \$args of function wp_admin_notice expects array\{type\?\: string, dismissible\?\: bool, id\?\: string, additional_classes\?\: array\, attributes\?\: array\, paragraph_wrap\?\: bool, \.\.\.\}, array\{type\: ''error'', additional_classes\: ''inline''\} given\.$#' - identifier: argument.type - count: 1 - path: ../../../src/wp-admin/includes/class-wp-ms-themes-list-table.php - message: '#^Parameter \#3 \$number of function _nx expects int, float given\.$#' identifier: argument.type @@ -603,11 +598,6 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-content/themes/twentynineteen/inc/helper-functions.php - - - message: '#^Parameter \#1 \$args of function comment_form expects array\{fields\?\: array\{author\?\: string, email\?\: string, url\?\: string, cookies\?\: string, \.\.\.\}, comment_field\?\: string, must_log_in\?\: string, logged_in_as\?\: string, comment_notes_before\?\: string, comment_notes_after\?\: string, action\?\: string, novalidate\?\: bool, \.\.\., \.\.\.\}, array\{title_reply\: null\} given\.$#' - identifier: argument.type - count: 1 - path: ../../../src/wp-content/themes/twentynineteen/inc/template-tags.php - message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' identifier: argument.type diff --git a/tests/phpstan/baselines/offsetAccess.notFound.neon b/tests/phpstan/baselines/offsetAccess.notFound.neon index a5e2eb0698cc8..4470319b88805 100644 --- a/tests/phpstan/baselines/offsetAccess.notFound.neon +++ b/tests/phpstan/baselines/offsetAccess.notFound.neon @@ -19,7 +19,7 @@ parameters: ignoreErrors: - - message: '#^Offset float does not exist on list\.$#' + message: '#^Offset float does not exist on list\\.$#' identifier: offsetAccess.notFound count: 1 path: ../../../src/wp-admin/includes/class-wp-site-health.php From 866d74e19978369937103212c833eb9a143b871a Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 28 Aug 2026 11:03:05 +0000 Subject: [PATCH 11/17] Docs: Type the hash keys core's own calls contradict. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these was recorded in a baseline when the hashes became shapes rather than fixed, and each is a `@type` that says less than the function accepts. In three of them the description sitting beside it already said so. `wp_nav_menu()` documents `$container` as a string, then tests `is_string( $args->container )` before using it — a guard with nothing to guard against unless the value can be something else, which is what Twenty Twenty One's footer passes. `wp_list_pages()` documents `$title_li` as a string in a sentence reading "Passing a null or empty value will result in no heading". Twenty Twenty passes false. The type and the sentence now agree on falsy. `WP_Ajax_Response::add()` documents `$position` as a string in a sentence reading "Accepts 1 (bottom), -1 (top)". Its `$id` is documented `int|WP_Error`, while every caller in wp-admin passes a `$_POST` value or `$comment->comment_ID`, both strings. `get_bookmarks()` documents `$category` as a comma-separated list of IDs, then assigns `$parsed_args['category'] = $term->term_id` into it itself when `$category_name` was given instead. Two calls are corrected rather than their documentation, because the value the documentation names already existed. Twenty Twenty passes `''` for `wp_nav_menu()`'s `$fallback_cb`, where the docblock and Twenty Twenty One's own footer write `false`; both are falsy, and `wp_nav_menu()` tests truthiness before `is_callable()`. `WP_Customize_Manager` passes `0` for `get_pages()`'s documented `bool $hierarchical`. Eleven baseline entries go with them, taking what this branch adds to `argument.type` from twenty-two to nine. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018CG1pXsNoUnbjzti49c1Zy --- .../template-parts/footer-menus-widgets.php | 2 +- .../template-parts/modal-menu.php | 2 +- src/wp-includes/bookmark.php | 44 +++++++-------- src/wp-includes/class-wp-ajax-response.php | 30 +++++----- .../class-wp-customize-manager.php | 2 +- src/wp-includes/nav-menu-template.php | 4 +- src/wp-includes/post-template.php | 2 +- tests/phpstan/baselines/argument.type.neon | 55 ------------------- 8 files changed, 43 insertions(+), 98 deletions(-) diff --git a/src/wp-content/themes/twentytwenty/template-parts/footer-menus-widgets.php b/src/wp-content/themes/twentytwenty/template-parts/footer-menus-widgets.php index 83c3c8c47cfe5..4355afe64bed9 100644 --- a/src/wp-content/themes/twentytwenty/template-parts/footer-menus-widgets.php +++ b/src/wp-content/themes/twentytwenty/template-parts/footer-menus-widgets.php @@ -70,7 +70,7 @@ 'depth' => 1, 'link_before' => '', 'link_after' => '', - 'fallback_cb' => '', + 'fallback_cb' => false, ) ); ?> diff --git a/src/wp-content/themes/twentytwenty/template-parts/modal-menu.php b/src/wp-content/themes/twentytwenty/template-parts/modal-menu.php index f157f5159b946..139f00ae1ac13 100644 --- a/src/wp-content/themes/twentytwenty/template-parts/modal-menu.php +++ b/src/wp-content/themes/twentytwenty/template-parts/modal-menu.php @@ -129,7 +129,7 @@ 'depth' => 1, 'link_before' => '', 'link_after' => '', - 'fallback_cb' => '', + 'fallback_cb' => false, ) ); ?> diff --git a/src/wp-includes/bookmark.php b/src/wp-includes/bookmark.php index d4b597843820d..e2063ef92febf 100644 --- a/src/wp-includes/bookmark.php +++ b/src/wp-includes/bookmark.php @@ -108,28 +108,28 @@ function get_bookmark_field( $field, $bookmark, $context = 'display' ) { * @param string|array $args { * Optional. String or array of arguments to retrieve bookmarks. * - * @type string $orderby How to order the links by. Accepts 'id', 'link_id', 'name', 'link_name', - * 'url', 'link_url', 'visible', 'link_visible', 'rating', 'link_rating', - * 'owner', 'link_owner', 'updated', 'link_updated', 'notes', 'link_notes', - * 'description', 'link_description', 'length' and 'rand'. - * When `$orderby` is 'length', orders by the character length of - * 'link_name'. Default 'name'. - * @type string $order Whether to order bookmarks in ascending or descending order. - * Accepts 'ASC' (ascending) or 'DESC' (descending). Default 'ASC'. - * @type int $limit Amount of bookmarks to display. Accepts any positive number or - * -1 for all. Default -1. - * @type string $category Comma-separated list of category IDs to include links from. - * Default empty. - * @type string $category_name Category to retrieve links for by name. Default empty. - * @type int|bool $hide_invisible Whether to show or hide links marked as 'invisible'. Accepts - * 1|true or 0|false. Default 1|true. - * @type int|bool $show_updated Whether to display the time the bookmark was last updated. - * Accepts 1|true or 0|false. Default 0|false. - * @type string $include Comma-separated list of bookmark IDs to include. Default empty. - * @type string $exclude Comma-separated list of bookmark IDs to exclude. Default empty. - * @type string $search Search terms. Will be SQL-formatted with wildcards before and after - * and searched in 'link_url', 'link_name' and 'link_description'. - * Default empty. + * @type string $orderby How to order the links by. Accepts 'id', 'link_id', 'name', 'link_name', + * 'url', 'link_url', 'visible', 'link_visible', 'rating', 'link_rating', + * 'owner', 'link_owner', 'updated', 'link_updated', 'notes', 'link_notes', + * 'description', 'link_description', 'length' and 'rand'. + * When `$orderby` is 'length', orders by the character length of + * 'link_name'. Default 'name'. + * @type string $order Whether to order bookmarks in ascending or descending order. + * Accepts 'ASC' (ascending) or 'DESC' (descending). Default 'ASC'. + * @type int $limit Amount of bookmarks to display. Accepts any positive number or + * -1 for all. Default -1. + * @type int|string $category Comma-separated list of category IDs to include links from. + * Default empty. + * @type string $category_name Category to retrieve links for by name. Default empty. + * @type int|bool $hide_invisible Whether to show or hide links marked as 'invisible'. Accepts + * 1|true or 0|false. Default 1|true. + * @type int|bool $show_updated Whether to display the time the bookmark was last updated. + * Accepts 1|true or 0|false. Default 0|false. + * @type string $include Comma-separated list of bookmark IDs to include. Default empty. + * @type string $exclude Comma-separated list of bookmark IDs to exclude. Default empty. + * @type string $search Search terms. Will be SQL-formatted with wildcards before and after + * and searched in 'link_url', 'link_name' and 'link_description'. + * Default empty. * } * @return object[] List of bookmark row objects. */ diff --git a/src/wp-includes/class-wp-ajax-response.php b/src/wp-includes/class-wp-ajax-response.php index ed996a19de0dd..ab747618e0fbf 100644 --- a/src/wp-includes/class-wp-ajax-response.php +++ b/src/wp-includes/class-wp-ajax-response.php @@ -46,21 +46,21 @@ public function __construct( $args = '' ) { * @param string|array $args { * Optional. An array or string of XML response arguments. * - * @type string $what XML-RPC response type. Used as a child element of ``. - * Default 'object' (``). - * @type string|false $action Value to use for the `action` attribute in ``. Will be - * appended with `_$id` on output. If false, `$action` will default to - * the value of `$_POST['action']`. Default false. - * @type int|WP_Error $id The response ID, used as the response type `id` attribute. Also - * accepts a `WP_Error` object if the ID does not exist. Default 0. - * @type int|false $old_id The previous response ID. Used as the value for the response type - * `old_id` attribute. False hides the attribute. Default false. - * @type string $position Value of the response type `position` attribute. Accepts 1 (bottom), - * -1 (top), HTML ID (after), or -HTML ID (before). Default 1 (bottom). - * @type string|WP_Error $data The response content/message. Also accepts a WP_Error object if the - * ID does not exist. Default empty. - * @type array $supplemental An array of extra strings that will be output within a `` - * element as CDATA. Default empty array. + * @type string $what XML-RPC response type. Used as a child element of ``. + * Default 'object' (``). + * @type string|false $action Value to use for the `action` attribute in ``. Will be + * appended with `_$id` on output. If false, `$action` will default to + * the value of `$_POST['action']`. Default false. + * @type int|string|WP_Error $id The response ID, used as the response type `id` attribute. Also + * accepts a `WP_Error` object if the ID does not exist. Default 0. + * @type int|false $old_id The previous response ID. Used as the value for the response type + * `old_id` attribute. False hides the attribute. Default false. + * @type int|string $position Value of the response type `position` attribute. Accepts 1 (bottom), + * -1 (top), HTML ID (after), or -HTML ID (before). Default 1 (bottom). + * @type string|WP_Error $data The response content/message. Also accepts a WP_Error object if the + * ID does not exist. Default empty. + * @type array $supplemental An array of extra strings that will be output within a `` + * element as CDATA. Default empty array. * } * @return string XML response. */ diff --git a/src/wp-includes/class-wp-customize-manager.php b/src/wp-includes/class-wp-customize-manager.php index 6ee4292496aa6..02dd558ea24cd 100644 --- a/src/wp-includes/class-wp-customize-manager.php +++ b/src/wp-includes/class-wp-customize-manager.php @@ -5816,7 +5816,7 @@ public function has_published_pages() { get_pages( array( 'number' => 1, - 'hierarchical' => 0, + 'hierarchical' => false, ) ) ); diff --git a/src/wp-includes/nav-menu-template.php b/src/wp-includes/nav-menu-template.php index fc07e6fd2c79a..afdbd27f68235 100644 --- a/src/wp-includes/nav-menu-template.php +++ b/src/wp-includes/nav-menu-template.php @@ -31,8 +31,8 @@ * Default 'menu'. * @type string $menu_id The ID that is applied to the ul element which forms the menu. * Default is the menu slug, incremented. - * @type string $container Whether to wrap the ul, and what to wrap it with. - * Default 'div'. + * @type string|false $container Whether to wrap the ul, and what to wrap it with. + * False for no container. Default 'div'. * @type string $container_class Class that is applied to the container. * Default 'menu-{menu slug}-container'. * @type string $container_id The ID that is applied to the container. Default empty. diff --git a/src/wp-includes/post-template.php b/src/wp-includes/post-template.php index cb693fb48517f..34099428cbad5 100644 --- a/src/wp-includes/post-template.php +++ b/src/wp-includes/post-template.php @@ -1291,7 +1291,7 @@ function wp_dropdown_pages( $args = '' ) { * @type string $sort_column Comma-separated list of column names to sort the pages by. Accepts 'post_author', * 'post_date', 'post_title', 'post_name', 'post_modified', 'post_modified_gmt', * 'menu_order', 'post_parent', 'ID', 'rand', or 'comment_count'. Default 'post_title'. - * @type string $title_li List heading. Passing a null or empty value will result in no heading, and the list + * @type string|false $title_li List heading. Passing a falsy value will result in no heading, and the list * will not be wrapped with unordered list `
    ` tags. Default 'Pages'. * @type string $item_spacing Whether to preserve whitespace within the menu's HTML. Accepts 'preserve' or 'discard'. * Default 'preserve'. diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index ca776f4ded260..5e343b2375ad2 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -88,26 +88,6 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-admin/edit.php - - - message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''comment'', id\: numeric\-string, data\: string\|false, position\: ''\-1''\|int, supplemental\: array\{in_moderation\: int, i18n_comments_text\: string, i18n_moderation_text\: string, parent_approved\: numeric\-string, parent_post_id\: numeric\-string\}\|array\{in_moderation\: int, i18n_comments_text\: string, i18n_moderation_text\: string\}\} given\.$#' - identifier: argument.type - count: 1 - path: ../../../src/wp-admin/includes/ajax-actions.php - - - message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''edit_comment'', id\: non\-falsy\-string&numeric\-string, data\: string\|false, position\: ''\-1''\|int\} given\.$#' - identifier: argument.type - count: 1 - path: ../../../src/wp-admin/includes/ajax-actions.php - - - message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''link\-category'', id\: int, data\: non\-falsy\-string, position\: \-1\} given\.$#' - identifier: argument.type - count: 1 - path: ../../../src/wp-admin/includes/ajax-actions.php - - - message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''term'', position\: int\<0, max\>, supplemental\: non\-empty\-array\} given\.$#' - identifier: argument.type - count: 1 - path: ../../../src/wp-admin/includes/ajax-actions.php - message: '#^Parameter \#1 \$attachment of function wp_get_attachment_id3_keys expects WP_Post, stdClass given\.$#' identifier: argument.type @@ -168,11 +148,6 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-admin/includes/class-wp-comments-list-table.php - - - message: '#^Parameter \#1 \$args of function get_bookmarks expects array\{orderby\?\: string, order\?\: string, limit\?\: int, category\?\: string, category_name\?\: string, hide_invisible\?\: bool\|int, show_updated\?\: bool\|int, include\?\: string, \.\.\., \.\.\.\}\|string, array\{hide_invisible\: 0, hide_empty\: 0, category\?\: int, search\?\: non\-falsy\-string, orderby\?\: non\-falsy\-string, order\?\: non\-falsy\-string\} given\.$#' - identifier: argument.type - count: 1 - path: ../../../src/wp-admin/includes/class-wp-links-list-table.php - message: '#^Parameter \#3 \$number of function _nx expects int, float given\.$#' identifier: argument.type @@ -743,11 +718,6 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-content/themes/twentytwenty/functions.php - - - message: '#^Parameter \#1 \$args of function wp_list_pages expects array\{child_of\?\: int, authors\?\: string, date_format\?\: string, depth\?\: int, echo\?\: bool, exclude\?\: string, include\?\: array, link_after\?\: string, \.\.\., \.\.\.\}\|string, array\{match_menu_classes\: true, show_sub_menu_icons\: true, title_li\: false, walker\: TwentyTwenty_Walker_Page\} given\.$#' - identifier: argument.type - count: 1 - path: ../../../src/wp-content/themes/twentytwenty/header.php - message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' identifier: argument.type @@ -758,26 +728,6 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-content/themes/twentytwenty/template-parts/entry-author-bio.php - - - message: '#^Parameter \#1 \$args of function wp_nav_menu expects array\{menu\?\: int\|string\|WP_Term, menu_class\?\: string, menu_id\?\: string, container\?\: string, container_class\?\: string, container_id\?\: string, container_aria_label\?\: string, fallback_cb\?\: \(callable\(\)\: mixed\)\|false, \.\.\., \.\.\.\}, array\{theme_location\: ''social'', container\: '''', container_class\: '''', items_wrap\: ''%%3\$s'', menu_id\: '''', menu_class\: '''', depth\: 1, link_before\: ''\'', link_after\: ''\'', fallback_cb\: false\} given\.$#' - identifier: argument.type - count: 1 - path: ../../../src/wp-content/themes/twentytwentyone/footer.php - message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' identifier: argument.type @@ -853,11 +803,6 @@ parameters: identifier: argument.type count: 6 path: ../../../src/wp-includes/class-wp-customize-manager.php - - - message: '#^Parameter \#1 \$args of function get_pages expects array\{child_of\?\: int, sort_order\?\: string, sort_column\?\: string, hierarchical\?\: bool, exclude\?\: array\, include\?\: array\, meta_key\?\: string, meta_value\?\: string, \.\.\., \.\.\.\}\|string, array\{number\: 1, hierarchical\: 0\} given\.$#' - identifier: argument.type - count: 1 - path: ../../../src/wp-includes/class-wp-customize-manager.php - message: '#^Parameter \#1 \$args of method WP_Customize_Manager\:\:get_changeset_posts\(\) expects array\{posts_per_page\?\: int, author\?\: int, post_status\?\: string, exclude_restore_dismissed\?\: bool, \.\.\.\}, array\{post_status\: array\, exclude_restore_dismissed\: false, author\: ''any'', posts_per_page\: 1, order\: ''DESC'', orderby\: ''date''\} given\.$#' identifier: argument.type From 38c7688fd9c92c33947177515a1034c9681d61b9 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 28 Aug 2026 17:56:08 +0000 Subject: [PATCH 12/17] Build/Test Tools: Keep the object shapes an intersection does not seal. An earlier commit on this branch stopped deriving a shape for any object hash that had to stay open, on the reasoning that PHPStan's object shapes carry a fixed member list and no `...`, so sealing one would report every member the hash does not name. That is true of a bare `object{ ... }`. It is not true of the intersection the visitor actually emits for a hash on a named class. `stdClass&object{ ... }` is not sealed: the intersection leaves the class to say what else may be read, and a `stdClass` accepts anything. Reading an undocumented property off one is reported for the bare shape and allowed for the intersection, which is the case that matters here, since core builds these values with `wp_parse_args()` and the hash names the keys it reads rather than the only keys present. So only a bare `object` hash that has to stay open is skipped now. `WP_Theme_Install_List_Table::single_row()` derives its shape again, and it is the only docblock in `src` the distinction moves. The rules in the class docblock and in `tests/phpstan/README.md` said the wrong thing and now say this one. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018CG1pXsNoUnbjzti49c1Zy --- tests/phpstan/HashNotationVisitor.php | 25 ++++++++++++++++--------- tests/phpstan/README.md | 2 +- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/tests/phpstan/HashNotationVisitor.php b/tests/phpstan/HashNotationVisitor.php index 0d5d9c7953276..43e80ec8d09d1 100644 --- a/tests/phpstan/HashNotationVisitor.php +++ b/tests/phpstan/HashNotationVisitor.php @@ -64,9 +64,11 @@ * - 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 PHPStan's own type * parser reads as one. Anything else, and the whole tag is skipped. - * - An object hash that would have to stay open is skipped: PHPStan's object - * shapes have a fixed member list and no `...`, so sealing one would report - * every member the hash does not name. + * - A hash on a bare `object` that would have to stay open is skipped: PHPStan's + * object shapes have a fixed member list and no `...`, so sealing one would + * report every member the hash does not name. A hash on a named class is not + * skipped, because the intersection leaves that class to decide what else may + * be read, and a `stdClass` accepts anything. * - A parameter taken by reference is skipped. PHPStan checks a by-reference * argument in both directions, so a shape there is a contract every caller's * variable has to satisfy before the call, which is not what the hash says. @@ -613,7 +615,8 @@ private function substitute( string $declared, array $entries, bool $for_param ) $member = $members[ $target ]; $normalized = strtolower( $member ); - $shape = $this->resolve_container( $entries, $for_param, 'array' === $normalized ); + $named = 'array' !== $normalized && 'object' !== $normalized; + $shape = $this->resolve_container( $entries, $for_param, 'array' === $normalized, $named ); if ( null === $shape ) { return null; } @@ -624,7 +627,7 @@ private function substitute( string $declared, array $entries, bool $for_param ) * Naming the class in the docblock keeps both: the value stays that class, and * its members are typed by the shape intersected with it. */ - if ( 'array' !== $normalized && 'object' !== $normalized ) { + if ( $named ) { $shape = $member . '&' . $shape; // An intersection inside a union needs parentheses to parse. @@ -670,9 +673,10 @@ private function is_shapeable( string $member ): bool { * @param list $entries Entries of this level. * @param bool $for_param Whether the hash documents a `@param`. * @param bool $is_array Whether the hash describes an array rather than an object. + * @param bool $named Whether the shape will be intersected with a named class. * @return string|null */ - private function resolve_container( array $entries, bool $for_param, bool $is_array ): ?string { + private function resolve_container( array $entries, bool $for_param, bool $is_array, bool $named = false ): ?string { /* * A single `...$0` entry describes a repeated value rather than a key. * The hash says nothing about the keys it repeats under, and core uses @@ -721,11 +725,14 @@ private function resolve_container( array $entries, bool $for_param, bool $is_ar } /* - * PHPStan's object shapes have no `...`, so an object hash that has to stay - * open cannot be expressed as one and is left alone rather than sealed. + * PHPStan's object shapes have no `...`, so a bare one that has to stay open + * cannot be expressed and is left alone rather than sealed. Intersecting with + * the class named in the docblock does not seal it: the class decides what + * else may be read, and a `stdClass` accepts anything, which is what a hash on + * a value core builds with `wp_parse_args()` needs. */ if ( ! $is_array ) { - return $open ? null : sprintf( 'object{%s}', implode( ', ', $members ) ); + return $open && ! $named ? null : sprintf( 'object{%s}', implode( ', ', $members ) ); } /* diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index 6c206dd5b5379..e08d19e3a1d8b 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -81,7 +81,7 @@ A hash whose translation would be a guess is left alone, and the value keeps wha - 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 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. -- An object hash that would have to stay open is skipped. PHPStan's object shapes have a fixed member list and no `...`, so sealing one would report every member the hash does not name — which is what a hash on a value from an external API, such as the theme data `WP_Theme_Install_List_Table::single_row()` documents, would produce. +- 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. From ba784280078ff0dc22349a9b94f72fbc2b3d10d3 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 28 Aug 2026 23:08:16 +0200 Subject: [PATCH 13/17] Update src/wp-includes/class-wp-xmlrpc-server.php Co-authored-by: Weston Ruter --- src/wp-includes/class-wp-xmlrpc-server.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-xmlrpc-server.php b/src/wp-includes/class-wp-xmlrpc-server.php index 015f31c7888bb..fbb18413b040d 100644 --- a/src/wp-includes/class-wp-xmlrpc-server.php +++ b/src/wp-includes/class-wp-xmlrpc-server.php @@ -3190,7 +3190,7 @@ public function wp_deletePage( $args ) { * @type int $1 Page ID. * @type string $2 Username. * @type string $3 Password. - * @type array $4 Content struct. See mw_newPost() for the recognized keys. + * @type array $4 Content struct, with keys documented on {@see self::mw_newPost()}. * @type int $5 Publish flag. 0 for draft, 1 for publish. * } * @return array|IXR_Error From 7e879b58015d6bc0ef9c930ea357a0cf03d6ee01 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 28 Aug 2026 23:08:26 +0200 Subject: [PATCH 14/17] Update src/wp-includes/post.php Co-authored-by: Weston Ruter --- src/wp-includes/post.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index d664d7685be68..33de22b4e716d 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -3699,7 +3699,7 @@ function get_post_mime_types() { * @since 2.5.0 * * @param array $post_mime_types Default list of post mime types. - * See get_post_mime_types(). + * See {@see get_post_mime_types()}. */ return apply_filters( 'post_mime_types', $post_mime_types ); } From 2f383d554c35cb49b4bdeb4595bf6100ad96ae65 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 31 Aug 2026 09:24:08 +0000 Subject: [PATCH 15/17] Docs: Describe the shape _wp_upload_dir() builds. Review asked for the shape on _wp_upload_dir() as well, rather than only on the two functions that return its value filtered. It is the same shape wp_upload_dir() documents, except that error is always false: the error string is only ever attached by wp_upload_dir() after trying to create the directory, and the upload_dir filter that could add one does not run here. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TQBXKmmaicaU2z8HhPnxs2 --- src/wp-includes/functions.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index ab3938be44934..3d488c286d4a3 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -2463,6 +2463,14 @@ function wp_upload_dir( $time = null, $create_dir = true, $refresh_cache = false * * @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null. * @return array See wp_upload_dir() + * @phpstan-return array{ + * path: non-empty-string, + * url: non-empty-string, + * subdir: string, + * basedir: non-empty-string, + * baseurl: non-empty-string, + * error: false, + * } */ function _wp_upload_dir( $time = null ) { $siteurl = get_option( 'siteurl' ); From 35fb63e79104940d7d62caa3a4fc3756d6758489 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 31 Aug 2026 09:24:08 +0000 Subject: [PATCH 16/17] Build/Test Tools: Drop the baseline entry trunk resolved. Trunk widened WP_Customize_Setting's default from string to mixed, so the shape derived from its constructor hash now accepts the array default WP_Customize_Nav_Menus passes, and the entry written against the narrower shape matched nothing. Regenerated with composer phpstan:baselines, which removes only that entry. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TQBXKmmaicaU2z8HhPnxs2 --- tests/phpstan/baselines/argument.type.neon | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index 5e343b2375ad2..da7b0c1f8c7ed 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -828,11 +828,6 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-includes/class-wp-customize-manager.php - - - message: '#^Parameter \#3 \$args of class WP_Customize_Filter_Setting constructor expects array\{type\?\: string, capability\?\: string, theme_supports\?\: array\\|string, default\?\: string, transport\?\: string, validate_callback\?\: callable\(\)\: mixed, sanitize_callback\?\: callable\(\)\: mixed, sanitize_js_callback\?\: callable\(\)\: mixed, \.\.\., \.\.\.\}, array\{transport\: ''postMessage'', type\: ''option'', default\: array\{\}, sanitize_callback\: array\{\$this\(WP_Customize_Nav_Menus\), ''sanitize_nav_menus…''\}\} given\.$#' - identifier: argument.type - count: 1 - path: ../../../src/wp-includes/class-wp-customize-nav-menus.php - message: '#^Parameter \#2 \$parent_query of method WP_Date_Query\:\:get_sql_for_clause\(\) expects array, string given\.$#' identifier: argument.type From 85d7db2168c48dad58561936061ce6aec1df4526 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 31 Aug 2026 09:44:04 +0000 Subject: [PATCH 17/17] Build/Test Tools: Update the baseline entry trunk reworded. Trunk documented register_setting()'s sanitize_callback as nullable, so the shape derived from its hash now reads (callable(): mixed)|null and the entry written against the older wording matched nothing. Regenerated with composer phpstan:baselines, which rewrites only that entry. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TQBXKmmaicaU2z8HhPnxs2 --- tests/phpstan/baselines/argument.type.neon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index da7b0c1f8c7ed..b1dc1dea87d2e 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -499,7 +499,7 @@ parameters: count: 1 path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php - - message: '#^Parameter \#3 \$args of function register_setting expects array\{type\?\: string, label\?\: string, description\?\: string, sanitize_callback\?\: callable\(\)\: mixed, show_in_rest\?\: array\|bool, default\?\: mixed, \.\.\.\}, ''twentyeleven_theme…'' given\.$#' + message: '#^Parameter \#3 \$args of function register_setting expects array\{type\?\: string, label\?\: string, description\?\: string, sanitize_callback\?\: \(callable\(\)\: mixed\)\|null, show_in_rest\?\: array\|bool, default\?\: mixed, \.\.\.\}, ''twentyeleven_theme…'' given\.$#' identifier: argument.type count: 1 path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php