Skip to content

Build/Test Tools: Honor @global docblock tags in PHPStan analysis#11692

Open
westonruter wants to merge 7 commits intoWordPress:trunkfrom
westonruter:phpstan/honor-global-docblock-tags
Open

Build/Test Tools: Honor @global docblock tags in PHPStan analysis#11692
westonruter wants to merge 7 commits intoWordPress:trunkfrom
westonruter:phpstan/honor-global-docblock-tags

Conversation

@westonruter
Copy link
Copy Markdown
Member

@westonruter westonruter commented May 1, 2026

tl;dr: Adds a PHPStan parser-node visitor that bridges WordPress core's @global Type $varname PHPDoc convention to PHPStan's variable type resolution, eliminating 36,297 false-positive mixed errors without touching any production code.


I use a local PHPStan configuration at level 10. This causes PHPStan to complain a lot, including about the use of globals like $wpdb. For code like this:

I get two errors from PHPStan:

  • phpstan: Cannot access property $site on mixed.
  • phpstan: Part $wpdb->site (mixed) of encapsed string cannot be cast to string.

This is in spite of the fact that the method has:

* @global wpdb $wpdb WordPress database abstraction object.

and

The issue is that PHPStan doesn't recognize this use of @global. It's a WordPress-specific thing. It does recognize this, however:

/** @var wpdb $wpdb */
global $wpdb;

While we could fix the issues by going throughout core and adding these @var tags, this would be extremely noisy and be of very little value. Instead, we can introduce a PHPStan extension for core which causes PHPStan to treat the @global annotations as aliases for inline @var annotations. This is what is implemented by this PR.

Approach by Claude

Adds a custom parser node visitor, WordPress\PHPStan\GlobalDocBlockVisitor, that:

  1. Walks each FunctionLike node and parses any @global Type $name tags from its docblock.
  2. For every global $name; statement inside that function body, if $name matches a documented tag, attaches a synthetic /** @var Type $name */ doc comment to the global AST node.
  3. PHPStan's existing @var-on-global handling then assigns the documented type.

Behavior:

  • Functions without @global tags are untouched; their globals continue to resolve as mixed.
  • Globals not listed in the function's @global block are untouched; they continue to resolve as mixed.
  • Hand-written @var annotations already present on a global statement are respected and preserved.
  • Nested functions/closures get their own @global map (visitor uses a stack).

The visitor is registered as a phpstan.parser.richParserNodeVisitor service in tests/phpstan/base.neon and autoloaded via a new autoload-dev PSR-4 entry in composer.json mapping WordPress\PHPStan\ to tests/phpstan/. composer install (which runs composer dump-autoload) makes the class available to PHPStan automatically.

Result

When running composer -- phpstan --configuration=phpstan.neon.dist --level=10:

  • Before: [ERROR] Found 40069 errors
  • After: [ERROR] Found 36300 errors

Claude comparison

Comparison summary

Trunk Branch Diff
Total errors (PHPStan totals) 40069 36300 -3769
Unique errors (file+line+id+msg) 39372 35705 -3667
Fixed by branch (in trunk, not branch) 4456
Introduced by branch (in branch, not trunk) 789

The fixed-vs-introduced asymmetry exists because the visitor narrows $wpdb etc. from mixed to wpdb, which both removes errors (the mixed.method() family) and exposes new ones (real type mismatches in wpdb method signatures, @var array injections that PHPStan wants array<...> for, etc.).

Top fixed identifiers

1375 method.nonObject              -- $foo->method() on what was mixed
1040 property.nonObject            -- $foo->prop on what was mixed
 607 encapsedStringPart.nonString  -- "FROM $wpdb->posts" interpolations
 462 argument.type
 304 offsetAccess.nonOffsetAccessible
 188 binaryOp.invalid
 126 foreach.nonIterable
 119 return.type
  64 cast.int
  46 assignOp.invalid
  39 property.notFound
  32 method.notFound
  21 offsetAccess.invalidOffset
  15 assign.propertyType
   6 preInc.type

Top fixed messages

304 Cannot call method prepare() on mixed.
233 Cannot access property $posts on mixed.
188 Part $wpdb->posts (mixed) of encapsed string cannot be cast to string.
126 Argument of an invalid type mixed supplied for foreach, only iterables are supported.
111 Cannot access offset string on mixed.
110 Cannot call method query() on mixed.
108 Cannot call method get_results() on mixed.
100 Cannot call method get_var() on mixed.
 82 Cannot access offset mixed on mixed.
 75 Cannot access property $comments on mixed.
 64 Cannot cast mixed to int.
 64 Cannot call method get_col() on mixed.
 59 Cannot call method update() on mixed.
 53 Cannot access property $options on mixed.
 52 Part $wpdb->comments (mixed) of encapsed string cannot be cast to string.
 51 Cannot call method delete() on mixed.
 44 Cannot access property $term_taxonomy on mixed.
 41 Cannot call method esc_like() on mixed.
 38 Cannot access property $postmeta on mixed.
 38 Part $wpdb->options (mixed) of encapsed string cannot be cast to string.
 27 Cannot access property $site on mixed.    ← your original case

