Make WordPress Core

Opened 5 months ago

Closed 2 months ago

#65376 closed defect (bug) (fixed)

Build/Test Tools: Statically verify that hook documentation and arguments stay in sync

Reported by: westonruter Owned by: westonruter
Priority: normal Milestone: 7.1
Component: Build/Test Tools Version:
Severity: normal Keywords: has-patch has-unit-tests has-test-info
Cc: Focuses: docs

Description (last modified by westonruter)

WordPress hooks rely on conventions that are currently only enforced by human review, which means they drift out of sync over time.

The problem

Every apply_filters() and do_action() call is expected to be preceded by a docblock documenting the hook, or by a reference comment pointing at the canonical docblock when the same hook fires in more than one place, for example:

/** This filter is documented in <file> */

Nothing verifies any of this automatically:

  • Reference comments are not checked. A This filter is documented in <file> comment can point at a file that no longer exists, or at a file that does not actually document that hook. These references have to be manually traced to confirm they are still accurate, and they silently rot when hooks are moved or renamed.
  • Argument counts are not checked. A hook's docblock lists the arguments it passes, and WordPress forwards those arguments straight to each callback. When a call site passes fewer arguments than documented, a callback registered for the documented count fails with an ArgumentCountError (or a TypeError for a typed parameter) at runtime. When it passes more, the extra argument is silently dropped and the documentation is misleading. Today the only way to catch either is to manually compare every call site against the docblock.
  • Undocumented hooks slip through. A new apply_filters()/do_action() with no preceding docblock or reference is not flagged, so gaps accumulate.
  • Documentation itself is unverified. Malformed @param tags (e.g. missing the variable name) and stale references are invisible until someone reads the file closely.

I realized the need for this when ​reviewing a PR for #65367, as the file reference was incorrect but I had to manually check it for accuracy.

In regards to argument counts no being checked, consider the following code:

<?php
add_filter(
        'https_local_ssl_verify',
        static function ( bool $ssl_verify, string $url ): bool {
                if ( str_contains( wp_parse_url( $url, PHP_URL_HOST ), 'local' ) ) {
                        $ssl_verify = false;
                }
                return $ssl_verify;
        },
        10,
        2
);

The filter callback is expecting two parameters, $ssl_verify and $url, according to the filter ​documentation:

/**
 * Filters whether SSL should be verified for local HTTP API requests.
 *
 * @since 2.8.0
 * @since 5.1.0 The `$url` parameter was added.
 *
 * @param bool|string $ssl_verify Boolean to control whether to verify the SSL connection
 *                                or path to an SSL certificate.
 * @param string      $url        The request URL.
 */

Nevertheless, the above filter code currently causes a fatal error when loading Site Health:

Uncaught Error: Too few arguments to function {closure}(), 1 passed in /var/www/src/wp-includes/class-wp-hook.php on line 344 and exactly 2 expected

This is because the filter is ​applied in WP_Site_Health::get_test_rest_availability() without that second parameter:

<?php
/** This filter is documented in wp-includes/class-wp-http-streams.php */
$sslverify = apply_filters( 'https_local_ssl_verify', false );

Since the $url parameter was added in 5.1.0, apparently this argument was not also added to all other instances of the https_local_ssl_verify filter being applied, or actually in this case the filter was added in 5.3.0 via r46231 and this parameter was missed.

Because these checks are manual, mistakes happen readily: there are hooks fired with the wrong number of arguments across taxonomy, multisite, XML-RPC, plugin, menu, and REST API code, references pointing at an obsolete file, malformed @param tags, and bundled-theme calls that did not pass the documented arguments.

Separately, the value returned from apply_filters() is typed as mixed, even though the hook's docblock already declares the type of the value being filtered. This discards type information that is sitting right there in the documentation and weakens static analysis of any code that consumes a filtered value.

Proposed solution

Teach PHPStan to read the existing hook docblocks so these conventions are verified automatically rather than by hand:

  • Validate that every hook invocation is documented, that "documented elsewhere" references resolve to a real docblock for that hook, and that each call passes the documented number of arguments.
  • Use the documented @param type as the return type of apply_filters().

Then fix the issues this surfaces in core.

