diff --git a/.github/workflows/reusable-phpstan-static-analysis-v1.yml b/.github/workflows/reusable-phpstan-static-analysis-v1.yml index 26a14ba8d890f..dba8158c2fc8a 100644 --- a/.github/workflows/reusable-phpstan-static-analysis-v1.yml +++ b/.github/workflows/reusable-phpstan-static-analysis-v1.yml @@ -84,13 +84,30 @@ 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. + # + # 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-${{ 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- + phpstan-analysis-cache-${{ hashFiles('phpstan.neon.dist', 'tests/phpstan/*.neon', 'tests/phpstan/*.php') }}- - name: Run PHP static analysis tests id: phpstan @@ -193,7 +210,7 @@ jobs: if: ${{ !cancelled() }} with: path: .cache - key: "phpstan-result-cache-${{ 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 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..12a798bc9d820 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. 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-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/class-wp-http.php b/src/wp-includes/class-wp-http.php index 323ec83aeca43..13b82d95bfbd2 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() ) { @@ -711,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 $newheaders 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 506b3faebd1c8..dcb2636d7a726 100644 --- a/src/wp-includes/class-wp-xmlrpc-server.php +++ b/src/wp-includes/class-wp-xmlrpc-server.php @@ -3250,7 +3250,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, with keys documented on {@see self::mw_newPost()}. * @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/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/functions.php b/src/wp-includes/functions.php index f6f902605a198..3d488c286d4a3 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(); @@ -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' ); @@ -2915,11 +2923,14 @@ 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|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: string, ... } */ function wp_upload_bits( $name, $deprecated, $bits, $time = null ) { if ( ! empty( $deprecated ) ) { @@ -3798,19 +3809,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/link-template.php b/src/wp-includes/link-template.php index 4c6b1fa929586..b50328793857e 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -4425,6 +4425,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/src/wp-includes/post.php b/src/wp-includes/post.php index 3f73e5463dbfe..44c1b1ecd8aff 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. @@ -3628,7 +3628,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 ) @@ -3721,7 +3725,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 {@see get_post_mime_types()}. */ return apply_filters( 'post_mime_types', $post_mime_types ); } diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 9d82fba3d9d46..7cd859b278bf9 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 new file mode 100644 index 0000000000000..43e80ec8d09d1 --- /dev/null +++ b/tests/phpstan/HashNotationVisitor.php @@ -0,0 +1,897 @@ +`, 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 `$name` and a type PHPStan's own type + * parser reads as one. Anything else, and the whole tag is skipped. + * - 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. + * + * 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. 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` — 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. + * + * 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' ); + + /** + * 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. + * + * @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 ( preg_match( '#@type[ \t]#', $text ) !== 1 ) { + 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 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; + } + + /* + * 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; + } + + /** + * 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(); + $tags = $this->split_tags( $text ); + $covered = $this->phpstan_counterparts( $tags ); + + foreach ( $tags 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[ $variable ] ) ) { + continue; + } + + $key = 'return' === $tag['name'] ? 'return' : 'param $' . $variable; + if ( isset( $covered[ $key ] ) ) { + 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, + '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; + } + + /** + * Collects what the docblock already documents for PHPStan by hand. + * + * 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 phpstan_counterparts( array $tags ): array { + $covered = array(); + + 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; + } + } + } + + return $covered; + } + + /** + * 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. + * @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, string &$intro = '' ): ?array { + $entries = array(); + $last = null; + $intro = ''; + $count = count( $lines ); + + while ( $index < $count ) { + $line = trim( $lines[ $index ] ); + ++$index; + + if ( '}' === $line ) { + return $entries; + } + + if ( preg_match( '#^@type[ \t]+(.*)$#', $line, $matches ) === 1 ) { + $entry = $this->parse_entry( $matches[1] ); + if ( null === $entry ) { + return null; + } + + if ( $entry['opens'] ) { + $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; + + /* + * 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; + } + + // A tag other than `@type` inside a hash means the hash was never closed. + if ( str_starts_with( $line, '@' ) ) { + return null; + } + + if ( '' === $line ) { + continue; + } + + if ( null !== $last ) { + $entries[ $last ]['description'] .= ' ' . $line; + } elseif ( array() === $entries ) { + $intro = '' === $intro ? $line : $intro . ' ' . $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 { + if ( ! $this->parses_as_type( $declared ) ) { + return null; + } + + $members = $this->split_union( $declared ); + if ( null === $members ) { + return null; + } + + $bare = array(); + $shapeable = array(); + + foreach ( $members as $position => $member ) { + if ( ! $this->is_shapeable( $member ) ) { + continue; + } + + $shapeable[] = $position; + $normalized = strtolower( $member ); + + if ( 'array' === $normalized || 'object' === $normalized ) { + $bare[] = $position; + } + } + + /* + * 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 ]; + $normalized = strtolower( $member ); + $named = 'array' !== $normalized && 'object' !== $normalized; + $shape = $this->resolve_container( $entries, $for_param, 'array' === $normalized, $named ); + 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 ( $named ) { + $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 { + $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. + if ( isset( self::TYPE_KEYWORDS[ $normalized ] ) ) { + 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. + * @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, 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 + * 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 ); + } + + $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'] ) { + $open = true; + continue; + } + + $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; + } + + /* + * 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 && ! $named ? 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. + */ + return sprintf( 'array{%s%s}', implode( ', ', $members ), $open ? ', ...' : '' ); + } + + /** + * 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->parses_as_type( $entry['type'] ) ? $entry['type'] : null; + } + + 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 { + /* + * 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 false; + } + + // Numbered keys document positional arguments, which are passed in order. + 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 ) . "'"; + } + + /** + * 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. 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 bool + */ + 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 brackets are unbalanced. + */ + private function split_union( string $type ): ?array { + $type = trim( $type ); + + $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..e08d19e3a1d8b 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -59,6 +59,41 @@ 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 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 +/** + * @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 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, 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. +- 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. + +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`. + +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). + ### 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. @@ -164,4 +199,18 @@ 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 [`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. diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index 57cf0cf40da15..bcf9fb7063f38 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. - @@ -157,6 +165,7 @@ parameters: - ../../src/wp-trackback.php - ../../src/xmlrpc.php - GlobalDocBlockVisitor.php + - HashNotationVisitor.php - HookDocsVisitor.php - HookDocBlock.php - ApplyFiltersDynamicFunctionReturnTypeExtension.php diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index 745a79986da89..b1dc1dea87d2e 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -364,7 +364,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 @@ -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, string 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 @@ -803,11 +803,21 @@ parameters: identifier: argument.type count: 6 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 @@ -843,6 +853,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 @@ -938,6 +953,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 @@ -1233,6 +1253,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 @@ -1273,6 +1298,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 @@ -1381,7 +1411,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\.$#' @@ -1573,6 +1608,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 d3dbb32a54dcf..88ac46bc45822 100644 --- a/tests/phpstan/baselines/assign.propertyType.neon +++ b/tests/phpstan/baselines/assign.propertyType.neon @@ -144,7 +144,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