From c943c2e8f1a9a39dc90f56585aed4b78f48671fb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Apr 2026 22:11:34 -0700 Subject: [PATCH 1/8] Build/Test Tools: Honor `@global` docblock tags in PHPStan analysis. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- composer.json | 5 + tests/phpstan/GlobalDocBlockVisitor.php | 134 ++++++++++++++++++++++++ tests/phpstan/base.neon | 8 ++ tests/phpstan/bootstrap.php | 4 +- 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 tests/phpstan/GlobalDocBlockVisitor.php diff --git a/composer.json b/composer.json index 59df0a7af3770..aee7a09524994 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,11 @@ }, "lock": false }, + "autoload-dev": { + "psr-4": { + "WordPress\\PHPStan\\": "tests/phpstan/" + } + }, "scripts": { "phpstan": "@php ./vendor/bin/phpstan analyse --memory-limit=2G", "compat": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --standard=phpcompat.xml.dist --report=summary,source", diff --git a/tests/phpstan/GlobalDocBlockVisitor.php b/tests/phpstan/GlobalDocBlockVisitor.php new file mode 100644 index 0000000000000..431d9f4b2e4ad --- /dev/null +++ b/tests/phpstan/GlobalDocBlockVisitor.php @@ -0,0 +1,134 @@ +> + */ + private array $stack = array(); + + /** + * Resets state at the start of each parser traversal. + * + * @param array $nodes Top-level nodes about to be traversed. + * @return null + */ + public function beforeTraverse( array $nodes ): ?array { + $this->stack = array(); + return null; + } + + /** + * Pushes a frame when entering a function/method, and injects synthetic + * `@var` doc comments on `global` statements that match a documented tag. + * + * @param Node $node The node being entered. + * @return null + */ + public function enterNode( Node $node ): ?Node { + if ( $node instanceof Node\FunctionLike ) { + $doc = $node->getDocComment(); + $this->stack[] = $doc !== null ? $this->parse_global_tags( $doc->getText() ) : array(); + return null; + } + + if ( ! ( $node instanceof Node\Stmt\Global_ ) || $this->stack === array() ) { + return null; + } + + $map = $this->stack[ count( $this->stack ) - 1 ]; + if ( $map === array() ) { + return null; + } + + // Respect a hand-written `@var` already on the statement. + $existing = $node->getDocComment(); + if ( $existing !== null && str_contains( $existing->getText(), '@var' ) ) { + return null; + } + + $lines = array(); + foreach ( $node->vars as $var ) { + if ( ! $var instanceof Node\Expr\Variable || ! is_string( $var->name ) ) { + continue; + } + if ( isset( $map[ $var->name ] ) ) { + $lines[] = sprintf( ' * @var %s $%s', $map[ $var->name ], $var->name ); + } + } + + if ( $lines !== array() ) { + $node->setDocComment( new Doc( "/**\n" . implode( "\n", $lines ) . "\n */" ) ); + } + + return null; + } + + /** + * Pops the function-like frame on the way back up. + * + * @param Node $node The node being left. + * @return null + */ + public function leaveNode( Node $node ): ?Node { + if ( $node instanceof Node\FunctionLike ) { + array_pop( $this->stack ); + } + return null; + } + + /** + * Extracts `@global Type $varname` tags from a docblock. + * + * Handles union types (`A|B`) and namespaced/array forms (`A\B`, `A[]`). + * Whitespace inside the type is collapsed. + * + * @param string $docblock Raw docblock text including `/**` markers. + * @return array Map of variable name (no `$`) to type. + */ + private function parse_global_tags( string $docblock ): array { + $map = array(); + if ( preg_match_all( '/@global\s+(\S+(?:\s*\|\s*\S+)*)\s+\$(\w+)/', $docblock, $matches, PREG_SET_ORDER ) > 0 ) { + foreach ( $matches as $match ) { + $map[ $match[2] ] = preg_replace( '/\s+/', '', $match[1] ); + } + } + return $map; + } +} \ No newline at end of file diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index 318b9969a928d..41d88c96906b5 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -4,6 +4,14 @@ # # https://phpstan.org/config-reference +services: + # Bridges WordPress core's `@global Type $varname` function docblock convention to + # PHPStan's variable type resolution. See tests/phpstan/GlobalDocBlockVisitor.php. + - + class: WordPress\PHPStan\GlobalDocBlockVisitor + tags: + - phpstan.parser.richParserNodeVisitor + parameters: # Cache is stored locally, so it's available for CI. tmpDir: ../../.cache diff --git a/tests/phpstan/bootstrap.php b/tests/phpstan/bootstrap.php index c87a26babf83d..6eedeec93c4a7 100644 --- a/tests/phpstan/bootstrap.php +++ b/tests/phpstan/bootstrap.php @@ -1,8 +1,10 @@ Date: Thu, 30 Apr 2026 22:15:50 -0700 Subject: [PATCH 2/8] Build/Test Tools: Fix PHPStan errors in `GlobalDocBlockVisitor`. 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) --- tests/phpstan/GlobalDocBlockVisitor.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpstan/GlobalDocBlockVisitor.php b/tests/phpstan/GlobalDocBlockVisitor.php index 431d9f4b2e4ad..2e155a470cd78 100644 --- a/tests/phpstan/GlobalDocBlockVisitor.php +++ b/tests/phpstan/GlobalDocBlockVisitor.php @@ -47,7 +47,7 @@ final class GlobalDocBlockVisitor extends NodeVisitorAbstract { * Resets state at the start of each parser traversal. * * @param array $nodes Top-level nodes about to be traversed. - * @return null + * @return array|null */ public function beforeTraverse( array $nodes ): ?array { $this->stack = array(); @@ -126,7 +126,7 @@ private function parse_global_tags( string $docblock ): array { $map = array(); if ( preg_match_all( '/@global\s+(\S+(?:\s*\|\s*\S+)*)\s+\$(\w+)/', $docblock, $matches, PREG_SET_ORDER ) > 0 ) { foreach ( $matches as $match ) { - $map[ $match[2] ] = preg_replace( '/\s+/', '', $match[1] ); + $map[ $match[2] ] = (string) preg_replace( '/\s+/', '', $match[1] ); } } return $map; From 3c0d4d63631accd1a6d5acc4b7396762d7d62bf6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Apr 2026 22:39:21 -0700 Subject: [PATCH 3/8] Add missing newline at EOF --- tests/phpstan/GlobalDocBlockVisitor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpstan/GlobalDocBlockVisitor.php b/tests/phpstan/GlobalDocBlockVisitor.php index 2e155a470cd78..f36ac37286659 100644 --- a/tests/phpstan/GlobalDocBlockVisitor.php +++ b/tests/phpstan/GlobalDocBlockVisitor.php @@ -131,4 +131,4 @@ private function parse_global_tags( string $docblock ): array { } return $map; } -} \ No newline at end of file +} From 9fc75997e0ac4052874741190a91f8bef2c37a08 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Apr 2026 23:20:40 -0700 Subject: [PATCH 4/8] Build/Test Tools: Merge synthetic `@var` lines with existing globals docblock. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- tests/phpstan/GlobalDocBlockVisitor.php | 32 +++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/tests/phpstan/GlobalDocBlockVisitor.php b/tests/phpstan/GlobalDocBlockVisitor.php index f36ac37286659..647f7e605597f 100644 --- a/tests/phpstan/GlobalDocBlockVisitor.php +++ b/tests/phpstan/GlobalDocBlockVisitor.php @@ -30,6 +30,11 @@ * function docblock does not list, are left untouched and continue to * resolve as `mixed` — preserving PHPStan's safety guarantees. * + * Hand-written `@var` annotations on a `global` statement are honored + * per-variable: in `global $a, $b;`, an existing `@var Foo $a` is left + * alone, but `$b` will still receive a synthetic `@var` if the function + * documents it via `@global`. + * * Registered as `phpstan.parser.richParserNodeVisitor` in `base.neon`. */ final class GlobalDocBlockVisitor extends NodeVisitorAbstract { @@ -77,10 +82,14 @@ public function enterNode( Node $node ): ?Node { return null; } - // Respect a hand-written `@var` already on the statement. - $existing = $node->getDocComment(); - if ( $existing !== null && str_contains( $existing->getText(), '@var' ) ) { - return null; + // Collect variable names that already have a hand-written `@var` on this + // statement so we can leave them alone but still inject `@var` lines for + // the remaining variables in a multi-variable `global $a, $b;` statement. + $existing = $node->getDocComment(); + $existing_text = $existing !== null ? $existing->getText() : ''; + $already_typed = array(); + if ( $existing_text !== '' && preg_match_all( '/@(?:phpstan-)?var\s+[^\n]*?\$(\w+)/', $existing_text, $existing_matches ) > 0 ) { + $already_typed = array_flip( $existing_matches[1] ); } $lines = array(); @@ -88,13 +97,22 @@ public function enterNode( Node $node ): ?Node { if ( ! $var instanceof Node\Expr\Variable || ! is_string( $var->name ) ) { continue; } - if ( isset( $map[ $var->name ] ) ) { - $lines[] = sprintf( ' * @var %s $%s', $map[ $var->name ], $var->name ); + if ( isset( $already_typed[ $var->name ] ) || ! isset( $map[ $var->name ] ) ) { + continue; } + $lines[] = sprintf( ' * @var %s $%s', $map[ $var->name ], $var->name ); + } + + if ( $lines === array() ) { + return null; } - if ( $lines !== array() ) { + if ( $existing_text === '' ) { $node->setDocComment( new Doc( "/**\n" . implode( "\n", $lines ) . "\n */" ) ); + } else { + // Insert the new `@var` lines just before the closing `*/`. + $merged = preg_replace( '#\s*\*/\s*$#', "\n" . implode( "\n", $lines ) . "\n */", $existing_text, 1 ); + $node->setDocComment( new Doc( (string) $merged ) ); } return null; From b8694b4555010b1c27b630ae700c21b1f8f89bef Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Apr 2026 23:21:34 -0700 Subject: [PATCH 5/8] Use multiline comment --- tests/phpstan/GlobalDocBlockVisitor.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/phpstan/GlobalDocBlockVisitor.php b/tests/phpstan/GlobalDocBlockVisitor.php index 647f7e605597f..aef67460c6622 100644 --- a/tests/phpstan/GlobalDocBlockVisitor.php +++ b/tests/phpstan/GlobalDocBlockVisitor.php @@ -82,9 +82,11 @@ public function enterNode( Node $node ): ?Node { return null; } - // Collect variable names that already have a hand-written `@var` on this - // statement so we can leave them alone but still inject `@var` lines for - // the remaining variables in a multi-variable `global $a, $b;` statement. + /* + * Collect variable names that already have a handwritten `@var` on this + * statement so we can leave them alone but still inject `@var` lines for + * the remaining variables in a multi-variable `global $a, $b;` statement. + */ $existing = $node->getDocComment(); $existing_text = $existing !== null ? $existing->getText() : ''; $already_typed = array(); From fb92e2c8e3ab6ffea2f9f39ba0448c29b5fbb875 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Apr 2026 23:30:53 -0700 Subject: [PATCH 6/8] Build/Test Tools: Include `GlobalDocBlockVisitor` in PHPStan analyzed 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) --- tests/phpstan/base.neon | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index 41d88c96906b5..b790d318e110c 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -98,6 +98,7 @@ parameters: - ../../src/wp-signup.php - ../../src/wp-trackback.php - ../../src/xmlrpc.php + - GlobalDocBlockVisitor.php bootstrapFiles: - bootstrap.php scanFiles: From 24a9df9c3e11fdaa3d18f1a4c2b4797d9c43bdc3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Apr 2026 23:48:10 -0700 Subject: [PATCH 7/8] Add more specific types --- tests/phpstan/GlobalDocBlockVisitor.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/phpstan/GlobalDocBlockVisitor.php b/tests/phpstan/GlobalDocBlockVisitor.php index aef67460c6622..e012e9f7d85fe 100644 --- a/tests/phpstan/GlobalDocBlockVisitor.php +++ b/tests/phpstan/GlobalDocBlockVisitor.php @@ -44,7 +44,7 @@ final class GlobalDocBlockVisitor extends NodeVisitorAbstract { * * Each frame maps variable names (without `$`) to their declared type. * - * @var list> + * @var list> */ private array $stack = array(); @@ -140,13 +140,15 @@ public function leaveNode( Node $node ): ?Node { * Whitespace inside the type is collapsed. * * @param string $docblock Raw docblock text including `/**` markers. - * @return array Map of variable name (no `$`) to type. + * @return array Map of variable name (no `$`) to type. */ private function parse_global_tags( string $docblock ): array { $map = array(); - if ( preg_match_all( '/@global\s+(\S+(?:\s*\|\s*\S+)*)\s+\$(\w+)/', $docblock, $matches, PREG_SET_ORDER ) > 0 ) { + if ( preg_match_all( '/@global\s+(?P\S+(?:\s*\|\s*\S+)*)\s+\$(?P\w+)/', $docblock, $matches, PREG_SET_ORDER ) > 0 ) { foreach ( $matches as $match ) { - $map[ $match[2] ] = (string) preg_replace( '/\s+/', '', $match[1] ); + $type = preg_replace( '/\s+/', '', $match['type'] ); + assert( is_string( $type ) && '' !== $type ); + $map[ $match['variable'] ] = $type; } } return $map; From 38f2bf2739742e91a2bb64a2fd233d9a3613eb3d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 1 May 2026 11:56:39 -0700 Subject: [PATCH 8/8] Build/Test Tools: Simplify the `@global` type pattern in `GlobalDocBlockVisitor`. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `(\S+(?:\s*\|\s*\S+)*)` was over-engineered: it explicitly handled spaced unions like `WP_User | WP_Error` but failed to capture types with internal whitespace such as generics (`array`). `(\S.*?)` — anchored on a non-whitespace start and reluctantly matching up to the next `\s+\$\w+` — is functionally equivalent for every type form the alternation already handled, while also correctly capturing types that contain spaces. Whitespace is still collapsed before storing the type. Net effect: 12 additional `@global` tags are now recognized in the codebase. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/phpstan/GlobalDocBlockVisitor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpstan/GlobalDocBlockVisitor.php b/tests/phpstan/GlobalDocBlockVisitor.php index e012e9f7d85fe..c5fbabfd336ee 100644 --- a/tests/phpstan/GlobalDocBlockVisitor.php +++ b/tests/phpstan/GlobalDocBlockVisitor.php @@ -144,7 +144,7 @@ public function leaveNode( Node $node ): ?Node { */ private function parse_global_tags( string $docblock ): array { $map = array(); - if ( preg_match_all( '/@global\s+(?P\S+(?:\s*\|\s*\S+)*)\s+\$(?P\w+)/', $docblock, $matches, PREG_SET_ORDER ) > 0 ) { + if ( preg_match_all( '/@global\s+(?P\S.*?)\s+\$(?P\w+)/', $docblock, $matches, PREG_SET_ORDER ) > 0 ) { foreach ( $matches as $match ) { $type = preg_replace( '/\s+/', '', $match['type'] ); assert( is_string( $type ) && '' !== $type );