Skip to content

Commit 9e0b63d

Browse files
committed
Build/Test Tools: Honor @global docblock tags in PHPStan analysis.
This adds a PHPStan extension with a parser-node visitor that bridges WordPress core's `@global Type $varname` PHPDoc convention to PHPStan's variable type resolution, eliminating 3,784 spurious errors caused by globals resolving as `mixed` when on rule level 10 (out of 40,069 errors total, so a 9.4% reduction). This avoids the need to add `/** @var Type $varname */` annotations with each global variable. Developed in WordPress#11692 Props westonruter, apermo, szepeviktor. See #64898. git-svn-id: https://develop.svn.wordpress.org/trunk@62292 602fd350-edb4-49c9-b593-d223f7449a82
1 parent afea258 commit 9e0b63d

4 files changed

Lines changed: 173 additions & 1 deletion

File tree

composer.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
},
3333
"lock": false
3434
},
35+
"autoload-dev": {
36+
"psr-4": {
37+
"WordPress\\PHPStan\\": "tests/phpstan/"
38+
}
39+
},
3540
"scripts": {
3641
"phpstan": "@php ./vendor/bin/phpstan analyse --memory-limit=2G",
3742
"compat": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --standard=phpcompat.xml.dist --report=summary,source",
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<?php
2+
/**
3+
* PHPStan parser node visitor that bridges WordPress core's `@global` PHPDoc
4+
* convention to PHPStan's variable type resolution.
5+
*
6+
* @package WordPress
7+
*/
8+
9+
declare(strict_types=1);
10+
11+
namespace WordPress\PHPStan;
12+
13+
use PhpParser\Comment\Doc;
14+
use PhpParser\Node;
15+
use PhpParser\NodeVisitorAbstract;
16+
17+
/**
18+
* Reads `@global Type $varname` tags from function and method docblocks and
19+
* injects equivalent inline `@var` docblocks onto matching `global $foo;`
20+
* statements inside the function body.
21+
*
22+
* PHPStan does not consult bootstrap- or stub-declared variable types when
23+
* resolving `global $foo;` inside functions. It only honors `@var`
24+
* annotations placed directly on the `global` statement. WordPress core
25+
* documents globals with `@global` tags on function docblocks instead. This
26+
* visitor closes the gap so PHPStan can use the existing core annotations
27+
* without each `global` statement needing its own redundant `@var`.
28+
*
29+
* Functions that do not document a global, or that import a global the
30+
* function docblock does not list, are left untouched and continue to
31+
* resolve as `mixed` — preserving PHPStan's safety guarantees.
32+
*
33+
* Hand-written `@var` annotations on a `global` statement are honored
34+
* per-variable: in `global $a, $b;`, an existing `@var Foo $a` is left
35+
* alone, but `$b` will still receive a synthetic `@var` if the function
36+
* documents it via `@global`.
37+
*
38+
* Registered as `phpstan.parser.richParserNodeVisitor` in `base.neon`.
39+
*/
40+
final class GlobalDocBlockVisitor extends NodeVisitorAbstract {
41+
42+
/**
43+
* Stack of `@global` tag maps, one frame per enclosing function-like node.
44+
*
45+
* Each frame maps variable names (without `$`) to their declared type.
46+
*
47+
* @var list<array<non-empty-string, non-empty-string>>
48+
*/
49+
private array $stack = array();
50+
51+
/**
52+
* Resets state at the start of each parser traversal.
53+
*
54+
* @param array<int, Node> $nodes Top-level nodes about to be traversed.
55+
* @return array<int, Node>|null
56+
*/
57+
public function beforeTraverse( array $nodes ): ?array {
58+
$this->stack = array();
59+
return null;
60+
}
61+
62+
/**
63+
* Pushes a frame when entering a function/method, and injects synthetic
64+
* `@var` doc comments on `global` statements that match a documented tag.
65+
*
66+
* @param Node $node The node being entered.
67+
* @return null
68+
*/
69+
public function enterNode( Node $node ): ?Node {
70+
if ( $node instanceof Node\FunctionLike ) {
71+
$doc = $node->getDocComment();
72+
$this->stack[] = $doc !== null ? $this->parse_global_tags( $doc->getText() ) : array();
73+
return null;
74+
}
75+
76+
if ( ! ( $node instanceof Node\Stmt\Global_ ) || $this->stack === array() ) {
77+
return null;
78+
}
79+
80+
$map = $this->stack[ count( $this->stack ) - 1 ];
81+
if ( $map === array() ) {
82+
return null;
83+
}
84+
85+
/*
86+
* Collect variable names that already have a handwritten `@var` on this
87+
* statement so we can leave them alone but still inject `@var` lines for
88+
* the remaining variables in a multi-variable `global $a, $b;` statement.
89+
*/
90+
$existing = $node->getDocComment();
91+
$existing_text = $existing !== null ? $existing->getText() : '';
92+
$already_typed = array();
93+
if ( $existing_text !== '' && preg_match_all( '/@(?:phpstan-)?var\s+[^\n]*?\$(\w+)/', $existing_text, $existing_matches ) > 0 ) {
94+
$already_typed = array_flip( $existing_matches[1] );
95+
}
96+
97+
$lines = array();
98+
foreach ( $node->vars as $var ) {
99+
if ( ! $var instanceof Node\Expr\Variable || ! is_string( $var->name ) ) {
100+
continue;
101+
}
102+
if ( isset( $already_typed[ $var->name ] ) || ! isset( $map[ $var->name ] ) ) {
103+
continue;
104+
}
105+
$lines[] = sprintf( ' * @var %s $%s', $map[ $var->name ], $var->name );
106+
}
107+
108+
if ( $lines === array() ) {
109+
return null;
110+
}
111+
112+
if ( $existing_text === '' ) {
113+
$node->setDocComment( new Doc( "/**\n" . implode( "\n", $lines ) . "\n */" ) );
114+
} else {
115+
// Insert the new `@var` lines just before the closing `*/`.
116+
$merged = preg_replace( '#\s*\*/\s*$#', "\n" . implode( "\n", $lines ) . "\n */", $existing_text, 1 );
117+
$node->setDocComment( new Doc( (string) $merged ) );
118+
}
119+
120+
return null;
121+
}
122+
123+
/**
124+
* Pops the function-like frame on the way back up.
125+
*
126+
* @param Node $node The node being left.
127+
* @return null
128+
*/
129+
public function leaveNode( Node $node ): ?Node {
130+
if ( $node instanceof Node\FunctionLike ) {
131+
array_pop( $this->stack );
132+
}
133+
return null;
134+
}
135+
136+
/**
137+
* Extracts `@global Type $varname` tags from a docblock.
138+
*
139+
* Handles union types (`A|B`) and namespaced/array forms (`A\B`, `A[]`).
140+
* Whitespace inside the type is collapsed.
141+
*
142+
* @param string $docblock Raw docblock text including `/**` markers.
143+
* @return array<non-empty-string, non-empty-string> Map of variable name (no `$`) to type.
144+
*/
145+
private function parse_global_tags( string $docblock ): array {
146+
$map = array();
147+
if ( preg_match_all( '/@global\s+(?P<type>\S.*?)\s+\$(?P<variable>\w+)/', $docblock, $matches, PREG_SET_ORDER ) > 0 ) {
148+
foreach ( $matches as $match ) {
149+
$type = preg_replace( '/\s+/', '', $match['type'] );
150+
assert( is_string( $type ) && '' !== $type );
151+
$map[ $match['variable'] ] = $type;
152+
}
153+
}
154+
return $map;
155+
}
156+
}

tests/phpstan/base.neon

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44
#
55
# https://phpstan.org/config-reference
66

7+
services:
8+
# Bridges WordPress core's `@global Type $varname` function docblock convention to
9+
# PHPStan's variable type resolution. See tests/phpstan/GlobalDocBlockVisitor.php.
10+
-
11+
class: WordPress\PHPStan\GlobalDocBlockVisitor
12+
tags:
13+
- phpstan.parser.richParserNodeVisitor
14+
715
parameters:
816
# Cache is stored locally, so it's available for CI.
917
tmpDir: ../../.cache
@@ -90,6 +98,7 @@ parameters:
9098
- ../../src/wp-signup.php
9199
- ../../src/wp-trackback.php
92100
- ../../src/xmlrpc.php
101+
- GlobalDocBlockVisitor.php
93102
bootstrapFiles:
94103
- bootstrap.php
95104
scanFiles:

tests/phpstan/bootstrap.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
<?php
22
/**
3-
* Defines default WordPress constants for discovery.
3+
* Defines default WordPress constants for PHPStan discovery.
44
*
55
* Mocks the constant initiation that would normally happen in wp-includes/wp-settings.php.
6+
*
7+
* Loaded as a `bootstrapFile` by PHPStan; see `base.neon`.
68
*/
79

810
// wp_initial_constants()

0 commit comments

Comments
 (0)