As with how r62292 implemented support in a bundled PHPStan extension for the @global tag in WordPress conventions, I suggest the above be implemented as bundled PHPStan extensions to start with. The great work of @szepeviktor in ​phpstan-wordpress can serve as a starting point. However, once landed in core, they should then be submitted upstream for adding to szepeviktor/phpstan-wordpress so they can more easily be reused by both core and Gutenberg (once ​gutenberg#66598 is completed).

This is part of #64898 and #64896.

Change History (32)

#1 @westonruter
5 months ago

  • Description modified (diff)

#2 @westonruter
5 months ago

PR to fix hook documentation in Gutenberg: ​https://github.com/WordPress/gutenberg/pull/78826

This ticket was mentioned in ​PR #12022 on ​WordPress/wordpress-develop by ​@westonruter.


5 months ago
#3

  • Keywords has-patch has-unit-tests added

Teaches PHPStan to read WordPress's existing hook docblocks so that hook documentation, "documented elsewhere" references, and the arguments passed to hooks are verified automatically instead of by manual review — then fixes the issues this surfaces in core.

See the Trac ticket for the full problem framing. In short:

  • The value returned from apply_filters() was typed as mixed, discarding the type already declared in the hook's docblock.
  • /** This filter is documented in <file> */ reference comments were never verified, so they could point at a missing file or a file that no longer documents the hook.
  • Nothing checked that a hook is fired with the number of arguments its docblock describes. Passing too few risks an ArgumentCountError (or a TypeError for a typed parameter) in a callback registered for the documented count; passing too many silently drops the extra argument and misleads the documentation.
  • Undocumented hooks and malformed @param tags went unflagged.

## What's in this PR

Tooling (in tests/phpstan/, wired in via base.neon so it applies to both the level‑0 phpstan.neon.dist and the local level‑10 config):

  • A dynamic return-type extension that types apply_filters() from the first documented @param. Adapted from ​`szepeviktor/phpstan-wordpress`.
  • Resolution of the "documented elsewhere" convention, including dynamic hook names (e.g. {$type}_template_hierarchy).
  • A rule requiring every hook invocation to be documented (inline or via a valid reference).
  • A rule requiring each invocation to pass the documented number of arguments.
  • File-scope @global docblock resolution in GlobalDocBlockVisitor.

Core fixes surfaced by the tooling:

  • Hooks fired with fewer arguments than documented: edit_terms / edited_terms / edit_term_taxonomy / edited_term_taxonomy / term_id_filter, https_local_ssl_verify, wp_update_nav_menu, wp_creating_autosave, install_plugins_upload, activate_{$plugin}, the network handle_network_bulk_actions-{$screen} screens, and the XML‑RPC pre_option_enable_xmlrpc / option_enable_xmlrpc shim.
  • Hooks fired with more arguments than documented: the_content in do_trackbacks() and the_excerpt in WP_REST_Revisions_Controller.
  • Documentation fixes: malformed @param tags and a stale reference comment in bundled themes, and the_permalink calls now passing the documented get_post() argument.

## Notes for reviewers

  • The argument-count fixes change which arguments callbacks receive, so they're worth reviewing against existing hook consumers. Where a call site had no meaningful value for a documented parameter (e.g. term-count callbacks, network-wide bulk screens), an empty array or 0 is passed to satisfy the signature.
  • The Gutenberg-generated src/wp-includes/build tree is excluded from analysis; the corresponding fix lives in the Gutenberg wp-build page templates and is handled separately.
  • The full analysis is clean except for one pre-existing, unrelated error (_mb_ord() in compat.php).

## Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.8
Used for: Drafting the PHPStan extension and rules, locating and fixing the hook issues across core, and ecosystem impact research. All changes were reviewed and directed by me.

​@westonruter commented on ​PR #12022:


5 months ago
#4

cc @szepeviktor @apermo

​@szepe.viktor commented on ​PR #12022:


5 months ago
#5

What an ocean of bugs revealed!

​@westonruter commented on ​PR #12022:


4 months ago
#6

## Level 10 error impact (trunk vs. this branch)

I ran the full level-10 analysis (phpstan.neon) on trunk and on this branch and compared the results by error identifier. Total: 36,241 → 33,034 file errors — a net reduction of 3,207 (−8.8%).

Only identifiers whose count changed are shown below; all others were identical.

Error identifier Before After Δ
------:---:---:
argument.type 12,279 11,276 −1,003
return.type 1,483 765 −718
offsetAccess.nonOffsetAccessible 4,839 4,171 −668
property.nonObject 3,752 3,571 −181
binaryOp.invalid 1,305 1,161 −144
encapsedStringPart.nonString 715 572 −143
echo.nonString 311 205 −106
foreach.nonIterable 578 507 −71
method.nonObject 662 613 −49
assign.propertyType 277 237 −40
offsetAccess.invalidOffset 733 699 −34
cast.int 830 801 −29
missingType.return 2,340 2,315 −25
assignOp.invalid 151 135 −16
phpDoc.parseError 5 0 −5
cast.string 89 84 −5
parameterByRef.type 15 12 −3
property.nameNotString 27 26 −1
offsetAccess.nonArray 7 6 −1
missingType.iterableValue 3,377 3,376 −1
array.invalidKey 18 17 −1
function.alreadyNarrowedType 17 18 +1
offsetAccess.notFound 120 122 +2
variable.undefined 540 547 +7
return.unusedType 9 16 +7
property.notFound 757 777 +20
Total (all identifiers) 36,241 33,034 −3,207

### Summary

  • The reduction is disproportionately large relative to the code change because the apply_filters() return-type extension is the dominant lever: typing filtered values (instead of mixed) cascades through every downstream consumer. The three biggest drops — argument.type (−1,003), return.type (−718), and offsetAccess.nonOffsetAccessible (−668) — are exactly this: once a filtered value has a real type, PHPStan stops flagging it as passed to typed params, returned from typed functions, or indexed as a possible non-array.
  • The small regressions (+37 across five identifiers, e.g. property.notFound +20, variable.undefined +7, return.unusedType +7) are the flip side of more precise typing: giving a value a concrete type occasionally exposes a more-specific complaint that mixed previously masked. These are precision gains rather than new defects, and are far outweighed by the ~3,244 genuine reductions.
  • The new rules (wordpress.hookDocMissing, wordpress.hookParamCountMismatch, and the "documented in" reference checks) contribute 0 errors in the "after" run — every violation they surfaced is fixed in this branch — so they add enforcement going forward without leaving standing errors. phpDoc.parseError also drops to 0 from the malformed @param docblock fixes.

Methodology: both runs used the same local phpstan.neon (level 10, treatPhpDocTypesAsCertain: false); the only difference is the branch's code and the registered rules. The "before" run is trunk (where the new rule files and registrations don't exist, so wordpress.* identifiers are absent by construction).

_(Analysis performed and posted by Claude, on behalf of @westonruter.)_

​@westonruter commented on ​PR #12022:


4 months ago
#7

I don't intend to commit this all in one go. The PHPStan extensions would be in one commit, and then the other fixes would be committed generally on a per-component basis.

​@westonruter commented on ​PR #12022:


4 months ago
#8

Or rather, the other fixes would be committed first, and _then_ the PHPStan extensions would be committed.

​@westonruter commented on ​PR #12022:


4 months ago
#9

@justlevine You may want to review this as well.

#10 @khokansardar
3 months ago

  • Keywords has-test-info added

Patch testing report

Patch / PR tested

Environment

WordPress: 7.1-alpha-62161-src
PHP: 8.2.18 (Docker)
MySQL: 8.0.36 (Docker)
OS: macOS 26.5.2
PHPStan: 2.2.2 (level 0, via composer phpstan)
PHPUnit: 9.6.35 (Docker)
Local wordpress-develop @ ​http://localhost:8889

Steps

  1. On trunk, ran composer phpstan (level 0) to capture the baseline.
  2. Checked out PR #12022, ran composer dump-autoload, then composer phpstan (level 0) so the two new rules run.
  3. Confirmed the single reported error is the pre-existing _mb_ord() one; verified it is already fixed on current trunk and unrelated to this PR.
  4. Reintroduced two of the fixed bugs on the branch and re-ran PHPStan to confirm the new rules flag them.
  5. Ran the PHPUnit suites for every core area the PR touches to confirm the argument-count changes are behaviour-safe.
  6. Restored trunk.