The 4456 fixed errors all stem from globals (mostly $wpdb, $wp_query, $wp_locale, $wp_filter, etc.) becoming concretely typed where @global tags exist. Nothing was suppressed via baseline; these are real PHPStan errors that the visitor now resolves.

Trac ticket: https://core.trac.wordpress.org/ticket/64898

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Opus 4.7
Used for: Research and code writing


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

westonruter and others added 3 commits April 30, 2026 22:11
WordPress core documents the globals imported into a function with `@global Type $varname` tags on the function's docblock, then writes `global $varname;` inside the body. PHPStan does not natively consult those tags when resolving the type of a variable imported via `global`; it only honors `@var` annotations placed directly on the `global` statement. As a result, every `global $wpdb;` site resolved as `mixed`, and any subsequent `$wpdb->prepare(...)` or `$wpdb->site` produced spurious "method/property on mixed" errors throughout core.

Bootstrap-file and stub-file declarations of global variable types (the form used by `php-stubs/wordpress-stubs`) were verified not to address this — PHPStan's `global $foo;` resolution path does not consult them. The only mechanism that works is an `@var` directly on the `global` statement.

This change adds a custom PHPStan parser node visitor, `WordPress\PHPStan\GlobalDocBlockVisitor`, that walks each function and method, parses the `@global` tags from its docblock, and injects an equivalent synthetic `/** @var Type $name */` doc comment onto every matching `global $name;` statement in the body. PHPStan's existing `@var`-on-global handling then assigns the documented type. Functions without `@global` tags, and globals not listed in the function's docblock, are left untouched and continue to resolve as `mixed` — preserving PHPStan's safety guarantees. Hand-written `@var` annotations already on a `global` statement are preserved as well.

The visitor is registered as a `phpstan.parser.richParserNodeVisitor` service in `tests/phpstan/base.neon`. It is autoloaded via a new `autoload-dev` PSR-4 entry in `composer.json` mapping `WordPress\PHPStan\` to `tests/phpstan/`; the `composer install` step (which runs `composer dump-autoload`) makes the class available to PHPStan automatically.

Effect on `composer phpstan` against the existing baseline: total error count drops from 40,081 to 36,310 — about 3,800 fewer false positives — without modifying any production code. The remaining `\$wpdb`-related errors are either in top-level scripts (where `global` is not used and PHPStan does not bridge bootstrap declarations) or are real type issues with `wpdb`'s own dynamic properties.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Specify the iterable value type on `beforeTraverse()`'s return, and cast `preg_replace()`'s `string|null` result to `string` (the regex is hard-coded and valid, so null is unreachable in practice).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 1, 2026

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props westonruter, szepeviktor.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@westonruter
Copy link
Copy Markdown
Member Author

cc @justlevine, @apermo, @SergeyBiryukov

@westonruter westonruter requested a review from Copilot May 1, 2026 05:56
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 1, 2026

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enhances the repository’s PHPStan tooling by teaching PHPStan to interpret WordPress core’s @global Type $varname docblock convention as variable type information for matching global $varname; statements, reducing false-positive mixed-related errors during static analysis.

Changes:

  • Adds WordPress\PHPStan\GlobalDocBlockVisitor, a PHPStan rich parser node visitor that injects synthetic inline @var docblocks onto global statements based on enclosing @global tags.
  • Registers the visitor in tests/phpstan/base.neon so it runs during PHPStan parsing.
  • Adds a Composer autoload-dev PSR-4 mapping so PHPStan can autoload the visitor class during analysis.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
tests/phpstan/bootstrap.php Clarifies PHPStan bootstrap purpose/documentation for discovery.
tests/phpstan/base.neon Registers the new rich parser node visitor service for PHPStan runs.
tests/phpstan/GlobalDocBlockVisitor.php Implements AST visitor that maps @global doc tags to inline @var on global statements.
composer.json Adds autoload-dev PSR-4 mapping to autoload the visitor from tests/phpstan/.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/phpstan/GlobalDocBlockVisitor.php Outdated
Comment thread tests/phpstan/GlobalDocBlockVisitor.php Outdated
@westonruter
Copy link
Copy Markdown
Member Author

cc @szepeviktor

westonruter and others added 3 commits April 30, 2026 23:20
…docblock.

The previous implementation skipped a `global` statement entirely if any `@var` was already present on its docblock. This dropped synthetic annotations for the other variables in multi-variable forms like `global $a, $b, $c;` where only `$a` had a hand-written `@var`.

The visitor now extracts the variable names already covered by `@var` (or `@phpstan-var`) from the existing docblock, injects synthetic `@var` lines only for the remaining globals, and merges them into the existing docblock just before the closing `*/` — preserving prose, hand-written types, and any other tags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… paths.

Without this, PHPStan emits a "Result cache might not behave correctly" warning because the visitor class is registered as a service but lives outside the analysed paths, so edits to the file do not invalidate the result cache.

Adds the file specifically (rather than the whole `tests/phpstan/` directory) to avoid analyzing `bootstrap.php` and `baseline.php`, which are not intended as analysis subjects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@westonruter
Copy link
Copy Markdown
Member Author

With 9fc7599, the error count is now brought down by another three to 36,297 (from 36,300).

@szepeviktor
Copy link
Copy Markdown

@westonruter Please do not kill my package.

Copy link
Copy Markdown

@apermo apermo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good to me so far, great work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants