From a7ec9c936f6f3ac5cd2419f9ce3c943ffd11630b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 29 May 2026 10:46:40 -0700 Subject: [PATCH 01/21] Build/Test Tools: Infer apply_filters() return types from hook docblocks in PHPStan. Add a PHPStan dynamic return type extension, ported and adapted from szepeviktor/phpstan-wordpress, that types the return value of apply_filters() and its variants (apply_filters_deprecated(), apply_filters_ref_array()) from the `@param` type documented in the docblock preceding the call, rather than falling back to `mixed`. Also supports WordPress core's "documented elsewhere" convention: when a hook is invoked under a `/** This filter is documented in */` reference comment, the canonical docblock is looked up from the referenced file by matching the hook name and used as if written at the call site. Wired into tests/phpstan/base.neon so it applies to both the level-0 dist configuration and the local level-10 override. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...tersDynamicFunctionReturnTypeExtension.php | 107 +++++++ tests/phpstan/HookDocBlock.php | 275 ++++++++++++++++++ tests/phpstan/HookDocsVisitor.php | 79 +++++ tests/phpstan/base.neon | 17 ++ 4 files changed, 478 insertions(+) create mode 100644 tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php create mode 100644 tests/phpstan/HookDocBlock.php create mode 100644 tests/phpstan/HookDocsVisitor.php diff --git a/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php b/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php new file mode 100644 index 0000000000000..5fd6b25ab5909 --- /dev/null +++ b/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php @@ -0,0 +1,107 @@ +hookDocBlock = $hook_doc_block; + } + + /** + * Determines whether this extension applies to the given function. + * + * @param FunctionReflection $function_reflection Function being analyzed. + * @return bool + */ + public function isFunctionSupported( FunctionReflection $function_reflection ): bool { + return in_array( + $function_reflection->getName(), + array( + 'apply_filters', + 'apply_filters_deprecated', + 'apply_filters_ref_array', + ), + true + ); + } + + /** + * Resolves the return type of the filter call from its preceding docblock. + * + * @see https://developer.wordpress.org/reference/functions/apply_filters/ + * @see https://developer.wordpress.org/reference/functions/apply_filters_deprecated/ + * @see https://developer.wordpress.org/reference/functions/apply_filters_ref_array/ + * + * @param FunctionReflection $function_reflection Function being analyzed. + * @param FuncCall $function_call The function call node. + * @param Scope $scope Analysis scope. + * @return Type + */ + public function getTypeFromFunctionCall( FunctionReflection $function_reflection, FuncCall $function_call, Scope $scope ): Type { + unset( $function_reflection ); + + $default = new MixedType(); + $resolved_php_doc = $this->hookDocBlock->getNullableHookDocBlock( $function_call, $scope ); + + if ( null === $resolved_php_doc ) { + return $default; + } + + // Fetch the `@param` values from the docblock; the first describes the filtered value. + $params = $resolved_php_doc->getParamTags(); + + foreach ( $params as $param ) { + return $param->getType(); + } + + return $default; + } +} \ No newline at end of file diff --git a/tests/phpstan/HookDocBlock.php b/tests/phpstan/HookDocBlock.php new file mode 100644 index 0000000000000..c97bb2ca340d6 --- /dev/null +++ b/tests/phpstan/HookDocBlock.php @@ -0,0 +1,275 @@ + canonical docblock text, keyed by absolute file path. + * + * @var array> + */ + private $fileHookDocs = array(); + + /** + * Constructor. + * + * @param FileTypeMapper $file_type_mapper File type mapper. + */ + public function __construct( FileTypeMapper $file_type_mapper ) { + $this->fileTypeMapper = $file_type_mapper; + } + + /** + * Resolves the docblock preceding the given function call, if any. + * + * @param FuncCall $function_call Hook function call node. + * @param Scope $scope Analysis scope. + * @return ResolvedPhpDocBlock|null Resolved docblock, or null when none precedes the call. + */ + public function getNullableHookDocBlock( FuncCall $function_call, Scope $scope ): ?ResolvedPhpDocBlock { + $comment = self::getNullableNodeComment( $function_call ); + + if ( null === $comment ) { + return null; + } + + // Fetch the docblock contents. + $code = $comment->getText(); + + // Handle the "This filter/action is documented in " convention by + // substituting the canonical docblock from the referenced file. + $referenced = $this->resolveDocumentedInReference( $code, $function_call, $scope ); + if ( null !== $referenced ) { + return $referenced; + } + + // Resolve the docblock in the current scope. + $class_reflection = $scope->getClassReflection(); + $trait_reflection = $scope->getTraitReflection(); + + return $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + ( $scope->isInClass() && null !== $class_reflection ) ? $class_reflection->getName() : null, + ( $scope->isInTrait() && null !== $trait_reflection ) ? $trait_reflection->getName() : null, + $scope->getFunctionName(), + $code + ); + } + + /** + * Resolves the canonical docblock referenced by a "This filter/action is + * documented in " comment. + * + * @param string $comment_text Raw comment text preceding the hook call. + * @param FuncCall $function_call Hook function call node. + * @param Scope $scope Analysis scope. + * @return ResolvedPhpDocBlock|null Resolved canonical docblock, or null when it cannot be located. + */ + private function resolveDocumentedInReference( string $comment_text, FuncCall $function_call, Scope $scope ): ?ResolvedPhpDocBlock { + if ( ! preg_match( '#This (?:filter|action) is documented in (\S+)#', $comment_text, $matches ) ) { + return null; + } + + $hook_name = self::getHookName( $function_call ); + if ( null === $hook_name ) { + return null; + } + + $target_file = self::resolveReferencePath( $scope->getFile(), $matches[1] ); + if ( null === $target_file ) { + return null; + } + + $doc_text = $this->getHookDocTextFromFile( $target_file, $hook_name ); + if ( null === $doc_text ) { + return null; + } + + // The canonical docblock lives in the referenced file; resolve it there so + // any `use` imports in that file are taken into account. Hook docblocks + // describe plain/global types, so class/trait/function context is omitted. + return $this->fileTypeMapper->getResolvedPhpDoc( + $target_file, + null, + null, + null, + $doc_text + ); + } + + /** + * Returns the canonical docblock text for a hook defined in the given file. + * + * @param string $file Absolute path to the file declaring the hook. + * @param string $hook_name Hook name to match. + * @return string|null Docblock text, or null when no documented invocation is found. + */ + private function getHookDocTextFromFile( string $file, string $hook_name ): ?string { + if ( ! isset( $this->fileHookDocs[ $file ] ) ) { + $this->fileHookDocs[ $file ] = self::parseHookDocs( $file ); + } + + return $this->fileHookDocs[ $file ][ $hook_name ] ?? null; + } + + /** + * Parses a file and collects the docblock text for each documented hook + * invocation it contains. + * + * @param string $file Absolute path to the file. + * @return array Map of hook name to docblock text. + */ + private static function parseHookDocs( string $file ): array { + if ( ! is_file( $file ) || ! is_readable( $file ) ) { + return array(); + } + + $code = file_get_contents( $file ); + if ( false === $code ) { + return array(); + } + + $parser = ( new ParserFactory() )->createForHostVersion(); + $stmts = $parser->parse( $code ); + if ( null === $stmts ) { + return array(); + } + + // Propagate each docblock down to the nested hook-call node by line. + $traverser = new NodeTraverser(); + $traverser->addVisitor( new HookDocsVisitor() ); + $stmts = $traverser->traverse( $stmts ); + + $docs = array(); + $calls = ( new NodeFinder() )->findInstanceOf( $stmts, FuncCall::class ); + foreach ( $calls as $call ) { + if ( ! $call instanceof FuncCall || ! $call->name instanceof \PhpParser\Node\Name ) { + continue; + } + + if ( ! in_array( $call->name->toString(), self::HOOK_FUNCTIONS, true ) ) { + continue; + } + + $hook_name = self::getHookName( $call ); + if ( null === $hook_name || isset( $docs[ $hook_name ] ) ) { + continue; + } + + $doc = $call->getAttribute( 'latestDocComment' ); + // Only treat as canonical a docblock that actually documents parameters, + // so reference comments ("documented in ...") are skipped. + if ( $doc instanceof Doc && false !== strpos( $doc->getText(), '@param' ) ) { + $docs[ $hook_name ] = $doc->getText(); + } + } + + return $docs; + } + + /** + * Resolves a WordPress-root-relative reference path against the file + * containing the reference comment. + * + * @param string $current_file Absolute path to the file with the reference comment. + * @param string $reference_path Root-relative path (e.g. "wp-includes/media.php"). + * @return string|null Absolute path to the referenced file, or null when the root cannot be determined. + */ + private static function resolveReferencePath( string $current_file, string $reference_path ): ?string { + $reference_path = ltrim( $reference_path, '/' ); + + foreach ( array( '/wp-includes/', '/wp-admin/' ) as $needle ) { + $pos = strpos( $current_file, $needle ); + if ( false !== $pos ) { + return substr( $current_file, 0, $pos ) . '/' . $reference_path; + } + } + + return null; + } + + /** + * Returns the hook name (first string argument) of a hook function call. + * + * @param FuncCall $call Hook function call node. + * @return string|null Hook name, or null when it is not a string literal. + */ + private static function getHookName( FuncCall $call ): ?string { + $args = $call->getArgs(); + if ( ! isset( $args[0] ) ) { + return null; + } + + $value = $args[0]->value; + + return $value instanceof String_ ? $value->value : null; + } + + /** + * Returns the docblock attached to the node by HookDocsVisitor, if present. + * + * @param FuncCall $node Function call node. + * @return Doc|null + */ + private static function getNullableNodeComment( FuncCall $node ): ?Doc { + /** @var \PhpParser\Comment\Doc|null $doc */ + $doc = $node->getAttribute( 'latestDocComment' ); + return $doc; + } +} \ No newline at end of file diff --git a/tests/phpstan/HookDocsVisitor.php b/tests/phpstan/HookDocsVisitor.php new file mode 100644 index 0000000000000..0f0458bab931d --- /dev/null +++ b/tests/phpstan/HookDocsVisitor.php @@ -0,0 +1,79 @@ +latestStartLine = null; + $this->latestDocComment = null; + + return null; + } + + /** + * Tracks the latest docblock and attaches it to the current node. + * + * @param Node $node Node being entered. + * @return Node|null + */ + public function enterNode( Node $node ): ?Node { + if ( $node->getStartLine() !== $this->latestStartLine ) { + $this->latestDocComment = null; + } + + $this->latestStartLine = $node->getStartLine(); + + $doc = $node->getDocComment(); + + if ( null !== $doc ) { + $this->latestDocComment = $doc; + } + + $node->setAttribute( 'latestDocComment', $this->latestDocComment ); + + return null; + } +} \ No newline at end of file diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index b790d318e110c..1fbe082f30089 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -12,6 +12,20 @@ services: tags: - phpstan.parser.richParserNodeVisitor + # Types the return value of apply_filters() (and variants) from the `@param` + # type in the docblock preceding the call, including the "This filter is + # documented in " convention. Adapted from szepeviktor/phpstan-wordpress. + - + class: WordPress\PHPStan\HookDocsVisitor + tags: + - phpstan.parser.richParserNodeVisitor + - + class: WordPress\PHPStan\HookDocBlock + - + class: WordPress\PHPStan\ApplyFiltersDynamicFunctionReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + parameters: # Cache is stored locally, so it's available for CI. tmpDir: ../../.cache @@ -99,6 +113,9 @@ parameters: - ../../src/wp-trackback.php - ../../src/xmlrpc.php - GlobalDocBlockVisitor.php + - HookDocsVisitor.php + - HookDocBlock.php + - ApplyFiltersDynamicFunctionReturnTypeExtension.php bootstrapFiles: - bootstrap.php scanFiles: From 260254d5e3ffb4ae3eea0a03c4c206df73211266 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 29 May 2026 14:40:53 -0700 Subject: [PATCH 02/21] Bundled Themes: Add hook documentation references. Document the hook invocations that were missing inline documentation, using the standard "This filter is documented in " reference comments per the WordPress inline documentation standards: - the_permalink in Twenty Thirteen and Twenty Fifteen link-format helpers. - widget_title in the Twenty Fourteen ephemera widget. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-content/themes/twentyfifteen/inc/template-tags.php | 1 + src/wp-content/themes/twentyfourteen/inc/widgets.php | 3 ++- src/wp-content/themes/twentythirteen/functions.php | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/wp-content/themes/twentyfifteen/inc/template-tags.php b/src/wp-content/themes/twentyfifteen/inc/template-tags.php index 7f39cdc194a7c..057c39bf1cbd8 100644 --- a/src/wp-content/themes/twentyfifteen/inc/template-tags.php +++ b/src/wp-content/themes/twentyfifteen/inc/template-tags.php @@ -246,6 +246,7 @@ function twentyfifteen_post_thumbnail() { function twentyfifteen_get_link_url() { $has_url = get_url_in_content( get_the_content() ); + /** This filter is documented in wp-includes/link-template.php */ return $has_url ? $has_url : apply_filters( 'the_permalink', get_permalink() ); } endif; diff --git a/src/wp-content/themes/twentyfourteen/inc/widgets.php b/src/wp-content/themes/twentyfourteen/inc/widgets.php index 7c4f237294b3a..93099318d05f6 100644 --- a/src/wp-content/themes/twentyfourteen/inc/widgets.php +++ b/src/wp-content/themes/twentyfourteen/inc/widgets.php @@ -110,7 +110,8 @@ public function widget( $args, $instance ) { $number = ! empty( $instance['number'] ) ? absint( $instance['number'] ) : 2; $title = ! empty( $instance['title'] ) ? $instance['title'] : $format_string; - $title = apply_filters( 'widget_title', $title, $instance, $this->id_base ); + /** This filter is documented in wp-includes/widgets/class-wp-widget-pages.php */ + $title = apply_filters( 'widget_title', $title, $instance, $this->id_base ); $ephemera = new WP_Query( array( diff --git a/src/wp-content/themes/twentythirteen/functions.php b/src/wp-content/themes/twentythirteen/functions.php index d59a1989eaaab..8c14defb4896e 100644 --- a/src/wp-content/themes/twentythirteen/functions.php +++ b/src/wp-content/themes/twentythirteen/functions.php @@ -731,6 +731,7 @@ function twentythirteen_get_link_url() { $content = get_the_content(); $has_url = get_url_in_content( $content ); + /** This filter is documented in wp-includes/link-template.php */ return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink() ); } From bd260a097ccdfadfa52f2806ee1b1ccdb627c757 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 29 May 2026 14:42:11 -0700 Subject: [PATCH 03/21] Docs: Document hook invocations in XML-RPC and the Abilities API. - In WP_XMLRPC_Server::set_is_enabled(), the back-compat shim applies the pre_option_enable_xmlrpc and option_enable_xmlrpc filters directly. Add the "This filter is documented in wp-includes/option.php" references and pass the option name (and default value) so the calls match the documented signatures of those dynamic option filters. - Move the wp_pre_execute_ability docblock in WP_Ability so it immediately precedes the apply_filters() call, rather than being separated from it by the sentinel assignment. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/abilities-api/class-wp-ability.php | 5 +++-- src/wp-includes/class-wp-xmlrpc-server.php | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index fd1eefc1534b0..651f2e63df046 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -747,6 +747,8 @@ public function execute( $input = null ) { */ do_action( 'wp_ability_invoked', $this->name, $input, $this ); + $pre_execute_sentinel = new WP_Filter_Sentinel(); + /** * Filters whether to short-circuit ability execution. * @@ -769,8 +771,7 @@ public function execute( $input = null ) { * @param mixed $input The raw input passed to `execute()`. * @param WP_Ability $ability The ability instance. */ - $pre_execute_sentinel = new WP_Filter_Sentinel(); - $pre = apply_filters( 'wp_pre_execute_ability', $pre_execute_sentinel, $this->name, $input, $this ); + $pre = apply_filters( 'wp_pre_execute_ability', $pre_execute_sentinel, $this->name, $input, $this ); if ( $pre !== $pre_execute_sentinel ) { return $pre; } diff --git a/src/wp-includes/class-wp-xmlrpc-server.php b/src/wp-includes/class-wp-xmlrpc-server.php index 8cbf6d977f5a2..e38cddb8587f4 100644 --- a/src/wp-includes/class-wp-xmlrpc-server.php +++ b/src/wp-includes/class-wp-xmlrpc-server.php @@ -191,9 +191,11 @@ private function set_is_enabled() { * Respect old get_option() filters left for back-compat when the 'enable_xmlrpc' * option was deprecated in 3.5.0. Use the {@see 'xmlrpc_enabled'} hook instead. */ - $is_enabled = apply_filters( 'pre_option_enable_xmlrpc', false ); + /** This filter is documented in wp-includes/option.php */ + $is_enabled = apply_filters( 'pre_option_enable_xmlrpc', false, 'enable_xmlrpc', false ); if ( false === $is_enabled ) { - $is_enabled = apply_filters( 'option_enable_xmlrpc', true ); + /** This filter is documented in wp-includes/option.php */ + $is_enabled = apply_filters( 'option_enable_xmlrpc', true, 'enable_xmlrpc' ); } /** From 8bc73ae9b016e615ec334129753d93e35a0022b2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 29 May 2026 14:42:44 -0700 Subject: [PATCH 04/21] Build/Test Tools: Enforce hook documentation and resolve "documented elsewhere" references in PHPStan. Build on the apply_filters() return-type extension with hook-documentation support: - Add HookDocumentationRule, reporting any apply_filters()/do_action() (and variants) invocation that is not preceded by a documenting docblock or a valid "This filter is documented in " reference. References whose target file is missing, or that do not actually document the hook, are reported too. Calls whose hook name carries no literal text (e.g. the generic *_ref_array( $hook, $args ) forwarders) are exempt. - Resolve the "documented elsewhere" convention when typing apply_filters(), matching dynamic canonical hook names (e.g. "{$type}_template_hierarchy") against the literal name used at the referencing call site. - Rework HookDocsVisitor to follow the documentation standard: a docblock is captured from any node and propagated to the hook call it documents, including hooks inside a multi-line condition or used as an array element value, and is cleared at statement boundaries. - Register the rule and exclude the Gutenberg-generated src/wp-includes/build tree from analysis. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/phpstan/HookDocBlock.php | 293 +++++++++++++++++++++--- tests/phpstan/HookDocsVisitor.php | 46 ++-- tests/phpstan/HookDocumentationRule.php | 146 ++++++++++++ tests/phpstan/base.neon | 10 + 4 files changed, 439 insertions(+), 56 deletions(-) create mode 100644 tests/phpstan/HookDocumentationRule.php diff --git a/tests/phpstan/HookDocBlock.php b/tests/phpstan/HookDocBlock.php index c97bb2ca340d6..a781280c00b4e 100644 --- a/tests/phpstan/HookDocBlock.php +++ b/tests/phpstan/HookDocBlock.php @@ -11,6 +11,8 @@ * * In that case the canonical docblock is looked up from the referenced file by * matching the hook name, and used as if it had been written at the call site. + * Dynamic canonical hook names (e.g. `"{$type}_template_hierarchy"`) are matched + * against the literal name used at the referencing site. * * Adapted from szepeviktor/phpstan-wordpress (HookDocBlock): * https://github.com/szepeviktor/phpstan-wordpress/blob/master/src/HookDocBlock.php @@ -23,7 +25,12 @@ namespace WordPress\PHPStan; use PhpParser\Comment\Doc; +use PhpParser\Node\Expr; +use PhpParser\Node\Expr\BinaryOp\Concat; use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\InterpolatedStringPart; +use PhpParser\Node\Name; +use PhpParser\Node\Scalar\InterpolatedString; use PhpParser\Node\Scalar\String_; use PhpParser\NodeFinder; use PhpParser\NodeTraverser; @@ -37,13 +44,15 @@ * docblock resolution machinery. * * @see HookDocsVisitor + * + * @phpstan-type HookDocs array{exact: array, patterns: list} */ class HookDocBlock { /** * Hook functions that carry a documenting docblock for their first argument. */ - private const HOOK_FUNCTIONS = array( + public const HOOK_FUNCTIONS = array( 'apply_filters', 'apply_filters_deprecated', 'apply_filters_ref_array', @@ -52,6 +61,22 @@ class HookDocBlock { 'do_action_ref_array', ); + /** + * Problem code: the referenced file does not exist. + */ + public const PROBLEM_FILE_MISSING = 'fileMissing'; + + /** + * Problem code: the hook is not documented in the referenced file. + */ + public const PROBLEM_HOOK_MISSING = 'hookMissing'; + + /** + * Pattern matching WordPress core's "documented elsewhere" reference comment. + * Captures the referenced root-relative file path. + */ + private const REFERENCE_PATTERN = '#This (?:filter|action) is documented in (\S+)#'; + /** * File type mapper used to resolve docblocks in scope. * @@ -60,9 +85,9 @@ class HookDocBlock { protected $fileTypeMapper; /** - * Cache of hook-name => canonical docblock text, keyed by absolute file path. + * Cache of parsed hook documentation, keyed by absolute file path. * - * @var array> + * @var array */ private $fileHookDocs = array(); @@ -112,6 +137,107 @@ public function getNullableHookDocBlock( FuncCall $function_call, Scope $scope ) ); } + /** + * Returns the docblock preceding a hook call, classifying it as either an + * inline docblock or a "documented elsewhere" reference. + * + * @param FuncCall $function_call Hook function call node. + * @return array{type: 'inline'|'reference', text: string}|null + * Null when no docblock precedes the call. + */ + public function getPrecedingDocBlock( FuncCall $function_call ): ?array { + $comment = self::getNullableNodeComment( $function_call ); + if ( null === $comment ) { + return null; + } + + $text = $comment->getText(); + + return array( + 'type' => preg_match( self::REFERENCE_PATTERN, $text ) ? 'reference' : 'inline', + 'text' => $text, + ); + } + + /** + * Determines whether a hook call's name can be identified well enough to + * require or locate documentation. + * + * Calls whose hook name carries no literal text (e.g. the generic + * `apply_filters_ref_array( $hook_name, $args )` forwarders in plugin.php) + * cannot be meaningfully documented at the call site and are excluded. + * + * @param FuncCall $function_call Hook function call node. + * @return bool + */ + public static function hasIdentifiableHookName( FuncCall $function_call ): bool { + $args = $function_call->getArgs(); + if ( ! isset( $args[0] ) ) { + return false; + } + + $value = $args[0]->value; + if ( $value instanceof String_ ) { + return true; + } + + return null !== self::buildHookNameRegex( $value ); + } + + /** + * Validates a "documented elsewhere" reference comment preceding a hook call. + * + * Returns null when the comment is not such a reference, when the hook name is + * not a literal, when the WordPress root cannot be determined, or when the + * reference is valid. Otherwise returns the problem details. + * + * @param FuncCall $function_call Hook function call node. + * @param Scope $scope Analysis scope. + * @return array{path: string, hook: string, problem: string}|null + */ + public function getReferenceProblem( FuncCall $function_call, Scope $scope ): ?array { + $comment = self::getNullableNodeComment( $function_call ); + if ( null === $comment ) { + return null; + } + + if ( ! preg_match( self::REFERENCE_PATTERN, $comment->getText(), $matches ) ) { + return null; + } + + $hook_name = self::getHookName( $function_call ); + if ( null === $hook_name ) { + return null; + } + + $reference_path = $matches[1]; + $target_file = self::resolveReferencePath( $scope->getFile(), $reference_path ); + + // The WordPress root could not be determined from the current file path; + // skip rather than report a false positive. + if ( null === $target_file ) { + return null; + } + + if ( ! is_file( $target_file ) ) { + return array( + 'path' => $reference_path, + 'hook' => $hook_name, + 'problem' => self::PROBLEM_FILE_MISSING, + ); + } + + if ( null === $this->getHookDocTextFromFile( $target_file, $hook_name ) ) { + return array( + 'path' => $reference_path, + 'hook' => $hook_name, + 'problem' => self::PROBLEM_HOOK_MISSING, + ); + } + + return null; + } + /** * Resolves the canonical docblock referenced by a "This filter/action is * documented in " comment. @@ -122,7 +248,7 @@ public function getNullableHookDocBlock( FuncCall $function_call, Scope $scope ) * @return ResolvedPhpDocBlock|null Resolved canonical docblock, or null when it cannot be located. */ private function resolveDocumentedInReference( string $comment_text, FuncCall $function_call, Scope $scope ): ?ResolvedPhpDocBlock { - if ( ! preg_match( '#This (?:filter|action) is documented in (\S+)#', $comment_text, $matches ) ) { + if ( ! preg_match( self::REFERENCE_PATTERN, $comment_text, $matches ) ) { return null; } @@ -141,20 +267,17 @@ private function resolveDocumentedInReference( string $comment_text, FuncCall $f return null; } - // The canonical docblock lives in the referenced file; resolve it there so - // any `use` imports in that file are taken into account. Hook docblocks - // describe plain/global types, so class/trait/function context is omitted. - return $this->fileTypeMapper->getResolvedPhpDoc( - $target_file, - null, - null, - null, - $doc_text - ); + // Resolve the canonical docblock in the global namespace, with no file + // context. Hook docblocks describe global/plain types (e.g. string[], + // WP_REST_Response), so the referenced file's `use` imports are not needed. + // Passing the referenced file here would also re-enter PHPStan's name-scope + // builder while that file is itself being analysed, which makes + // getResolvedPhpDoc return an empty docblock (NameScopeAlreadyBeingCreated). + return $this->fileTypeMapper->getResolvedPhpDoc( null, null, null, null, $doc_text ); } /** - * Returns the canonical docblock text for a hook defined in the given file. + * Returns the canonical docblock text for a hook documented in the given file. * * @param string $file Absolute path to the file declaring the hook. * @param string $hook_name Hook name to match. @@ -165,41 +288,64 @@ private function getHookDocTextFromFile( string $file, string $hook_name ): ?str $this->fileHookDocs[ $file ] = self::parseHookDocs( $file ); } - return $this->fileHookDocs[ $file ][ $hook_name ] ?? null; + $docs = $this->fileHookDocs[ $file ]; + + if ( isset( $docs['exact'][ $hook_name ] ) ) { + return $docs['exact'][ $hook_name ]; + } + + // Fall back to dynamic canonical hook names (e.g. "{$type}_foo"). + foreach ( $docs['patterns'] as $pattern ) { + if ( preg_match( $pattern['regex'], $hook_name ) ) { + return $pattern['text']; + } + } + + return null; } /** - * Parses a file and collects the docblock text for each documented hook - * invocation it contains. + * Parses a file and collects the canonical docblock text for each hook + * invocation it documents. + * + * A docblock is treated as canonical when it is not itself a "documented + * elsewhere" reference, so referencing call sites do not count as the source + * of documentation. Hooks with a literal name are indexed exactly; hooks with + * a dynamic name that contains literal text are indexed as a regex. * * @param string $file Absolute path to the file. - * @return array Map of hook name to docblock text. + * @return HookDocs */ private static function parseHookDocs( string $file ): array { + $docs = array( + 'exact' => array(), + 'patterns' => array(), + ); + if ( ! is_file( $file ) || ! is_readable( $file ) ) { - return array(); + return $docs; } $code = file_get_contents( $file ); if ( false === $code ) { - return array(); + return $docs; } $parser = ( new ParserFactory() )->createForHostVersion(); $stmts = $parser->parse( $code ); if ( null === $stmts ) { - return array(); + return $docs; } - // Propagate each docblock down to the nested hook-call node by line. + // Propagate each docblock down to the nested hook-call node. $traverser = new NodeTraverser(); $traverser->addVisitor( new HookDocsVisitor() ); $stmts = $traverser->traverse( $stmts ); - $docs = array(); + $seen = array(); $calls = ( new NodeFinder() )->findInstanceOf( $stmts, FuncCall::class ); foreach ( $calls as $call ) { - if ( ! $call instanceof FuncCall || ! $call->name instanceof \PhpParser\Node\Name ) { + if ( ! $call instanceof FuncCall || ! $call->name instanceof Name ) { continue; } @@ -207,22 +353,94 @@ private static function parseHookDocs( string $file ): array { continue; } - $hook_name = self::getHookName( $call ); - if ( null === $hook_name || isset( $docs[ $hook_name ] ) ) { + $args = $call->getArgs(); + if ( ! isset( $args[0] ) ) { continue; } $doc = $call->getAttribute( 'latestDocComment' ); - // Only treat as canonical a docblock that actually documents parameters, - // so reference comments ("documented in ...") are skipped. - if ( $doc instanceof Doc && false !== strpos( $doc->getText(), '@param' ) ) { - $docs[ $hook_name ] = $doc->getText(); + + // Skip reference comments so only the canonical documentation counts. + if ( ! $doc instanceof Doc || preg_match( self::REFERENCE_PATTERN, $doc->getText() ) ) { + continue; + } + + $name_expr = $args[0]->value; + + if ( $name_expr instanceof String_ ) { + if ( ! isset( $docs['exact'][ $name_expr->value ] ) ) { + $docs['exact'][ $name_expr->value ] = $doc->getText(); + } + continue; + } + + $regex = self::buildHookNameRegex( $name_expr ); + if ( null !== $regex && ! isset( $seen[ $regex ] ) ) { + $seen[ $regex ] = true; + $docs['patterns'][] = array( + 'regex' => $regex, + 'text' => $doc->getText(), + ); } } return $docs; } + /** + * Builds an anchored regex matching a dynamic hook name expression, or null + * when the expression carries no literal text to anchor on. + * + * @param Expr $expr Hook name expression. + * @return string|null + */ + private static function buildHookNameRegex( Expr $expr ): ?string { + $parts = self::hookNameRegexParts( $expr ); + if ( null === $parts || ! $parts[1] ) { + return null; + } + + return '#^' . $parts[0] . '$#'; + } + + /** + * Recursively converts a hook name expression into a regex fragment. + * + * @param Expr $expr Hook name expression. + * @return array{0: string, 1: bool}|null Fragment and whether it contains literal text, or null if unsupported. + */ + private static function hookNameRegexParts( Expr $expr ): ?array { + if ( $expr instanceof String_ ) { + return array( preg_quote( $expr->value, '#' ), true ); + } + + if ( $expr instanceof Concat ) { + $left = self::hookNameRegexParts( $expr->left ); + $right = self::hookNameRegexParts( $expr->right ); + if ( null === $left || null === $right ) { + return null; + } + return array( $left[0] . $right[0], $left[1] || $right[1] ); + } + + if ( $expr instanceof InterpolatedString ) { + $fragment = ''; + $has_literal = false; + foreach ( $expr->parts as $part ) { + if ( $part instanceof InterpolatedStringPart ) { + $fragment .= preg_quote( $part->value, '#' ); + $has_literal = true; + } else { + $fragment .= '.+'; + } + } + return array( $fragment, $has_literal ); + } + + // Variables, property fetches, etc.: a wildcard with no literal anchor. + return array( '.+', false ); + } + /** * Resolves a WordPress-root-relative reference path against the file * containing the reference comment. @@ -234,14 +452,21 @@ private static function parseHookDocs( string $file ): array { private static function resolveReferencePath( string $current_file, string $reference_path ): ?string { $reference_path = ltrim( $reference_path, '/' ); + // Locate the earliest top-level WordPress directory in the current path; + // everything before it is the WordPress root. + $root_end = null; foreach ( array( '/wp-includes/', '/wp-admin/' ) as $needle ) { $pos = strpos( $current_file, $needle ); - if ( false !== $pos ) { - return substr( $current_file, 0, $pos ) . '/' . $reference_path; + if ( false !== $pos && ( null === $root_end || $pos < $root_end ) ) { + $root_end = $pos; } } - return null; + if ( null === $root_end ) { + return null; + } + + return substr( $current_file, 0, $root_end ) . '/' . $reference_path; } /** diff --git a/tests/phpstan/HookDocsVisitor.php b/tests/phpstan/HookDocsVisitor.php index 0f0458bab931d..163964e485e35 100644 --- a/tests/phpstan/HookDocsVisitor.php +++ b/tests/phpstan/HookDocsVisitor.php @@ -1,8 +1,18 @@ latestStartLine = null; $this->latestDocComment = null; return null; } /** - * Tracks the latest docblock and attaches it to the current node. + * Tracks the applicable docblock and attaches it to the node. * * @param Node $node Node being entered. * @return Node|null */ public function enterNode( Node $node ): ?Node { - if ( $node->getStartLine() !== $this->latestStartLine ) { - $this->latestDocComment = null; - } - - $this->latestStartLine = $node->getStartLine(); - $doc = $node->getDocComment(); if ( null !== $doc ) { + // A docblock here documents this node and everything nested within it. $this->latestDocComment = $doc; + } elseif ( $node instanceof Stmt ) { + // A new statement without its own docblock ends the reach of the + // previous one. Array items are intentionally not reset here so that a + // statement-level docblock can still document a hook nested in an array. + $this->latestDocComment = null; } $node->setAttribute( 'latestDocComment', $this->latestDocComment ); diff --git a/tests/phpstan/HookDocumentationRule.php b/tests/phpstan/HookDocumentationRule.php new file mode 100644 index 0000000000000..a89d06e242204 --- /dev/null +++ b/tests/phpstan/HookDocumentationRule.php @@ -0,0 +1,146 @@ + *\/` reference comment. + * + * When a reference comment is used, the referenced file must exist and must + * actually document a hook of the same name; otherwise an error is reported. + * + * @package WordPress + */ + +declare(strict_types=1); + +namespace WordPress\PHPStan; + +use PhpParser\Node; +use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Name; +use PHPStan\Analyser\Scope; +use PHPStan\Rules\Rule; +use PHPStan\Rules\RuleErrorBuilder; + +/** + * Reports undocumented hooks and broken "documented elsewhere" references. + * + * @implements Rule + */ +class HookDocumentationRule implements Rule { + + /** + * Hook docblock resolver. + * + * @var \WordPress\PHPStan\HookDocBlock + */ + private $hookDocBlock; + + /** + * Constructor. + * + * @param HookDocBlock $hook_doc_block Hook docblock resolver. + */ + public function __construct( HookDocBlock $hook_doc_block ) { + $this->hookDocBlock = $hook_doc_block; + } + + /** + * Returns the node type this rule processes. + * + * @return string + */ + public function getNodeType(): string { + return FuncCall::class; + } + + /** + * Processes a function call node. + * + * @param Node $node Function call node. + * @param Scope $scope Analysis scope. + * @return list<\PHPStan\Rules\IdentifierRuleError> + */ + public function processNode( Node $node, Scope $scope ): array { + if ( ! $node instanceof FuncCall || ! $node->name instanceof Name ) { + return array(); + } + + if ( ! in_array( $node->name->toString(), HookDocBlock::HOOK_FUNCTIONS, true ) ) { + return array(); + } + + // Skip calls whose hook name carries no literal text, i.e. a bare variable + // such as the generic apply_filters_ref_array( $hook_name, $args ) + // re-dispatch in plugin.php. There is no concrete hook to document or look + // up. Calls naming a hook literally (e.g. apply_filters_ref_array( 'the_posts', + // ... )) or dynamically with literal text (e.g. "{$type}_template_hierarchy") + // remain subject to the documentation requirement. + if ( ! HookDocBlock::hasIdentifiableHookName( $node ) ) { + return array(); + } + + $function_name = $node->name->toString(); + $doc_block = $this->hookDocBlock->getPrecedingDocBlock( $node ); + + // No preceding docblock at all: the hook is undocumented. + if ( null === $doc_block ) { + return array( + RuleErrorBuilder::message( + sprintf( + '%s() call is not preceded by a docblock documenting the hook, nor by a "This filter is documented in " reference comment.', + $function_name + ) + ) + ->identifier( 'wordpress.hookDocMissing' ) + ->line( $node->getStartLine() ) + ->build(), + ); + } + + // An inline docblock documents the hook in place; nothing more to check. + if ( 'reference' !== $doc_block['type'] ) { + return array(); + } + + // A reference comment must point at a file that documents this hook. + $problem = $this->hookDocBlock->getReferenceProblem( $node, $scope ); + if ( null === $problem ) { + return array(); + } + + if ( HookDocBlock::PROBLEM_FILE_MISSING === $problem['problem'] ) { + return array( + RuleErrorBuilder::message( + sprintf( + '%s() call for hook "%s" references documentation in "%s", but that file does not exist.', + $function_name, + $problem['hook'], + $problem['path'] + ) + ) + ->identifier( 'wordpress.hookDocReferenceFileMissing' ) + ->line( $node->getStartLine() ) + ->build(), + ); + } + + return array( + RuleErrorBuilder::message( + sprintf( + '%s() call for hook "%s" references documentation in "%s", but no documented "%s" hook is found there.', + $function_name, + $problem['hook'], + $problem['path'], + $problem['hook'] + ) + ) + ->identifier( 'wordpress.hookDocReferenceHookMissing' ) + ->line( $node->getStartLine() ) + ->build(), + ); + } +} \ No newline at end of file diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index 1fbe082f30089..3746a167ce675 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -26,6 +26,13 @@ services: tags: - phpstan.broker.dynamicFunctionReturnTypeExtension + # Enforces that every hook invocation is preceded by a documenting docblock or + # a valid "This filter is documented in " reference comment. + - + class: WordPress\PHPStan\HookDocumentationRule + tags: + - phpstan.rules.rule + parameters: # Cache is stored locally, so it's available for CI. tmpDir: ../../.cache @@ -116,6 +123,7 @@ parameters: - HookDocsVisitor.php - HookDocBlock.php - ApplyFiltersDynamicFunctionReturnTypeExtension.php + - HookDocumentationRule.php bootstrapFiles: - bootstrap.php scanFiles: @@ -133,6 +141,8 @@ parameters: - ../../src/wp-includes/pluggable-deprecated.php # These files are autogenerated by tools/gutenberg/copy.js. - ../../src/wp-includes/blocks + # Generated output from the Gutenberg plugin's wp-build templates. + - ../../src/wp-includes/build # Third-party libraries. - ../../src/wp-admin/includes/class-ftp-pure.php - ../../src/wp-admin/includes/class-ftp-sockets.php From a0ec688d236c714b23470d03021c0b8b73f4549c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 29 May 2026 15:29:40 -0700 Subject: [PATCH 05/21] Improve array documentation for wp_get_image_editor_output_format() and filter --- src/wp-includes/media.php | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index d318a275a9607..8596c7f7d0e6d 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -6413,7 +6413,7 @@ function wp_high_priority_element_flag( $value = null ): bool { * * @param string $filename Path to the image. * @param string $mime_type The source image mime type. - * @return string[] An array of mime type mappings. + * @return array An array of mime type mappings. */ function wp_get_image_editor_output_format( $filename, $mime_type ) { $output_format = array( @@ -6435,14 +6435,10 @@ function wp_get_image_editor_output_format( $filename, $mime_type ) { * @since 6.7.0 The default was changed from an empty array to an array * containing the HEIC/HEIF images mime types. * - * @param string[] $output_format { - * An array of mime type mappings. Maps a source mime type to a new - * destination mime type. By default maps HEIC/HEIF input to JPEG output. - * - * @type string ...$0 The new mime type. - * } - * @param string $filename Path to the image. - * @param string $mime_type The source image mime type. + * @param array $output_format An array of mime type mappings. Maps a source mime type to a new + * destination mime type. By default maps HEIC/HEIF input to JPEG output. + * @param string $filename Path to the image. + * @param string $mime_type The source image mime type. */ return apply_filters( 'image_editor_output_format', $output_format, $filename, $mime_type ); } From b77d90374ac324ae118a36e8ace4cdb7d89fe4c5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 29 May 2026 17:37:21 -0700 Subject: [PATCH 06/21] Bundled Themes: Pass the post to the the_permalink filter. The the_permalink filter is documented (in wp-includes/link-template.php) as receiving the permalink and the post, but the Twenty Eleven, Twenty Thirteen, and Twenty Fifteen link-format helpers fired it with only the permalink. Pass get_post() as the second argument so callbacks receive the documented post argument, matching the canonical the_permalink invocation. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-content/themes/twentyeleven/functions.php | 2 +- src/wp-content/themes/twentyfifteen/inc/template-tags.php | 2 +- src/wp-content/themes/twentythirteen/functions.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-content/themes/twentyeleven/functions.php b/src/wp-content/themes/twentyeleven/functions.php index 900c1f2cf23c0..98f8bd0a73ceb 100644 --- a/src/wp-content/themes/twentyeleven/functions.php +++ b/src/wp-content/themes/twentyeleven/functions.php @@ -685,7 +685,7 @@ function twentyeleven_get_first_url() { } /** This filter is documented in wp-includes/link-template.php */ - return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink() ); + return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink(), get_post() ); } /** diff --git a/src/wp-content/themes/twentyfifteen/inc/template-tags.php b/src/wp-content/themes/twentyfifteen/inc/template-tags.php index 057c39bf1cbd8..f77e13250965f 100644 --- a/src/wp-content/themes/twentyfifteen/inc/template-tags.php +++ b/src/wp-content/themes/twentyfifteen/inc/template-tags.php @@ -247,7 +247,7 @@ function twentyfifteen_get_link_url() { $has_url = get_url_in_content( get_the_content() ); /** This filter is documented in wp-includes/link-template.php */ - return $has_url ? $has_url : apply_filters( 'the_permalink', get_permalink() ); + return $has_url ? $has_url : apply_filters( 'the_permalink', get_permalink(), get_post() ); } endif; diff --git a/src/wp-content/themes/twentythirteen/functions.php b/src/wp-content/themes/twentythirteen/functions.php index 8c14defb4896e..c7b48ba06aaec 100644 --- a/src/wp-content/themes/twentythirteen/functions.php +++ b/src/wp-content/themes/twentythirteen/functions.php @@ -732,7 +732,7 @@ function twentythirteen_get_link_url() { $has_url = get_url_in_content( $content ); /** This filter is documented in wp-includes/link-template.php */ - return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink() ); + return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink(), get_post() ); } if ( ! function_exists( 'twentythirteen_excerpt_more' ) && ! is_admin() ) : From dcfd5488eaf84c0e2779144a62f9ae6ae86f8f6a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 29 May 2026 17:39:46 -0700 Subject: [PATCH 07/21] Bundled Themes: Fix hook parameter documentation. Add the missing parameter name to the `@param` tag documenting several theme-specific filters (twentyeleven_author_bio_avatar_size, twentyeleven_status_avatar, twentyeleven_attachment_size, tag_archive_meta, twentyten_header_image_width, twentyten_header_image_height, and twentytwentyone_html_classes), which previously read e.g. "@param int The..." with no `$variable`. Also correct the Twenty Eleven Ephemera widget's widget_title reference, which pointed at the obsolete wp-includes/default-widgets.php, to the canonical wp-includes/widgets/class-wp-widget-pages.php. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-content/themes/twentyeleven/author.php | 2 +- src/wp-content/themes/twentyeleven/content-status.php | 2 +- src/wp-content/themes/twentyeleven/image.php | 2 +- src/wp-content/themes/twentyeleven/inc/widgets.php | 2 +- src/wp-content/themes/twentyeleven/tag.php | 2 +- src/wp-content/themes/twentyten/functions.php | 4 ++-- src/wp-content/themes/twentytwentyone/functions.php | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/wp-content/themes/twentyeleven/author.php b/src/wp-content/themes/twentyeleven/author.php index ad2117324f571..23ca4310c1317 100644 --- a/src/wp-content/themes/twentyeleven/author.php +++ b/src/wp-content/themes/twentyeleven/author.php @@ -57,7 +57,7 @@ * * @since Twenty Eleven 1.0 * - * @param int The height and width avatar dimension in pixels. Default 60. + * @param int $size The height and width avatar dimension in pixels. Default 60. */ $author_bio_avatar_size = apply_filters( 'twentyeleven_author_bio_avatar_size', 60 ); echo get_avatar( get_the_author_meta( 'user_email' ), $author_bio_avatar_size ); diff --git a/src/wp-content/themes/twentyeleven/content-status.php b/src/wp-content/themes/twentyeleven/content-status.php index 15484232cd0d6..c76760b321111 100644 --- a/src/wp-content/themes/twentyeleven/content-status.php +++ b/src/wp-content/themes/twentyeleven/content-status.php @@ -39,7 +39,7 @@ * * @since Twenty Eleven 1.0 * - * @param int The height and width avatar dimensions in pixels. Default 65. + * @param int $size The height and width avatar dimensions in pixels. Default 65. */ echo get_avatar( get_the_author_meta( 'ID' ), apply_filters( 'twentyeleven_status_avatar', 65 ) ); ?> diff --git a/src/wp-content/themes/twentyeleven/image.php b/src/wp-content/themes/twentyeleven/image.php index 54bfb17498fe7..1bab57b581259 100644 --- a/src/wp-content/themes/twentyeleven/image.php +++ b/src/wp-content/themes/twentyeleven/image.php @@ -99,7 +99,7 @@ * * @since Twenty Eleven 1.0 * - * @param int The width for the image attachment size in pixels. Default 848. + * @param int $size The width for the image attachment size in pixels. Default 848. */ $attachment_size = apply_filters( 'twentyeleven_attachment_size', 848 ); echo wp_get_attachment_image( $post->ID, array( $attachment_size, 1024 ) ); diff --git a/src/wp-content/themes/twentyeleven/inc/widgets.php b/src/wp-content/themes/twentyeleven/inc/widgets.php index 4e82cdf6a055e..72411d01a8064 100644 --- a/src/wp-content/themes/twentyeleven/inc/widgets.php +++ b/src/wp-content/themes/twentyeleven/inc/widgets.php @@ -70,7 +70,7 @@ public function widget( $args, $instance ) { ob_start(); - /** This filter is documented in wp-includes/default-widgets.php */ + /** This filter is documented in wp-includes/widgets/class-wp-widget-pages.php */ $args['title'] = apply_filters( 'widget_title', empty( $instance['title'] ) ? __( 'Ephemera', 'twentyeleven' ) : $instance['title'], $instance, $this->id_base ); if ( ! isset( $instance['number'] ) ) { diff --git a/src/wp-content/themes/twentyeleven/tag.php b/src/wp-content/themes/twentyeleven/tag.php index 23517622f0cb6..966dc7c603a93 100644 --- a/src/wp-content/themes/twentyeleven/tag.php +++ b/src/wp-content/themes/twentyeleven/tag.php @@ -30,7 +30,7 @@ * * @since Twenty Eleven 1.0 * - * @param string The default tag description. + * @param string $tag_archive_meta The default tag description. */ echo apply_filters( 'tag_archive_meta', '
' . $tag_description . '
' ); } diff --git a/src/wp-content/themes/twentyten/functions.php b/src/wp-content/themes/twentyten/functions.php index baf9e13121e99..fbdf5581f0633 100644 --- a/src/wp-content/themes/twentyten/functions.php +++ b/src/wp-content/themes/twentyten/functions.php @@ -168,7 +168,7 @@ function twentyten_setup() { * * @since Twenty Ten 1.0 * - * @param int The default header image width in pixels. Default 940. + * @param int $width The default header image width in pixels. Default 940. */ 'width' => apply_filters( 'twentyten_header_image_width', 940 ), /** @@ -176,7 +176,7 @@ function twentyten_setup() { * * @since Twenty Ten 1.0 * - * @param int The default header image height in pixels. Default 198. + * @param int $height The default header image height in pixels. Default 198. */ 'height' => apply_filters( 'twentyten_header_image_height', 198 ), // Support flexible heights. diff --git a/src/wp-content/themes/twentytwentyone/functions.php b/src/wp-content/themes/twentytwentyone/functions.php index 01a97e597e78a..4483716e27ef2 100644 --- a/src/wp-content/themes/twentytwentyone/functions.php +++ b/src/wp-content/themes/twentytwentyone/functions.php @@ -577,7 +577,7 @@ function twentytwentyone_the_html_classes() { * * @since Twenty Twenty-One 1.0 * - * @param string The list of classes. Default empty string. + * @param string $classes The list of classes. Default empty string. */ $classes = apply_filters( 'twentytwentyone_html_classes', '' ); if ( ! $classes ) { From 72e9acfaaadad5e301f642ebd51b3c79e8e093a6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 29 May 2026 17:43:06 -0700 Subject: [PATCH 08/21] REST API: Pass the $is_update argument to the wp_creating_autosave action. When updating an existing autosave, WP_REST_Autosaves_Controller fired wp_creating_autosave with only the autosave array, omitting the $is_update argument added in 6.4.0. This call path updates an existing autosave (it sets the existing autosave ID and calls wp_update_post()), mirroring the update branch in wp_create_post_autosave(), so pass true to match the documented two-argument signature and give callbacks the expected $is_update value. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rest-api/endpoints/class-wp-rest-autosaves-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index 65ca4e0018cb6..c0d58160467b2 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -425,7 +425,7 @@ public function create_post_autosave( $post_data, array $meta = array() ) { $new_autosave['post_author'] = $user_id; /** This action is documented in wp-admin/includes/post.php */ - do_action( 'wp_creating_autosave', $new_autosave ); + do_action( 'wp_creating_autosave', $new_autosave, true ); // wp_update_post() expects escaped array. $revision_id = wp_update_post( wp_slash( $new_autosave ) ); From b23f1d22bc15ef8c0a797d18afcdbe3c4b0c5247 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 29 May 2026 18:14:01 -0700 Subject: [PATCH 09/21] HTTP API: Pass the request URL to the https_local_ssl_verify filter. The https_local_ssl_verify filter is documented (in class-wp-http-streams.php / class-wp-http-curl.php) as receiving the SSL-verify value and the request URL (the $url parameter added in 5.1.0), but several loopback callers fired it with only the value. Pass the loopback request URL as the second argument so filters can vary SSL verification per URL, matching the canonical invocations. Affects the loopback requests in WP_Automatic_Updater, WP_Site_Health (REST availability, can_perform_loopback, check_for_page_caching), the plugin/ theme editor scrape in wp_edit_theme_plugin_file(), and spawn_cron(). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../includes/class-wp-automatic-updater.php | 8 ++++---- src/wp-admin/includes/class-wp-site-health.php | 13 +++++++------ src/wp-admin/includes/file.php | 9 +++++---- src/wp-includes/cron.php | 6 ++++-- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/wp-admin/includes/class-wp-automatic-updater.php b/src/wp-admin/includes/class-wp-automatic-updater.php index 2facbeb1d522f..cd9426c6ef88b 100644 --- a/src/wp-admin/includes/class-wp-automatic-updater.php +++ b/src/wp-admin/includes/class-wp-automatic-updater.php @@ -1785,9 +1785,6 @@ protected function has_fatal_error() { 'Cache-Control' => 'no-cache', ); - /** This filter is documented in wp-includes/class-wp-http-streams.php */ - $sslverify = apply_filters( 'https_local_ssl_verify', false ); - // Include Basic auth in the loopback request. if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) { $headers['Authorization'] = 'Basic ' . base64_encode( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( $_SERVER['PHP_AUTH_PW'] ) ); @@ -1804,7 +1801,10 @@ protected function has_fatal_error() { $needle_start = "###### wp_scraping_result_start:$scrape_key ######"; $needle_end = "###### wp_scraping_result_end:$scrape_key ######"; $url = add_query_arg( $scrape_params, home_url( '/' ) ); - $response = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) ); + + /** This filter is documented in wp-includes/class-wp-http-streams.php */ + $sslverify = apply_filters( 'https_local_ssl_verify', false, $url ); + $response = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) ); if ( is_wp_error( $response ) ) { if ( $is_debug ) { diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 75e046ef8ffa7..98a6fa6a00ed4 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -2212,9 +2212,6 @@ public function get_test_rest_availability() { 'Cache-Control' => 'no-cache', 'X-WP-Nonce' => wp_create_nonce( 'wp_rest' ), ); - /** This filter is documented in wp-includes/class-wp-http-streams.php */ - $sslverify = apply_filters( 'https_local_ssl_verify', false ); - // Include Basic auth in loopback requests. if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) { $headers['Authorization'] = 'Basic ' . base64_encode( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( $_SERVER['PHP_AUTH_PW'] ) ); @@ -2230,6 +2227,9 @@ public function get_test_rest_availability() { $url ); + /** This filter is documented in wp-includes/class-wp-http-streams.php */ + $sslverify = apply_filters( 'https_local_ssl_verify', false, $url ); + $r = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) ); if ( is_wp_error( $r ) ) { @@ -3289,8 +3289,6 @@ public function can_perform_loopback() { $headers = array( 'Cache-Control' => 'no-cache', ); - /** This filter is documented in wp-includes/class-wp-http-streams.php */ - $sslverify = apply_filters( 'https_local_ssl_verify', false ); // Include Basic auth in loopback requests. if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) { @@ -3299,6 +3297,9 @@ public function can_perform_loopback() { $url = site_url( 'wp-cron.php' ); + /** This filter is documented in wp-includes/class-wp-http-streams.php */ + $sslverify = apply_filters( 'https_local_ssl_verify', false, $url ); + /* * A post request is used for the wp-cron.php loopback test to cause the file * to finish early without triggering cron jobs. This has two benefits: @@ -3621,7 +3622,7 @@ public function get_page_cache_headers(): array { private function check_for_page_caching() { /** This filter is documented in wp-includes/class-wp-http-streams.php */ - $sslverify = apply_filters( 'https_local_ssl_verify', false ); + $sslverify = apply_filters( 'https_local_ssl_verify', false, home_url( '/' ) ); $headers = array(); diff --git a/src/wp-admin/includes/file.php b/src/wp-admin/includes/file.php index 0c6d968ea02d3..f2009f2acbb5d 100644 --- a/src/wp-admin/includes/file.php +++ b/src/wp-admin/includes/file.php @@ -541,9 +541,6 @@ function wp_edit_theme_plugin_file( $args ) { 'Cache-Control' => 'no-cache', ); - /** This filter is documented in wp-includes/class-wp-http-streams.php */ - $sslverify = apply_filters( 'https_local_ssl_verify', false ); - // Include Basic auth in loopback requests. if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) { $headers['Authorization'] = 'Basic ' . base64_encode( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( $_SERVER['PHP_AUTH_PW'] ) ); @@ -583,7 +580,11 @@ function wp_edit_theme_plugin_file( $args ) { session_write_close(); } - $url = add_query_arg( $scrape_params, $url ); + $url = add_query_arg( $scrape_params, $url ); + + /** This filter is documented in wp-includes/class-wp-http-streams.php */ + $sslverify = apply_filters( 'https_local_ssl_verify', false, $url ); + $r = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) ); $body = wp_remote_retrieve_body( $r ); $scrape_result_position = strpos( $body, $needle_start ); diff --git a/src/wp-includes/cron.php b/src/wp-includes/cron.php index 7bbb0036f1c02..b1f2ad6e3a527 100644 --- a/src/wp-includes/cron.php +++ b/src/wp-includes/cron.php @@ -958,6 +958,8 @@ function spawn_cron( $gmt_time = 0 ) { $doing_wp_cron = sprintf( '%.22F', $gmt_time ); set_transient( 'doing_cron', $doing_wp_cron ); + $cron_url = add_query_arg( 'doing_wp_cron', $doing_wp_cron, site_url( 'wp-cron.php' ) ); + /** * Filters the cron request arguments. * @@ -982,13 +984,13 @@ function spawn_cron( $gmt_time = 0 ) { $cron_request = apply_filters( 'cron_request', array( - 'url' => add_query_arg( 'doing_wp_cron', $doing_wp_cron, site_url( 'wp-cron.php' ) ), + 'url' => $cron_url, 'key' => $doing_wp_cron, 'args' => array( 'timeout' => 0.01, 'blocking' => false, /** This filter is documented in wp-includes/class-wp-http-streams.php */ - 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), + 'sslverify' => apply_filters( 'https_local_ssl_verify', false, $cron_url ), ), ), $doing_wp_cron From 753e5021985f15e65ab658ca37302c458cdae63f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 29 May 2026 18:54:03 -0700 Subject: [PATCH 10/21] Menus: Pass $menu_data to the wp_update_nav_menu action from auto-add updates. The wp_update_nav_menu action is documented (in wp-includes/nav-menu.php) as receiving the menu ID and an array of menu data, but the two sites that fire it after updating only the "auto add pages" option -- wp_nav_menu_update_menu_items() and WP_REST_Menus_Controller::handle_auto_add() -- passed only the menu ID. These paths have no menu-data array, so pass an empty array() to satisfy the documented two-argument signature and avoid an ArgumentCountError for callbacks registered with two arguments. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-admin/includes/nav-menu.php | 2 +- .../rest-api/endpoints/class-wp-rest-menus-controller.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/nav-menu.php b/src/wp-admin/includes/nav-menu.php index 70263a2034807..f26d63d528e78 100644 --- a/src/wp-admin/includes/nav-menu.php +++ b/src/wp-admin/includes/nav-menu.php @@ -1509,7 +1509,7 @@ function wp_nav_menu_update_menu_items( $nav_menu_selected_id, $nav_menu_selecte wp_defer_term_counting( false ); /** This action is documented in wp-includes/nav-menu.php */ - do_action( 'wp_update_nav_menu', $nav_menu_selected_id ); + do_action( 'wp_update_nav_menu', $nav_menu_selected_id, array() ); /* translators: %s: Nav menu title. */ $message = sprintf( __( '%s has been updated.' ), '' . $nav_menu_selected_title . '' ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php index 3947bfd6107ce..706e36fb6cc66 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php @@ -453,7 +453,7 @@ protected function handle_auto_add( $menu_id, $request ) { $update = update_option( 'nav_menu_options', $nav_menu_option ); /** This action is documented in wp-includes/nav-menu.php */ - do_action( 'wp_update_nav_menu', $menu_id ); + do_action( 'wp_update_nav_menu', $menu_id, array() ); return $update; } From d359a7f067e0bf384f93d60eea83f2464e63793b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 29 May 2026 22:17:22 -0700 Subject: [PATCH 11/21] Plugins: Pass $network_wide to the activate_{$plugin} action in error scraping. The error_scrape handler re-fires the activate_{$plugin} action to reproduce a fatal that occurred during activation, but passed no arguments. The action is documented as receiving a bool $network_wide; relying on do_action()'s empty argument padding meant a callback type-hinting the parameter would receive an empty string, which fails under strict types (and for an int parameter would fatal even in coercive mode). Pass false explicitly, matching the single-site activation this admin screen reproduces. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-admin/plugins.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/plugins.php b/src/wp-admin/plugins.php index 6a359c822fac1..b560892b36bfe 100644 --- a/src/wp-admin/plugins.php +++ b/src/wp-admin/plugins.php @@ -193,7 +193,7 @@ // Go back to "sandbox" scope so we get the same errors as before. plugin_sandbox_scrape( $plugin ); /** This action is documented in wp-admin/includes/plugin.php */ - do_action( "activate_{$plugin}" ); + do_action( "activate_{$plugin}", false ); exit; case 'deactivate': From ea34b556e701897bfbbf10947c007c0f2bee6e11 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 29 May 2026 23:15:42 -0700 Subject: [PATCH 12/21] Build/Test Tools: Resolve file-scope @global docblocks in PHPStan. GlobalDocBlockVisitor previously injected @var types from @global tags only for global statements inside a function or method body. WordPress also documents file-scope globals with an @global docblock on the global statement itself (see wp-admin/edit.php and other admin entry points), but PHPStan resolved those as mixed. Extend the visitor to also read @global tags from the docblock attached directly to a file-scope global statement, merging them with any tags inherited from an enclosing function (statement-level tags take precedence). Function-scope behavior and handwritten @var precedence are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/phpstan/GlobalDocBlockVisitor.php | 29 ++++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/phpstan/GlobalDocBlockVisitor.php b/tests/phpstan/GlobalDocBlockVisitor.php index c5fbabfd336ee..355e7504c7dc0 100644 --- a/tests/phpstan/GlobalDocBlockVisitor.php +++ b/tests/phpstan/GlobalDocBlockVisitor.php @@ -15,9 +15,11 @@ use PhpParser\NodeVisitorAbstract; /** - * Reads `@global Type $varname` tags from function and method docblocks and - * injects equivalent inline `@var` docblocks onto matching `global $foo;` - * statements inside the function body. + * Reads `@global Type $varname` tags and injects equivalent inline `@var` + * docblocks onto matching `global $foo;` statements. The tags may be documented + * on the enclosing function/method docblock (applying to `global` statements in + * its body) or, for a file-scope `global` statement with no enclosing function, + * directly on the statement itself. * * PHPStan does not consult bootstrap- or stub-declared variable types when * resolving `global $foo;` inside functions. It only honors `@var` @@ -73,11 +75,24 @@ public function enterNode( Node $node ): ?Node { return null; } - if ( ! ( $node instanceof Node\Stmt\Global_ ) || $this->stack === array() ) { + if ( ! ( $node instanceof Node\Stmt\Global_ ) ) { return null; } - $map = $this->stack[ count( $this->stack ) - 1 ]; + /* + * The `@global` tags may be documented on the enclosing function (the top + * stack frame) or, for a file-scope `global` statement that has no enclosing + * function, directly on the statement itself. Merge both, with tags on the + * statement taking precedence. + */ + $existing = $node->getDocComment(); + $existing_text = $existing !== null ? $existing->getText() : ''; + + $map = $this->stack !== array() ? $this->stack[ count( $this->stack ) - 1 ] : array(); + if ( $existing_text !== '' ) { + $map = array_merge( $map, $this->parse_global_tags( $existing_text ) ); + } + if ( $map === array() ) { return null; } @@ -87,9 +102,7 @@ public function enterNode( Node $node ): ?Node { * 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(); + $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] ); } From 413ba4cf248a4f5322500c500d289eca0a7e63fd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 29 May 2026 23:16:20 -0700 Subject: [PATCH 13/21] Plugins: Pass $paged to the install_plugins_upload action. The install_plugins_{$tab} action is documented as receiving the current page number, and the canonical invocation passes $paged. The upload-tab form rendered on non-upload screens fired install_plugins_upload with no arguments, so pass $paged to match the documented signature. Document the $tab and $paged globals (populated by WP_Plugin_Install_List_Table::prepare_items()) with an @global docblock so they resolve throughout the file. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-admin/plugin-install.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/wp-admin/plugin-install.php b/src/wp-admin/plugin-install.php index 5c8be143bf332..395e55b8eca30 100644 --- a/src/wp-admin/plugin-install.php +++ b/src/wp-admin/plugin-install.php @@ -40,6 +40,15 @@ $wp_list_table->prepare_items(); +/** + * WP_Plugin_Install_List_Table::prepare_items() populates these globals, which + * are used throughout the rest of this file. + * + * @global string $tab The current tab of the Install Plugins screen. + * @global int $paged The current page number of the plugins list. + */ +global $tab, $paged; + $total_pages = $wp_list_table->get_pagination_arg( 'total_pages' ); if ( $pagenum > $total_pages && $total_pages > 0 ) { @@ -169,7 +178,7 @@
Date: Sat, 30 May 2026 12:42:53 -0700 Subject: [PATCH 14/21] Taxonomy: Pass $args to edit/edited term actions and the term_id_filter. Several term hooks are documented as receiving an $args array (added in 6.1.0) as their final argument, but some invocations omitted it: - In wp_insert_term(), the edit_terms and edited_terms actions fired on the empty-slug path are now passed the function's $args. - In wp_update_term(), the term_id_filter (documented elsewhere in this file) is now passed $args, matching its canonical invocation. - In the _update_post_term_count() and _update_generic_term_count() callbacks, the edit_term_taxonomy and edited_term_taxonomy actions are fired while only recounting, where no wp_update_term() args exist, so an empty array() is passed to satisfy the documented three-argument signature. This gives callbacks the documented argument and avoids an ArgumentCountError for callbacks registered with three arguments. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/taxonomy.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 80f457de0e6f7..9faddca11ba32 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -2602,11 +2602,11 @@ function wp_insert_term( $term, $taxonomy, $args = array() ) { $slug = sanitize_title( $slug, $term_id ); /** This action is documented in wp-includes/taxonomy.php */ - do_action( 'edit_terms', $term_id, $taxonomy ); + do_action( 'edit_terms', $term_id, $taxonomy, $args ); $wpdb->update( $wpdb->terms, compact( 'slug' ), compact( 'term_id' ) ); /** This action is documented in wp-includes/taxonomy.php */ - do_action( 'edited_terms', $term_id, $taxonomy ); + do_action( 'edited_terms', $term_id, $taxonomy, $args ); } $tt_id = $wpdb->get_var( $wpdb->prepare( "SELECT tt.term_taxonomy_id FROM $wpdb->term_taxonomy AS tt INNER JOIN $wpdb->terms AS t ON tt.term_id = t.term_id WHERE tt.taxonomy = %s AND t.term_id = %d", $taxonomy, $term_id ) ); @@ -3452,7 +3452,7 @@ function wp_update_term( $term_id, $taxonomy, $args = array() ) { do_action( "edit_{$taxonomy}", $term_id, $tt_id, $args ); /** This filter is documented in wp-includes/taxonomy.php */ - $term_id = apply_filters( 'term_id_filter', $term_id, $tt_id ); + $term_id = apply_filters( 'term_id_filter', $term_id, $tt_id, $args ); clean_term_cache( $term_id, $taxonomy ); @@ -4207,11 +4207,11 @@ function _update_post_term_count( $terms, $taxonomy ) { do_action( 'update_term_count', $tt_id, $taxonomy->name, $count ); /** This action is documented in wp-includes/taxonomy.php */ - do_action( 'edit_term_taxonomy', $tt_id, $taxonomy->name ); + do_action( 'edit_term_taxonomy', $tt_id, $taxonomy->name, array() ); $wpdb->update( $wpdb->term_taxonomy, compact( 'count' ), array( 'term_taxonomy_id' => $tt_id ) ); /** This action is documented in wp-includes/taxonomy.php */ - do_action( 'edited_term_taxonomy', $tt_id, $taxonomy->name ); + do_action( 'edited_term_taxonomy', $tt_id, $taxonomy->name, array() ); } } @@ -4237,11 +4237,11 @@ function _update_generic_term_count( $terms, $taxonomy ) { do_action( 'update_term_count', $term, $taxonomy->name, $count ); /** This action is documented in wp-includes/taxonomy.php */ - do_action( 'edit_term_taxonomy', $term, $taxonomy->name ); + do_action( 'edit_term_taxonomy', $term, $taxonomy->name, array() ); $wpdb->update( $wpdb->term_taxonomy, compact( 'count' ), array( 'term_taxonomy_id' => $term ) ); /** This action is documented in wp-includes/taxonomy.php */ - do_action( 'edited_term_taxonomy', $term, $taxonomy->name ); + do_action( 'edited_term_taxonomy', $term, $taxonomy->name, array() ); } } From 92a2767a05184fae9148e29369767ca64390d87e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 30 May 2026 13:01:42 -0700 Subject: [PATCH 15/21] Build/Test Tools: Add a PHPStan rule enforcing hook argument counts. Add HookParamCountRule, which reports any apply_filters()/do_action() (and variant) invocation that passes a different number of arguments than its documentation describes. WordPress passes a hook's arguments straight through to its callbacks, so a documented-but-unpassed parameter is a real defect: a callback registered for the documented argument count triggers an ArgumentCountError (or a TypeError for a typed parameter) when the hook fires with fewer arguments. The provided count is read from the call: positional arguments for the variadic apply_filters()/do_action(), or the size of the array argument for the *_ref_array()/*_deprecated() variants, using PHPStan's inferred constant array size so literals and typed variables are both handled. Calls whose count cannot be determined statically are skipped. To support this, HookDocBlock gains getDocumentedParamCount() (which resolves a hook's documented parameters inline or via a "documented in" reference, without the return-type extension's fallback to the bare reference comment). Reference resolution now walks up the directory tree to locate the named file from any location instead of assuming a wp-includes/wp-admin root, and matches dynamic hook names (e.g. "{$type}_template_hierarchy") between the referencing site and the canonical invocation. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/phpstan/HookDocBlock.php | 211 ++++++++++++++++++++++----- tests/phpstan/HookParamCountRule.php | 209 ++++++++++++++++++++++++++ tests/phpstan/base.neon | 8 + 3 files changed, 391 insertions(+), 37 deletions(-) create mode 100644 tests/phpstan/HookParamCountRule.php diff --git a/tests/phpstan/HookDocBlock.php b/tests/phpstan/HookDocBlock.php index a781280c00b4e..5425f8aafb308 100644 --- a/tests/phpstan/HookDocBlock.php +++ b/tests/phpstan/HookDocBlock.php @@ -28,6 +28,7 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\Concat; use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Expr\Variable; use PhpParser\Node\InterpolatedStringPart; use PhpParser\Node\Name; use PhpParser\Node\Scalar\InterpolatedString; @@ -159,6 +160,51 @@ public function getPrecedingDocBlock( FuncCall $function_call ): ?array { ); } + /** + * Returns the number of parameters documented for a hook call, resolving the + * docblock the same way as the return-type extension (inline or via a + * "documented in" reference). + * + * Unlike getNullableHookDocBlock(), this does NOT fall back to the reference + * comment when a reference cannot be resolved to a canonical docblock; it + * returns null instead, so callers do not mistake an unresolved reference + * (which has no `@param` tags) for a genuine zero-parameter hook. + * + * @param FuncCall $function_call Hook function call node. + * @param Scope $scope Analysis scope. + * @return int|null Documented parameter count, or null when there is no + * docblock or a reference cannot be resolved. + */ + public function getDocumentedParamCount( FuncCall $function_call, Scope $scope ): ?int { + $comment = self::getNullableNodeComment( $function_call ); + if ( null === $comment ) { + return null; + } + + $code = $comment->getText(); + + if ( preg_match( self::REFERENCE_PATTERN, $code ) ) { + $referenced = $this->resolveDocumentedInReference( $code, $function_call, $scope ); + if ( null === $referenced ) { + return null; + } + return count( $referenced->getParamTags() ); + } + + $class_reflection = $scope->getClassReflection(); + $trait_reflection = $scope->getTraitReflection(); + + $resolved = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + ( $scope->isInClass() && null !== $class_reflection ) ? $class_reflection->getName() : null, + ( $scope->isInTrait() && null !== $trait_reflection ) ? $trait_reflection->getName() : null, + $scope->getFunctionName(), + $code + ); + + return count( $resolved->getParamTags() ); + } + /** * Determines whether a hook call's name can be identified well enough to * require or locate documentation. @@ -205,32 +251,27 @@ public function getReferenceProblem( FuncCall $function_call, Scope $scope ): ?a return null; } - $hook_name = self::getHookName( $function_call ); - if ( null === $hook_name ) { + $matcher = self::getHookNameMatcher( $function_call ); + if ( null === $matcher ) { return null; } $reference_path = $matches[1]; $target_file = self::resolveReferencePath( $scope->getFile(), $reference_path ); - // The WordPress root could not be determined from the current file path; - // skip rather than report a false positive. + // The referenced file could not be located up the directory tree. if ( null === $target_file ) { - return null; - } - - if ( ! is_file( $target_file ) ) { return array( 'path' => $reference_path, - 'hook' => $hook_name, + 'hook' => self::getHookNameDisplay( $function_call ), 'problem' => self::PROBLEM_FILE_MISSING, ); } - if ( null === $this->getHookDocTextFromFile( $target_file, $hook_name ) ) { + if ( null === $this->findHookDoc( $target_file, $matcher ) ) { return array( 'path' => $reference_path, - 'hook' => $hook_name, + 'hook' => self::getHookNameDisplay( $function_call ), 'problem' => self::PROBLEM_HOOK_MISSING, ); } @@ -252,8 +293,8 @@ private function resolveDocumentedInReference( string $comment_text, FuncCall $f return null; } - $hook_name = self::getHookName( $function_call ); - if ( null === $hook_name ) { + $matcher = self::getHookNameMatcher( $function_call ); + if ( null === $matcher ) { return null; } @@ -262,7 +303,7 @@ private function resolveDocumentedInReference( string $comment_text, FuncCall $f return null; } - $doc_text = $this->getHookDocTextFromFile( $target_file, $hook_name ); + $doc_text = $this->findHookDoc( $target_file, $matcher ); if ( null === $doc_text ) { return null; } @@ -279,28 +320,51 @@ private function resolveDocumentedInReference( string $comment_text, FuncCall $f /** * Returns the canonical docblock text for a hook documented in the given file. * - * @param string $file Absolute path to the file declaring the hook. - * @param string $hook_name Hook name to match. + * @param string $file Absolute path to the file declaring the hook. + * @param array{kind: 'literal'|'pattern', value: string} $matcher Hook name matcher from getHookNameMatcher(). * @return string|null Docblock text, or null when no documented invocation is found. */ - private function getHookDocTextFromFile( string $file, string $hook_name ): ?string { + private function findHookDoc( string $file, array $matcher ): ?string { if ( ! isset( $this->fileHookDocs[ $file ] ) ) { $this->fileHookDocs[ $file ] = self::parseHookDocs( $file ); } $docs = $this->fileHookDocs[ $file ]; - if ( isset( $docs['exact'][ $hook_name ] ) ) { - return $docs['exact'][ $hook_name ]; + if ( 'literal' === $matcher['kind'] ) { + $name = $matcher['value']; + + if ( isset( $docs['exact'][ $name ] ) ) { + return $docs['exact'][ $name ]; + } + + // A literal name may be an instance of a dynamic canonical hook + // (e.g. "index_template_hierarchy" matching "{$type}_template_hierarchy"). + foreach ( $docs['patterns'] as $pattern ) { + if ( preg_match( $pattern['regex'], $name ) ) { + return $pattern['text']; + } + } + + return null; } - // Fall back to dynamic canonical hook names (e.g. "{$type}_foo"). + // A dynamic referencing name matches the same dynamic canonical (identical + // regex), or a literal canonical the pattern covers. + $regex = $matcher['value']; + foreach ( $docs['patterns'] as $pattern ) { - if ( preg_match( $pattern['regex'], $hook_name ) ) { + if ( $pattern['regex'] === $regex ) { return $pattern['text']; } } + foreach ( $docs['exact'] as $name => $text ) { + if ( preg_match( $regex, $name ) ) { + return $text; + } + } + return null; } @@ -445,45 +509,118 @@ private static function hookNameRegexParts( Expr $expr ): ?array { * Resolves a WordPress-root-relative reference path against the file * containing the reference comment. * + * The reference comment names the exact file (e.g. "wp-includes/media.php"), so + * resolution simply walks up from the current file's directory until that + * relative path resolves to a real file. This works regardless of where the + * referencing file lives (core, a bundled theme, the install root, ...) and + * only ever touches the single named file — no directory is enumerated. + * * @param string $current_file Absolute path to the file with the reference comment. * @param string $reference_path Root-relative path (e.g. "wp-includes/media.php"). - * @return string|null Absolute path to the referenced file, or null when the root cannot be determined. + * @return string|null Absolute path to the referenced file, or null when it cannot be located. */ private static function resolveReferencePath( string $current_file, string $reference_path ): ?string { $reference_path = ltrim( $reference_path, '/' ); + $dir = dirname( $current_file ); - // Locate the earliest top-level WordPress directory in the current path; - // everything before it is the WordPress root. - $root_end = null; - foreach ( array( '/wp-includes/', '/wp-admin/' ) as $needle ) { - $pos = strpos( $current_file, $needle ); - if ( false !== $pos && ( null === $root_end || $pos < $root_end ) ) { - $root_end = $pos; + while ( true ) { + $candidate = $dir . '/' . $reference_path; + if ( is_file( $candidate ) ) { + return $candidate; } + + $parent = dirname( $dir ); + if ( $parent === $dir ) { + return null; + } + $dir = $parent; } + } - if ( null === $root_end ) { + /** + * Returns a matcher describing a hook call's name: a literal string to look up + * exactly, or a regex for a dynamic name (e.g. "{$type}_template_hierarchy"). + * + * @param FuncCall $call Hook function call node. + * @return array{kind: 'literal'|'pattern', value: string}|null + * Null when the name carries no identifiable text (e.g. a bare variable). + */ + private static function getHookNameMatcher( FuncCall $call ): ?array { + $args = $call->getArgs(); + if ( ! isset( $args[0] ) ) { return null; } - return substr( $current_file, 0, $root_end ) . '/' . $reference_path; + $expr = $args[0]->value; + + if ( $expr instanceof String_ ) { + return array( + 'kind' => 'literal', + 'value' => $expr->value, + ); + } + + $regex = self::buildHookNameRegex( $expr ); + if ( null !== $regex ) { + return array( + 'kind' => 'pattern', + 'value' => $regex, + ); + } + + return null; } /** - * Returns the hook name (first string argument) of a hook function call. + * Renders a hook name expression to a readable string for diagnostics, e.g. + * "default_option_{$option}". * * @param FuncCall $call Hook function call node. - * @return string|null Hook name, or null when it is not a string literal. + * @return string */ - private static function getHookName( FuncCall $call ): ?string { + private static function getHookNameDisplay( FuncCall $call ): string { $args = $call->getArgs(); if ( ! isset( $args[0] ) ) { - return null; + return ''; } - $value = $args[0]->value; + return self::renderHookName( $args[0]->value ); + } + + /** + * Recursively renders a hook name expression to a readable string. + * + * @param Expr $expr Hook name expression. + * @return string + */ + private static function renderHookName( Expr $expr ): string { + if ( $expr instanceof String_ ) { + return $expr->value; + } + + if ( $expr instanceof Concat ) { + return self::renderHookName( $expr->left ) . self::renderHookName( $expr->right ); + } + + if ( $expr instanceof InterpolatedString ) { + $out = ''; + foreach ( $expr->parts as $part ) { + if ( $part instanceof InterpolatedStringPart ) { + $out .= $part->value; + } elseif ( $part instanceof Variable && is_string( $part->name ) ) { + $out .= '{$' . $part->name . '}'; + } else { + $out .= '{...}'; + } + } + return $out; + } + + if ( $expr instanceof Variable && is_string( $expr->name ) ) { + return '$' . $expr->name; + } - return $value instanceof String_ ? $value->value : null; + return '...'; } /** diff --git a/tests/phpstan/HookParamCountRule.php b/tests/phpstan/HookParamCountRule.php new file mode 100644 index 0000000000000..0068623593241 --- /dev/null +++ b/tests/phpstan/HookParamCountRule.php @@ -0,0 +1,209 @@ +" + * reference is checked against its canonical docblock. + * + * @package WordPress + */ + +declare(strict_types=1); + +namespace WordPress\PHPStan; + +use PhpParser\Node; +use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Name; +use PhpParser\Node\Scalar\String_; +use PHPStan\Analyser\Scope; +use PHPStan\Rules\Rule; +use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\Type\Constant\ConstantIntegerType; + +/** + * Reports hook invocations whose argument count does not match the number of + * documented parameters. + * + * @implements Rule + */ +class HookParamCountRule implements Rule { + + /** + * Hook functions that receive the hook arguments as variadic parameters. + */ + private const VARIADIC_FUNCTIONS = array( + 'apply_filters', + 'do_action', + ); + + /** + * Hook functions that receive the hook arguments as an array in their second + * parameter. + */ + private const ARRAY_ARG_FUNCTIONS = array( + 'apply_filters_ref_array', + 'apply_filters_deprecated', + 'do_action_ref_array', + 'do_action_deprecated', + ); + + /** + * Hook docblock resolver. + * + * @var \WordPress\PHPStan\HookDocBlock + */ + private $hookDocBlock; + + /** + * Constructor. + * + * @param HookDocBlock $hook_doc_block Hook docblock resolver. + */ + public function __construct( HookDocBlock $hook_doc_block ) { + $this->hookDocBlock = $hook_doc_block; + } + + /** + * Returns the node type this rule processes. + * + * @return string + */ + public function getNodeType(): string { + return FuncCall::class; + } + + /** + * Processes a function call node. + * + * @param Node $node Function call node. + * @param Scope $scope Analysis scope. + * @return list<\PHPStan\Rules\IdentifierRuleError> + */ + public function processNode( Node $node, Scope $scope ): array { + if ( ! $node instanceof FuncCall || ! $node->name instanceof Name ) { + return array(); + } + + $function_name = $node->name->toString(); + $is_variadic = in_array( $function_name, self::VARIADIC_FUNCTIONS, true ); + if ( ! $is_variadic && ! in_array( $function_name, self::ARRAY_ARG_FUNCTIONS, true ) ) { + return array(); + } + + // Without an identifiable hook name there is nothing to document or look up. + if ( ! HookDocBlock::hasIdentifiableHookName( $node ) ) { + return array(); + } + + // Only compare against documentation that actually resolves. Missing docs and + // unresolvable/broken references (reported by HookDocumentationRule) yield + // null here and are skipped rather than compared against a bogus zero count. + $documented = $this->hookDocBlock->getDocumentedParamCount( $node, $scope ); + if ( null === $documented ) { + return array(); + } + + $provided = $is_variadic + ? self::countVariadicArguments( $node, $scope ) + : self::countArrayArguments( $node, $scope ); + + // The provided count could not be determined statically; skip rather than + // guess (e.g. arguments spread from a variable of unknown size). + if ( null === $provided || $provided === $documented ) { + return array(); + } + + return array( + RuleErrorBuilder::message( + sprintf( + '%s() %sprovides %d argument%s, but the hook is documented with %d parameter%s.', + $function_name, + self::hookLabel( $node ), + $provided, + 1 === $provided ? '' : 's', + $documented, + 1 === $documented ? '' : 's' + ) + ) + ->identifier( 'wordpress.hookParamCountMismatch' ) + ->line( $node->getStartLine() ) + ->build(), + ); + } + + /** + * Counts the arguments a variadic hook call passes after the hook name. + * + * @param FuncCall $node Hook function call node. + * @param Scope $scope Analysis scope. + * @return int|null Argument count, or null when it cannot be determined statically. + */ + private static function countVariadicArguments( FuncCall $node, Scope $scope ): ?int { + $args = $node->getArgs(); + $count = 0; + + // Skip index 0, the hook name. + for ( $i = 1, $len = count( $args ); $i < $len; $i++ ) { + $arg = $args[ $i ]; + + if ( $arg->unpack ) { + $size = $scope->getType( $arg->value )->getArraySize(); + if ( ! $size instanceof ConstantIntegerType ) { + return null; + } + $count += $size->getValue(); + continue; + } + + ++$count; + } + + return $count; + } + + /** + * Counts the arguments a hook call passes via its array argument. + * + * @param FuncCall $node Hook function call node. + * @param Scope $scope Analysis scope. + * @return int|null Argument count, or null when it cannot be determined statically. + */ + private static function countArrayArguments( FuncCall $node, Scope $scope ): ?int { + $args = $node->getArgs(); + if ( ! isset( $args[1] ) ) { + return null; + } + + $size = $scope->getType( $args[1]->value )->getArraySize(); + if ( ! $size instanceof ConstantIntegerType ) { + return null; + } + + return $size->getValue(); + } + + /** + * Builds a `for hook "name" ` label fragment when the hook name is a literal. + * + * @param FuncCall $node Hook function call node. + * @return string + */ + private static function hookLabel( FuncCall $node ): string { + $args = $node->getArgs(); + if ( isset( $args[0] ) && $args[0]->value instanceof String_ ) { + return sprintf( 'for hook "%s" ', $args[0]->value->value ); + } + + return ''; + } +} \ No newline at end of file diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index 3746a167ce675..f24d1ad1c9587 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -33,6 +33,13 @@ services: tags: - phpstan.rules.rule + # Enforces that a hook invocation passes as many arguments as its documentation + # (inline or referenced) describes. + - + class: WordPress\PHPStan\HookParamCountRule + tags: + - phpstan.rules.rule + parameters: # Cache is stored locally, so it's available for CI. tmpDir: ../../.cache @@ -124,6 +131,7 @@ parameters: - HookDocBlock.php - ApplyFiltersDynamicFunctionReturnTypeExtension.php - HookDocumentationRule.php + - HookParamCountRule.php bootstrapFiles: - bootstrap.php scanFiles: From daaf47d57c11729fd34239e66255de29018b73e9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 30 May 2026 13:43:43 -0700 Subject: [PATCH 16/21] Networks and Sites: Pass the site ID to handle_network_bulk_actions on network screens. The handle_network_bulk_actions-{$screen} filter is documented as receiving a fourth $site_id argument, which the per-site Network Admin screens (site-themes, site-users, sites) pass. The network-wide Themes and Users bulk-action screens omitted it. These screens act across the whole network rather than a single site, so pass 0 -- the same int "no specific site" value site-themes.php uses when no id is in the request -- to match the documented four-argument signature. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-admin/network/themes.php | 2 +- src/wp-admin/network/users.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/network/themes.php b/src/wp-admin/network/themes.php index 763a13712a59b..8a27669f73b67 100644 --- a/src/wp-admin/network/themes.php +++ b/src/wp-admin/network/themes.php @@ -293,7 +293,7 @@ check_admin_referer( 'bulk-themes' ); /** This action is documented in wp-admin/network/site-themes.php */ - $referer = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $referer, $action, $themes ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + $referer = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $referer, $action, $themes, 0 ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores wp_safe_redirect( $referer ); exit; diff --git a/src/wp-admin/network/users.php b/src/wp-admin/network/users.php index 29238cd887034..eb279a804bdb7 100644 --- a/src/wp-admin/network/users.php +++ b/src/wp-admin/network/users.php @@ -157,7 +157,7 @@ $user_ids = (array) $_POST['allusers']; /** This action is documented in wp-admin/network/site-themes.php */ - $sendback = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $sendback, $doaction, $user_ids ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + $sendback = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $sendback, $doaction, $user_ids, 0 ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores wp_safe_redirect( $sendback ); exit; From 87654642de77ae1b3c82a01735ea339d5cea358f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 30 May 2026 14:28:43 -0700 Subject: [PATCH 17/21] Comments, REST API: Stop passing an undocumented argument to the_content and the_excerpt. Both the_content and the_excerpt are documented (in wp-includes/post-template.php) as receiving only the content/excerpt string, but two callers passed an extra argument: - do_trackbacks() passed $post->ID to the_content. - WP_REST_Revisions_Controller::prepare_excerpt_response() passed $post to the_excerpt. Neither argument is part of the documented signature, and callbacks attached to these filters do not receive it. Drop the extra argument so the invocations match the documented one-parameter signature. The $post parameter of prepare_excerpt_response() is left in place as it is part of the method's established (overridable) signature. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/comment.php | 2 +- .../rest-api/endpoints/class-wp-rest-revisions-controller.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 70d5c03b378f4..5febda87107e7 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -3111,7 +3111,7 @@ function do_trackbacks( $post ) { if ( empty( $post->post_excerpt ) ) { /** This filter is documented in wp-includes/post-template.php */ - $excerpt = apply_filters( 'the_content', $post->post_content, $post->ID ); + $excerpt = apply_filters( 'the_content', $post->post_content ); } else { /** This filter is documented in wp-includes/post-template.php */ $excerpt = apply_filters( 'the_excerpt', $post->post_excerpt ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index 73a888d6eac48..f4c5cb483d105 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -911,7 +911,7 @@ public function get_collection_params() { protected function prepare_excerpt_response( $excerpt, $post ) { /** This filter is documented in wp-includes/post-template.php */ - $excerpt = apply_filters( 'the_excerpt', $excerpt, $post ); + $excerpt = apply_filters( 'the_excerpt', $excerpt ); if ( empty( $excerpt ) ) { return ''; From faa7cf0fc774c0b159e83eef58da7c75f0d5539e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 30 May 2026 17:04:10 -0700 Subject: [PATCH 18/21] Add missing EOL at EOF --- .../phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php | 2 +- tests/phpstan/HookDocBlock.php | 2 +- tests/phpstan/HookDocsVisitor.php | 2 +- tests/phpstan/HookDocumentationRule.php | 2 +- tests/phpstan/HookParamCountRule.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php b/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php index 5fd6b25ab5909..54b44ec03933a 100644 --- a/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php +++ b/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php @@ -104,4 +104,4 @@ public function getTypeFromFunctionCall( FunctionReflection $function_reflection return $default; } -} \ No newline at end of file +} diff --git a/tests/phpstan/HookDocBlock.php b/tests/phpstan/HookDocBlock.php index 5425f8aafb308..cc6befcddd9fb 100644 --- a/tests/phpstan/HookDocBlock.php +++ b/tests/phpstan/HookDocBlock.php @@ -634,4 +634,4 @@ private static function getNullableNodeComment( FuncCall $node ): ?Doc { $doc = $node->getAttribute( 'latestDocComment' ); return $doc; } -} \ No newline at end of file +} diff --git a/tests/phpstan/HookDocsVisitor.php b/tests/phpstan/HookDocsVisitor.php index 163964e485e35..fbf557194958e 100644 --- a/tests/phpstan/HookDocsVisitor.php +++ b/tests/phpstan/HookDocsVisitor.php @@ -78,4 +78,4 @@ public function enterNode( Node $node ): ?Node { return null; } -} \ No newline at end of file +} diff --git a/tests/phpstan/HookDocumentationRule.php b/tests/phpstan/HookDocumentationRule.php index a89d06e242204..55e734d285891 100644 --- a/tests/phpstan/HookDocumentationRule.php +++ b/tests/phpstan/HookDocumentationRule.php @@ -143,4 +143,4 @@ public function processNode( Node $node, Scope $scope ): array { ->build(), ); } -} \ No newline at end of file +} diff --git a/tests/phpstan/HookParamCountRule.php b/tests/phpstan/HookParamCountRule.php index 0068623593241..7307094a79923 100644 --- a/tests/phpstan/HookParamCountRule.php +++ b/tests/phpstan/HookParamCountRule.php @@ -206,4 +206,4 @@ private static function hookLabel( FuncCall $node ): string { return ''; } -} \ No newline at end of file +} From 286ebd5ff493d2e258da54f73d5021d91e45b926 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 30 May 2026 17:30:46 -0700 Subject: [PATCH 19/21] Build/Test Tools: Scope hook docblocks to the node that introduces them. HookDocsVisitor cleared the tracked docblock only at statement boundaries, so a docblock attached to an array item (or another non-statement expression) leaked to following sibling items. That could make an undocumented hook in a later array element appear documented by an earlier element's docblock, a false negative for the hook documentation and argument-count rules. Track the docblock on a stack and restore the previous value when the introducing node is left, so a docblock applies only to that node's descendants while a statement-level docblock still flows into its nested expressions. This surfaced one genuinely undocumented-looking call: in get_inline_data() the editable_slug reference comment sat inside an echo concatenation rather than before a statement. Hoist the filtered value into a documented $editable_slug variable before the echo so the reference precedes the call, with identical output. Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-admin/includes/template.php | 8 +++-- tests/phpstan/HookDocsVisitor.php | 50 +++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/wp-admin/includes/template.php b/src/wp-admin/includes/template.php index 6680bca89691a..691af38bc5a2d 100644 --- a/src/wp-admin/includes/template.php +++ b/src/wp-admin/includes/template.php @@ -316,11 +316,13 @@ function get_inline_data( $post ) { $title = esc_textarea( trim( $post->post_title ) ); + /** This filter is documented in wp-admin/edit-tag-form.php */ + $editable_slug = apply_filters( 'editable_slug', $post->post_name, $post ); + echo '