Results

  • Baseline (trunk, level 0): pass — clean, "No errors"; confirms the hook conventions are currently unenforced.
  • Branch (level 0): pass — exactly 1 error, the pre-existing _mb_ord() return.missing in wp-includes/compat.php. All 38 hook errors (hookParamCountMismatch / hookDocMissing / hookDocReferenceHookMissing) are resolved.
  • _mb_ord() investigation: pass — the error is not introduced by this PR. compat.php is untouched by the branch, and current trunk already adds the missing return false; (the branch predates it, ref #12020). It disappears once the branch is rebased on trunk.
  • Rule works — argument count: pass — re-adding the extra $post->ID to the_content in do_trackbacks() was flagged as wordpress.hookParamCountMismatch (provides 2, documented 1).
  • Rule works — missing documentation: pass — removing the two This filter is documented in wp-includes/option.php reference comments in class-wp-xmlrpc-server.php was flagged as wordpress.hookDocMissing on both lines.
  • Unit tests — taxonomy group (edit_terms / edited_terms / edit_term_taxonomy / edited_term_taxonomy / term_id_filter): pass — 878 tests, 2243 assertions, 0 failures.
  • Unit tests — term files (wpInsertTerm, wpUpdateTerm, termCounts): pass — 37 tests, 117 assertions.
  • Unit tests — REST autosaves (wp_creating_autosave): pass — 40 tests, 207 assertions.
  • Unit tests — REST revisions (the_excerpt): pass — 62 tests, 334 assertions.
  • Unit tests — REST menus (wp_update_nav_menu): pass — 25 tests, 122 assertions.
  • Unit tests — Nav_Menu classes (wp_update_nav_menu admin path): pass — 143 tests, 768 assertions.
  • Unit tests — XML-RPC basic (pre_option_enable_xmlrpc / option_enable_xmlrpc shim): pass — 6 tests, 13 assertions.

Not covered

  • Testing was done at PHPStan level 0, where the two enforcement rules run. The local level-10 config (which exercises the apply_filters() dynamic return-type extension) was not run, so the return-type inference improvement is taken as described in the PR rather than independently verified here.

Conclusion
PR #12022 adds two bundled PHPStan rules (hook documentation + hook argument count), an apply_filters() return-type extension, and reference/@global docblock resolution, then fixes the 38 issues the tooling surfaces in core. Verified that the rules genuinely catch the documented problems and that the core fixes leave the full test suite for every touched area green. The changes are minimal and scoped to the ticket: the great majority are one-line argument-count or reference-comment corrections; the few larger diffs (template.php, abilities-api/class-wp-ability.php) are behaviour-preserving reorderings so the docblock sits immediately before the hook call, and media.php / plugin-install.php add @param/@global type refinements that directly serve the static-analysis goal. No unrelated refactors or speculative code.

Backward-compatibility: the argument-count fixes change what callbacks receive. Adding a documented argument is low risk (callbacks only receive the arguments they request; those that requested the documented count previously got null and now get the real value, as the docs always promised). The two cases that remove an undocumented extra argument (the_content in do_trackbacks(), the_excerpt in the revisions controller) only drop a value that was never part of the documented contract.

Workflow notes for the committer: the branch carries un-squashed fixup! commits and, per the author, is intended to be committed in pieces (the PHPStan rules first, then the core fixes). It should also be rebased on current trunk, which clears the lone _mb_ord() error noted above.

Recommend commit.

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.8
Used for: Running the PHPStan and PHPUnit suites, investigating and verifying the results (including the _mb_ord() finding and the rule regression checks), and drafting this test report.

Last edited 3 months ago by khokansardar (previous) (diff)

#11 follow-up: @westonruter
3 months ago

@khokansardar It looks like you used AI for this patch testing report. Please amend your comment with details about what AI tooling you used.

#12 in reply to: ↑ 11 @khokansardar
3 months ago

Replying to westonruter:

@khokansardar It looks like you used AI for this patch testing report. Please amend your comment with details about what AI tooling you used.

Details added here https://core.trac.wordpress.org/ticket/65376#comment:10

#14 @westonruter
3 months ago

In 62893:

Bundled Themes: Fix hook docblocks and filter arguments.

  • Add the missing variable name to @param tags that document a type and a description but no name, in Twenty Ten, Twenty Eleven, and Twenty Twenty-One.
  • Pass the documented $post argument to the the_permalink filter in Twenty Eleven, Twenty Thirteen, and Twenty Fifteen. The filter gained that parameter in 4.4, but these call sites were never updated, so a callback registered for two arguments received only one.
  • Point Twenty Eleven's widget_title reference comment at wp-includes/widgets/class-wp-widget-pages.php, since wp-includes/default-widgets.php no longer exists. Add the reference comments that were missing altogether for the_permalink in Twenty Thirteen and Twenty Fifteen, and for widget_title in Twenty Fourteen.

These issues were surfaced by the PHPStan extensions under development for this ticket, which are committed separately.

Developed as subset of ​https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r25625, r25627, r33843, r35002, r51304, r60077.

See #65376, #64896.

​@westonruter commented on ​PR #12022:


3 months ago
#15

Fixes to themes have been committed in r62893 (d7de266)

#16 @westonruter
3 months ago

In 62894:

HTTP API: Pass the missing URL to the https_local_ssl_verify filter.

The filter has documented a $url parameter since 5.1.0, when r42682 added it to the transports in WP_Http_Streams and WP_Http_Curl. However, this new parameter was not passed to all instances of the filter being applied, resulting in a possible fatal error if a callback is expecting it.

These call sites were surfaced by the PHPStan extensions under development for this ticket, which are committed separately.

Developed as subset of ​https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r28781, r42682, r46230, r46231, r54043, r58128.

See #65376, #42186, #47957, #56041, #58281.

​@westonruter commented on ​PR #12022:


3 months ago
#17

In r62894 (2f137d1fed1b611daf22b7a5303c9c852f80905c) the fixes are applied to the https_local_ssl_verify filter.

#18 @westonruter
3 months ago

In 62925:

Taxonomy: Pass the missing $args to several term hooks.

The edit_terms, edited_terms, edit_term_taxonomy, edited_term_taxonomy, and term_id_filter hooks have documented an $args parameter since 6.1.0, when r53627 added it. However, this new parameter was not passed to all instances of the hooks being fired, resulting in a possible fatal error if a callback is expecting it.

In wp_insert_term() and wp_update_term() the function's own $args is passed. In _update_post_term_count() and _update_generic_term_count() there is no such array, since the actions fire while recounting rather than while a term is being edited, so an empty array is passed.

These call sites were surfaced by the PHPStan extensions under development for this ticket, which are committed separately.

Developed as subset of ​https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r31525, r53627.

See #65376, #55441.

#19 @westonruter
3 months ago

In 62926:

Menus: Pass the missing $menu_data to the wp_update_nav_menu action.

The action has documented a $menu_data parameter since 3.0.0, when it was introduced in wp_update_nav_menu_object(). However, this parameter was not passed to all instances of the action being fired, resulting in a possible fatal error if a callback is expecting it.

Neither of these call sites has menu data to supply: wp_nav_menu_update_menu_items() fires the action after updating a menu's items, and WP_REST_Menus_Controller::handle_auto_add() after updating the nav_menu_options option. Both now pass an empty array to satisfy the documented signature.

These call sites were surfaced by the PHPStan extensions under development for this ticket, which are committed separately.

Developed as subset of ​https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r15104, r23441, r52079.

See #65376, #40878.

#20 @westonruter
3 months ago

In 62927:

REST API: Pass the missing $is_update param to the wp_creating_autosave action.

The action has documented an $is_update parameter since 6.4.0, when r56714 added it. However, this new parameter was not passed to all instances of the action being fired, resulting in a possible fatal error if a callback is expecting it.

WP_REST_Autosaves_Controller::create_post_autosave() fires the action on the branch that overwrites an author's existing autosave, mirroring wp_create_post_autosave(), so true is passed.

This call site was surfaced by the PHPStan extensions under development for this ticket, which are committed separately.

Developed as subset of ​https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r44126, r56714.

See #65376.

#21 @westonruter
3 months ago

In 62928:

Comments, REST API: Drop undocumented args from content filters.

This removes the undocumented post parameter from the the_content and the_excerpt filters.

The the_content filter briefly took a post ID during 3.6 development: r24301 added one, and r24598 removed it again before release on the grounds that it "only worsens the function prototype". However, do_trackbacks() was left passing $post->ID. The the_excerpt filter has never taken a second parameter, yet WP_REST_Revisions_Controller::prepare_excerpt_response() has passed the WP_Post object since r38832.

In both cases the extra value is absent from the documented signature and from every other invocation of the filter, so no callback can rely on receiving it. A survey of the plugin directory found no callback that requires the extra argument; those that declare one give it a default and fall back to the current post. Drop the arguments so the calls match their documentation. The $post parameter of prepare_excerpt_response() is left in place, since it is part of the method's overridable signature.

These call sites were surfaced by the PHPStan extensions under development for this ticket, which are committed separately.

Developed as subset of ​https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r24301, r24598, r38832.

See #65376.

#22 @westonruter
3 months ago

In 62930:

Networks and Sites: Pass the missing $site_id to bulk actions.

The handle_network_bulk_actions-{$screen} filter has documented a $site_id parameter since 4.7.0, when r38647 and r38957 introduced it. However, this parameter was not passed to all instances of the filter being applied, resulting in a possible fatal error if a callback is expecting it.

The Network Themes and Network Users screens act on the network as a whole rather than on a single site, so there is no site ID to supply and 0 is passed. This matches wp-admin/network/sites.php, where the value already falls back to 0 when the request carries no site.

These call sites were surfaced by the PHPStan extensions under development for this ticket, which are committed separately.

Developed as subset of ​https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r38647, r38957.

See #65376, #16031.

#23 @westonruter
3 months ago

In 62931:

Plugins: Pass the missing $network_wide to activate_{$plugin}.

The action has documented a $network_wide parameter since r16011, which passed the flag to the de/activation hooks in activate_plugin() and deactivate_plugins(). However, the error scraping path in wp-admin/plugins.php re-fires the action to reproduce a fatal error, and was left passing no arguments at all. A callback registered through register_activation_hook() that declares the parameter therefore raised an ArgumentCountError while the error was being scraped.

This call site was surfaced by the PHPStan extensions under development for this ticket, which are committed separately.

Developed as subset of ​https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r9352, r16011.

See #65376, #14170.

#24 @westonruter
3 months ago

In 62932:

Plugins: Pass the missing $paged to install_plugins_upload.

The install_plugins_{$tab} action has documented a $paged parameter since 2.7.0, when r8540 introduced the Add Plugins screen. Since r38172 the Add Plugins screen also fires the upload instance of it directly, so that the upload form can be printed on every tab rather than only its own, and that call was left passing no arguments. On the upload tab a callback received $paged; on every other tab the callback received nothing, raising an ArgumentCountError if it declared the parameter.

Also declare $tab and $paged as globals after WP_Plugin_Install_List_Table::prepare_items() populates them, documenting the side effect that the rest of the file has always relied on.

This call site was surfaced by the PHPStan extensions under development for this ticket, which are committed separately.

Developed as subset of ​https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r8540, r37221, r38172.

See #65376.

#25 @westonruter
3 months ago

In 62933:

XML-RPC: Pass the missing args to the enable_xmlrpc filters.

The option_{$option} filter has documented an $option parameter since 4.4.0, when r33738 added it, and pre_option_{$option} has documented both $option and, since 4.9.0, $default_value, added in r41013. However, WP_XMLRPC_Server::set_is_enabled() applies the enable_xmlrpc instances of these filters directly, in order to respect callbacks left over from before the option was deprecated in 3.5.0, and those calls were left passing only the value. A callback declaring the later parameters therefore raised an ArgumentCountError here, even though the very same callback works when the filter is reached through get_option().

The option name and the default are passed explicitly, since there is no $option variable in scope. The default matches what the filter would receive from get_option( 'enable_xmlrpc', false ).

Also add the standard reference comments pointing at the canonical docblocks in wp-includes/option.php.

These call sites were surfaced by the PHPStan extensions under development for this ticket, which are committed separately.

Developed as subset of ​https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r21804, r33738, r41013.

See #65376, #28402, #41254.

#26 @westonruter
3 months ago

In 62934:

Docs: Attach three hook doc comments to their filter calls.

Hook documentation is located by adjacency: the docblock must be the last thing before the line containing the apply_filters() or do_action() call, as established in r46088. In three places it was not, so the comment documented nothing.

  • In wp-admin/includes/ms.php the reference comment for users_have_additional_content was opened with /* rather than /**, making it an ordinary block comment instead of a docblock.
  • In wp-admin/includes/template.php the reference comment for editable_slug sat in the middle of a string concatenation, attached to no statement at all; the filter is now applied on its own line and the result interpolated.
  • In wp-includes/abilities-api/class-wp-ability.php the sentinel assignment sat between the wp_pre_execute_ability docblock and the call it documents, so the docblock attached to the assignment; the assignment is hoisted above the docblock.

None of this changes behavior. The filters run with the same arguments, in the same order, and produce the same output.

These comments were surfaced by the PHPStan extensions under development for this ticket, which resolve a hook's documentation the same way the parser behind the developer handbook does, and which are committed separately.

Developed as subset of ​https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r27237, r46088, r62397, r62688.

See #65376, #64896.

#27 @westonruter
3 months ago

In 62935:

Docs: Correct the type of the image_editor_output_format map.

The $output_format array maps a source mime type to a destination mime type, so its keys are mime type strings. Since r51221 it has been documented as string[] (which typically means an integer-keyed array of strings) expanded by a hash-notation block whose @type string ...$0 is the notation core uses for numerically indexed members. Both halves therefore described a list, and nothing in the documentation conveyed that the key is the source mime type, which is the entire point of the array.

Replace both with array<string, string>, which states the key and value types directly and needs no nested block. The @return of wp_get_image_editor_output_format() gets the same correction, having inherited string[] when r58849 moved the canonical docblock into wp-includes/media.php.

This mismatch was surfaced by the PHPStan extensions under development for this ticket, which read hook docblocks in order to type the return of apply_filters(), and which are committed separately.

Developed as subset of ​https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r50943, r51221, r58849.

See #65376, #64896.

​@westonruter commented on ​PR #12022:


3 months ago
#28

The remaining docs fixes have been committed to SVN. What remains is the new PHPStan extensions.

​@westonruter commented on ​PR #12022:


3 months ago
#29

Automated review by Claude Opus 5 (1M context), run via Claude Code at the request of @westonruter. Analysis was performed against add/phpstan-apply-filters-return-type-extension in a local checkout; every claim below labelled "verified", "measured", or "demonstrated" comes from a command that was actually executed, not from inspection alone. Human judgement still applies — treat the findings as leads to confirm, not verdicts.

# Review: Statically verify that hook documentation and arguments stay in sync

## Overview

The core fixes described in the PR description have already landed in trunk, so the currently reviewable diff is tooling only — 7 files, +1,289/−0 under tests/phpstan/:

File Role
HookDocsVisitor.php Rich-parser visitor propagating a docblock down to the hook call it documents
HookDocBlock.php Resolves inline docblocks and the /** This filter is documented in <file> */ convention (incl. dynamic hook names)
ApplyFiltersDynamicFunctionReturnTypeExtension.php Types apply_filters*() from the first documented @param
HookDocumentationRule.php Requires every hook invocation to be documented; validates reference comments
HookParamCountRule.php Requires the argument count to match the documented @param count
base.neon Wires the above; excludes src/wp-includes/build
GlobalDocBlockVisitor.php @noinspection PhpUnused only

The design is sound and follows the precedent already set by GlobalDocBlockVisitor. Two decisions deserve credit: getDocumentedParamCount() deliberately returns null (not 0) for an unresolvable reference, which kills an entire class of false positives; and the docblock-scope state machine in HookDocsVisitor (bounded reach, statements clearing the scope) is the right model for WP's docblock placement rules.

## What was verified independently

  • CI-equivalent run is clean. Full analysis with phpstan.neon.dist (level 0, baseline, reportUnmatchedIgnoredErrors: true), isolated cache dir: exit 0, zero errors. Since both rules are registered as plain phpstan.rules.rule, they are not level-gated — a clean level-0 run means no hook-rule errors anywhere in the analysed paths.
  • Rules fire correctly on fixtures for: missing docblock, referenced file missing, hook absent from referenced file, apply_filters_ref_array() array counting, and correctly *skipping* ...$args of unknown size.
  • Audited all 19 reference sites in core that resolve via a dynamic pattern rather than an exact name: all 19 are semantically correct instances (index_template_hierarchy → {$type}_template_hierarchy, load-page.php → load-{$page_hook}, etc.).
  • No duplicate or variadic @param names in any hook docblock in src/.

## Findings

### 1. Loose dynamic-hook patterns punch a hole in the doc rule (medium)

HookDocBlock::hookNameRegexParts() maps every non-literal part to .+, so a hook name that is *all* interpolation except a separator produces a near-universal pattern. Core has two such canonical hooks in the two most-referenced files:

  • src/wp-includes/taxonomy.php:1927 — "{$taxonomy}_{$field}" → #^.+_.+$# (12 reference sites point at this file)
  • src/wp-includes/post.php:5980 — "{$new_status}_{$post->post_type}" → #^.+_.+$# (29 reference sites)

Any hook name containing an underscore therefore "resolves" against those files. Confirmed with a fixture:

/** This filter is documented in wp-includes/taxonomy.php */
$a = apply_filters( 'wpr_bogus_hook_that_is_not_in_taxonomy_php', $x );  // ← no hookDocReferenceHookMissing
/** This filter is documented in wp-includes/taxonomy.php */
$b = apply_filters( 'bogushookname', $x );                               // ← correctly flagged
fixture2.php:5: apply_filters() for hook "wpr_bogus_hook_that_is_not_in_taxonomy_php" provides 1 argument,
                but the hook is documented with 3 parameters. [wordpress.hookParamCountMismatch]
fixture2.php:9: ... no documented "bogushookname" hook is found there. [wordpress.hookDocReferenceHookMissing]

So for the two hottest reference targets, a broken reference is not reported (the rule's whole purpose), and is instead compared against — and typed from — an unrelated hook's docblock. The underscore is the only thing separating the two outcomes above.

Suggested fix, in findHookDoc() / buildHookNameRegex():

  • try exact first (already done), then among *all* matching patterns pick the one with the most literal text rather than the first in file order;
  • reject patterns whose literal portion is only separator characters (_, -).

An audit of the 19 current pattern resolutions shows none of them rely on a separator-only pattern, so this tightening is safe against trunk today. (Pattern matching itself is load-bearing — e.g. the already-landed install_plugins_upload fix resolves through install_plugins_{$tab} — so it shouldn't be dropped, just narrowed.)

### 2. Cross-file docblock reads bypass PHPStan's dependency graph → stale result cache (medium)

parseHookDocs() reads referenced files with file_get_contents(), outside PHPStan's reflection/dependency tracking. A referencing file has no symbol dependency on its reference target, so editing a canonical docblock does not re-analyse the sites that inherit it.

Demonstrated on this branch. Warm cache, then added one @param to option_{$option} in option.php and nothing else:

RUN 1 (warm):  option.php:257  provides 2 arguments, documented with 3 parameters
RUN 2 (cold):  class-wp-customize-widgets.php:2180  provides 2, documented 3
               class-wp-xmlrpc-server.php:198       provides 2, documented 3   ← silently missed by RUN 1
               option.php:257                       provides 2, documented 3

This is a CI concern, not only a local one: .github/workflows/reusable-phpstan-static-analysis-v1.yml caches .cache with key phpstan-result-cache-${{ github.run_id }} and restore-keys: phpstan-result-cache-, so the result cache carries across runs. A PR that edits a canonical hook docblock can go green while 145 possible reference targets / 210 referencing files go unchecked — and at higher levels the inferred filter *return types* at those sites go stale too, so errors surface later in an unrelated PR that happens to touch the file.

Mitigation: register a ResultCacheMetaExtension (tag phpstan.resultCacheMetaExtension — present in the pinned phar) whose getHash() covers the parsed hook-doc maps of referenced files. That invalidates only when a hook docblock actually changes, and the full build of all 145 targets was measured at 1.05 s (7.4 MB), which is acceptable for a once-per-run hash. At minimum, document the limitation in tests/phpstan/README.md.

### 3. An attribute is written to every AST node (low, easy win)

HookDocsVisitor::enterNode() calls setAttribute( 'latestDocComment', … ) unconditionally, but only FuncCall nodes are ever read back (HookDocBlock::getNullableNodeComment(), parseHookDocs()). Measured over src/wp-includes/*.php alone: 543,762 nodes, of which 5.74% are FuncCall — the attributes retain ~21.5 MB. Given tests/phpstan/README.md already tells contributors to raise --memory-limit, gating the write on $node instanceof FuncCall (the propagation state machine is untouched) removes ~94% of the writes. GlobalDocBlockVisitor already only mutates the nodes it cares about.

### 4. Non-hook docblocks count as hook documentation (low)

A /** @var string $x */ or @since-only docblock immediately above a call satisfies HookDocumentationRule, then yields a confusing message from the other rule:

fixture3.php:4:  apply_filters() for hook "wpr_var_annotation_hook" provides 1 argument,
                 but the hook is documented with 0 parameters.

Consider requiring an inline docblock to carry at least one @param before treating it as hook documentation for apply_filters*, and phrasing the zero case as "no @param tags" so the fix is obvious.

### 5. getParamTags() is keyed by parameter name (low, latent)

ResolvedPhpDocBlock::getParamTags(): array<string, ParamTag> — two @param tags with the same name collapse into one, silently undercounting. A scan of every hook docblock in src/ found no current instances (and no variadic @param ...$args), so this is latent only. Worth a comment, or count tags off the raw PhpDocNode.

### 6. Duplicated resolution flows in HookDocBlock (low)

getNullableHookDocBlock() and getDocumentedParamCount() repeat the same class/trait-reflection getResolvedPhpDoc() block, and getPrecedingDocBlock() / getReferenceProblem() / getDocumentedParamCount() each independently re-run getNullableNodeComment() + preg_match( REFERENCE_PATTERN ). One resolve() returning a small value object (kind, doc text, resolved block, problem) would collapse three near-identical flows and remove the need for the "unlike getNullableHookDocBlock(), this does NOT fall back…" caveat in the docblock.

### 7. resolveReferencePath() walks to the filesystem root (low)

For wp-includes/media.php from a bundled theme it tests ~8 candidates and, in principle, could match a path outside the checkout before reaching the $wordpressRoot fallback. Bounding the walk at $wordpressRoot (then falling back) makes it hermetic while keeping the bundled-theme sibling references working.

## Nits

  • unset( $functionReflection ) / unset( $nodes ) read as dead code — if they exist to satisfy an unused-parameter inspection, say so in a comment; otherwise drop them.
  • foreach ( $params as $param ) { return $param->getType(); } → reset( $params ) / array_values( $params )[0] states "first param" directly (upstream's quirk, not worth inheriting).
  • @noinspection PhpUnused is added only to GlobalDocBlockVisitor; the five new classes are equally unreferenced. Apply consistently or drop — IDE directives in core source are a judgement call worth making once.
  • @param alignment is inconsistent in resolveDocumentedInReference() and both rules' processNode() while the rest of those files align; likewise $seen[ $regex ] vs $docs['patterns'][]. Cosmetic only — tests/phpstan/* is excluded from PHPCS.
  • base.neon: the comment introducing the service block describes the return-type extension, but HookDocsVisitor underneath it is shared by both rules. Retitle so the grouping isn't misleading.
  • tests/phpstan/README.md isn't updated — no mention of the new rules, the four new error identifiers, or what to do about a false positive. Given the rules now gate every hook call, that's the doc a confused contributor will look for.
  • src/wp-includes/build is excluded from all analysis, not just the hook rules. It mirrors the existing wp-includes/blocks precedent, so it's defensible; if the intent is only to defer the hook errors, ignoreErrors scoped to that path plus the two identifiers keeps the rest of the analysis alive.
  • Attribution to MIT-licensed szepeviktor/phpstan-wordpress is present in each adapted file's docblock. Worth confirming with the committer whether core wants a license notice for ApplyFiltersDynamicFunctionReturnTypeExtension, which stays close to upstream.
  • No tests. PHPStan\Testing\RuleTestCase is available in the pinned phar, and fixture-based tests would lock in the subtle semantics (docblock scoping, reference/pattern resolution, unpack skipping) that findings 1 and 4 poke at. GlobalDocBlockVisitor has no tests either, so this is a suggestion rather than a blocker — but these rules have far more behavior to pin down.

## Risks

  • Ecosystem behavior change from the already-landed argument-count fixes (out of this diff's scope, but the tooling is what motivates them): callbacks registered with a lower $accepted_args are unaffected, but callbacks that inspect func_get_args() now see different arity.
  • Trusting hook docblocks for types is the deliberate trade-off; treatPhpDocTypesAsCertain: false is already set in base.neon, which meaningfully limits the blast radius of a docblock that is narrower than reality.
  • Findings 1 and 2 both weaken the *enforcement guarantee* rather than break the build — which is the failure mode to worry about for a rule whose value is that it can't be forgotten.

None of these block the tooling landing; #1 and #2 are the two worth addressing (or explicitly noting in the README) before this becomes the thing core relies on to keep hook docs honest.

​@westonruter commented on ​PR #12022:


2 months ago
#30

From Claude Opus 5 (1M context), via Claude Code, at @westonruter's request. This follows up on the "No tests" nit in ​my earlier review comment, recording what I found while working through the other findings so that whoever picks tests up does not have to rediscover it. Everything stated as verified below came from running it in a local checkout.

## Following up on: "No tests"

No tests. PHPStan\Testing\RuleTestCase is available in the pinned phar, and fixture-based tests would lock in the subtle semantics (docblock scoping, reference/pattern resolution, unpack skipping) that findings 1 and 4 poke at. GlobalDocBlockVisitor has no tests either, so this is a suggestion rather than a blocker — but these rules have far more behavior to pin down.

Two viable routes, and they differ a lot in cost. The distinction matters because my original note made RuleTestCase sound like the cheap option, and it isn't.

### Route 1 — PHPStan\Testing\RuleTestCase: correct, but needs new test infrastructure

Class availability is not the blocker, which is what I had assumed. Both of these are true in a normal checkout:

RuleTestCase autoloadable: yes     (the phpstan/phpstan composer package's bootstrap exposes the phar's classes)
PHPUnit available:         yes

The blocker is bootstrapping. phpunit.xml.dist declares a single global bootstrap="tests/phpunit/includes/bootstrap.php", which boots WordPress. A RuleTestCase must not — it builds a PHPStan container instead — and any file placed under tests/phpunit/ is swept into the default suite. PHPUnit's bootstrap is per config file, not per test suite, so this route needs:

  • a second PHPUnit configuration with a minimal bootstrap (vendor/autoload.php and little else),
  • a composer / npm script to run it,
  • a CI job or an addition to an existing workflow.

That is a test-infrastructure decision for core rather than something to fold into this PR, so it probably wants its own ticket.

### Route 2 — fixtures plus an expected-output diff: self-contained

Drive PHPStan itself over committed fixture files and diff the result:

  • tests/phpstan/fixtures/*.php — the cases below,
  • a small fixtures.neon that includes base.neon and points paths at the fixtures,
  • an expected.txt of the exact --error-format=raw output,
  • a short runner plus a composer / npm script.

No PHPUnit, no WordPress bootstrap, no new dependency. It is mechanical because the fixtures and their exact expected output already exist — they are what I re-ran after each change while working through findings 1 through 7.

## What is worth covering, and why the analyzed codebase cannot cover it

The reasonable objection to adding tests here is that the codebase being analyzed *is* the test: if a rule breaks, core stops being clean. That holds for most of the behavior. It does not hold for cases core's own tree contains zero instances of — those are exactly where a regression would be silent:

Case Instances in core Where it does occur
Reference comment using dot segments (../foo.php) 0, across all 831 core releases indexed on Veloria WooCommerce (includes/admin/class-wc-admin.php → ../wc-user-functions.php) and MainWP (13 references such as ../widgets/widget-mainwp-recent-posts.php) — 15 occurrences across 3 of 71,582 indexed plugins, 0 of 14,806 themes
Two @param tags sharing a name 0 latent; collapses in getParamTags(), which both misreports a correct call and hides a genuine mismatch
A non-hook docblock (/** @var … */) immediately above a hook call 0 plugin and theme code generally
A reference comment that cannot be resolved 0 (all 712 reference sites in the tree resolve) any stale reference, which is the case the rule exists to catch
Argument spread of statically unknown size (...$args) 0 at a hook call must stay skipped rather than guessed
Result cache invalidation when an inherited docblock changes not observable from a clean run needs a warm-cache run, an edit, and a second run

The dot-segment row is the sharpest illustration. While reviewing finding 7 I nearly added a guard rejecting .. in reference paths. Core has never written one, so the change would have looked inconsequential in CI here — and it would have started reporting valid references in a plugin with seven million active installations as naming a missing file. What shipped instead canonicalizes the candidate and requires the result to be inside the analyzed tree, which keeps those references working. A fixture is the only thing that would keep a future contributor from making the mistake I nearly made.

## Ad hoc harnesses are not a substitute

While working through these findings I leaned on throwaway scripts that reached into HookDocBlock with reflection. One of them fataled the moment a typed property was added to the class:

Typed property WordPress\PHPStan\HookDocBlock::$canonicalWordpressRoot
must not be accessed before initialization

That was the script, not the code — but it is the argument for committed fixtures driven through PHPStan's public entry point: they exercise the rules the way PHPStan does, so they do not break when the internals move, which is precisely when you want them to still be running.

## Suggested split

  1. Route 2 as a follow-up on this ticket or a small companion one: it is additive, and it captures the six rows above.
  2. Route 1 as its own ticket, since the discussion is really "should tests/phpstan/ have a PHPUnit suite of its own", which is broader than these rules.

Either way this remains a suggestion rather than a blocker, for the reason in the original note: GlobalDocBlockVisitor ships untested today, so requiring tests here would be holding new tooling to a standard the existing tooling does not meet.

​@westonruter commented on ​PR #12022:


2 months ago
#31

From Claude Opus 5 (1M context), via Claude Code, at @westonruter's request. Follow-up to ​my review comment, which still reads as though its findings were open. Every finding in it has now been addressed on the branch, each change directed and reviewed by @westonruter. Commit subjects are given rather than SHAs, since the commits are not pushed as of this comment and may be rebased.

## Every finding from the review is addressed

# Finding Resolution
1 Loose dynamic hook patterns satisfied the doc rule *Require a specific anchor to resolve a hook doc reference.* A pattern anchored only on separators is no longer honored, and among matching patterns the most specifically anchored wins.
2 Cross-file docblock reads bypassed the dependency graph *Invalidate cached results when inherited hook docs change.* A ResultCacheMetaExtension folds the inheritable hook docblocks, and the tooling's own sources, into the result cache key.
3 An attribute was written to every AST node *Record hook docblocks only where they are read.* Written on function calls only, and only when a docblock applies: 543,762 nodes carrying it across src/wp-includes/*.php became 2,511, and ~21.5 MB of retained attributes is no longer measurable.
4 Non-hook docblocks counted as hook documentation *Require a filter's docblock to document the filtered value.* A filter always passes the value being filtered, so a filter documented with no @param is reported as undocumented rather than compared against a count of zero.
5 getParamTags() is keyed by parameter name *Count every documented hook parameter, including repeated names.* Counted from the parsed docblock nodes, which list every tag.
6 Duplicated resolution flows *Resolve a hook's documentation in one place.* One getHookDoc() replaces five overlapping methods; each rule resolves once instead of two or three times. Net 67 lines removed.
7 resolveReferencePath() walked to the filesystem root *Stop resolving a hook doc reference outside the analyzed tree*, then *Confine a resolved hook doc reference to the analyzed tree.* The walk stops at the WordPress root, and a candidate is canonicalized and accepted only when it resolves inside the tree.

The nits are addressed too: the unset() calls and the foreach that returned on its first iteration are gone, the two rules' @param tags are aligned, every service in base.neon now has a comment describing what it actually is, the adapted files credit szepeviktor/phpstan-wordpress with @link, name its MIT license and pin the upstream commit, and tests/phpstan/README.md now documents the extensions and all five error identifiers they report.

## Four things turned out worse than the review described

Each of these was demonstrated rather than reasoned about, and each was a real defect rather than the theoretical one I had written up:

  • Finding 1 was a false negative, not just imprecision. A reference comment naming a hook that taxonomy.php does not document produced *no* hookDocReferenceHookMissing at all, because "{$taxonomy}_{$field}" compiles to #^.+_.+$# and matched it — so the rule silently accepted a broken reference and then reported an argument count borrowed from an unrelated hook. Whether a bad reference was caught came down to whether the hook name happened to contain an underscore.
  • Finding 5 also hid genuine mismatches. I had described the name collapse as producing false positives. It does, but it also *suppresses* real errors: a hook documenting two @param tags that share a name and passing one argument matched a collapsed count of one, and was reported as correct.
  • Finding 7 was not merely wasted is_file() calls. With a file present above the tree, resolution reached it and used its docblock to document, type and count the hook — so a broken reference went unreported because something unrelated answered to the same relative path.
  • Finding 2 extended to the tooling itself. PHPStan hashes its configuration but not extension logic, so editing a rule left the results that rule had already produced in the cache. The same metadata extension now covers both.

## What is deliberately not changed

  • src/wp-includes/build stays excluded from analysis. The code is generated from Gutenberg rather than authored here, so the fix belongs upstream, as with src/wp-includes/blocks.
  • The return type extension still reads the first entry of getParamTags(). With two @param tags sharing a name, a later tag's type can displace the first. Resolving it properly needs TypeNodeResolver and a NameScope, and finding 5 makes the situation loud rather than silent: duplicates now count as two, so the call is reported.
  • Tests remain a follow-up, for the reasons in ​the previous comment.
  • PHPStan\Cache\Cache was tried and reverted. Persisting parsed docblocks across runs took a warm run from 1.86 s to 1.28 s, but PHPStan reports both calls as outside its backward compatibility promise, which would have required ignore entries in the shared base.neon. Not worth pinning core tooling to PHPStan internals for 0.6 s.

## One cost this introduces

The result cache metadata added for finding 2 costs about 1.2 s per run — roughly 0.25 s to scan the tree for reference comments, and 1 s to parse the 145 files they name. That is noise against a cold run of the full analysis, but it is most of the cost of an otherwise near-instant warm run. The alternatives are worse: hashing file contents rather than parsed docblocks would flush the whole cache whenever a frequently-referenced file like post.php is edited at all.

## Verification

Repeated after each change:

  • The CI configuration (phpstan.neon.dist, level 0 with the baseline and reportUnmatchedIgnoredErrors) reports no errors from a cold cache, in an isolated cache directory so a warm cache cannot mask a finding.
  • Level 10 with bleedingEdge over tests/phpstan/ is clean.
  • All 712 reference sites in the analyzed tree still resolve, through the same 19 dynamic patterns and to the same docblocks as before.
  • Four rule fixtures covering missing documentation, both broken-reference forms, _ref_array counting, argument spreads of unknown size, @var above a filter and repeated @param names produce byte-identical output across the refactors.
  • Filter return types were re-checked with PHPStan\dumpType() for inline, inherited, unresolvable, zero-parameter and multi-parameter docblocks, since the rule fixtures do not cover the return type extension.
  • Each finding's fix was confirmed against a reproduction that failed before it and passes after, including a warm-cache versus cold-cache comparison for finding 2.

#32 @westonruter
2 months ago

  • Resolution → fixed
  • Status assigned → closed

In 62939:

Build/Test Tools: Verify hook docblocks statically.

Add PHPStan extensions that read the docblock documenting each hook where the hook is fired. The value apply_filters() returns is typed from the first @param that docblock documents rather than mixed; core's /** This filter is documented in <file> */ convention is resolved, so a hook documented elsewhere is analyzed against its canonical docblock, including a dynamic canonical name such as "{$type}_template_hierarchy"; and two rules require every hook invocation to be documented, and to pass as many arguments as its documentation describes.

These conventions were previously enforced by review alone. A reference comment could name a file that no longer documents the hook, and a call site could pass fewer arguments than documented, which raises an ArgumentCountError in a callback registered for the documented count, or more, which drops the extra argument and leaves the documentation wrong. Both are now reported where they occur. The hook issues this surfaced in core were fixed in preceding commits.

The generated src/wp-includes/build tree is excluded from analysis, as its sources live in the Gutenberg plugin.

Developed in ​https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r61699, r62292, r62893, r62894, r62925, r62926, r62927, r62928, r62930, r62931, r62932, r62933, r62934, r62935.

Props westonruter, szepeviktor, khokansardar.
See #64898.
Fixes #65376.

Note: See TracTickets for help on using tickets.