From a938362eafb22bcc1bcd4bfe84d4156e25ae9676 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 23 Jul 2026 19:36:59 +0200 Subject: [PATCH 01/19] Test the docblock mangling --- tests/phpunit/tests/export/docblocks.inc | 10 ++++++++++ tests/phpunit/tests/export/docblocks.php | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index 2762463..bb846ca 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -40,6 +40,16 @@ function test_func( $var, $num ) { return true; } +/** + * Tests special characters in documentation. + * + * ```php + * true === wp_is_valid_utf8( '✏' ); + * false === wp_is_valid_utf8( "just \xC0 test" ); + * ``` + */ +function test_special_characters() {} + /** * This is a class docblock. * diff --git a/tests/phpunit/tests/export/docblocks.php b/tests/phpunit/tests/export/docblocks.php index ba73e3d..3789c30 100644 --- a/tests/phpunit/tests/export/docblocks.php +++ b/tests/phpunit/tests/export/docblocks.php @@ -230,6 +230,19 @@ public function test_function_docblocks() { ); } + /** + * Test that special characters in documentation are preserved. + */ + public function test_special_characters_are_preserved() { + $this->assertFunctionHasDocs( + 'test_special_characters', + array( + 'long_description' => '
true === wp_is_valid_utf8( \'✏\' );' . "\n"
+					. 'false === wp_is_valid_utf8( "just \\xC0 test" );
', + ) + ); + } + /** * Test that class docs are exported. */ From 2eafef10d447472fd01c773b8267f81ad4b721fc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 23 Jul 2026 19:41:45 +0200 Subject: [PATCH 02/19] Stop rewriting documentation --- lib/runner.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/runner.php b/lib/runner.php index 70e77d9..dfda240 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -152,10 +152,9 @@ function parse_files( $files, $root ) { /* * nikic/php-parser in version 3 started adding a namespace prefix * at the start of global names, but this is different than how the - * documentation was previously generated. this removes those prefixes - * by removing a leading reverse solidus (\) when no other reverse - * solidus appears before the end of a sequence of PHP identifier - * characters. + * documentation was previously generated. This removes those prefixes + * only at the start of an output value so embedded reverse soliduses (\) + * in documentation and other source text remain untouched. */ array_walk_recursive( $output, @@ -163,8 +162,8 @@ static function( &$value ) { if ( is_string( $value ) ) { // "\wp_kses()" -> "wp_kses()" $without_global_namespace = preg_replace( - '~(^|\p{Z})\\\\([A-Z_a-z\x80-\xFF][0-9A-Z_a-z\x80-\xFF]*)([:(\p{Z}]|->|$)~', - '$1$2$3', + '~^\\\\([A-Z_a-z\x80-\xFF][0-9A-Z_a-z\x80-\xFF]*)([:(\p{Z}]|->|$)~', + '$1$2', $value, ); From cc03c2e9cb23a3294b2656ae20637d894439a83f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 23 Jul 2026 19:59:43 +0200 Subject: [PATCH 03/19] Test names without eating text --- tests/phpunit/tests/export/docblocks.inc | 5 ++ tests/phpunit/tests/export/docblocks.php | 12 +++++ tests/phpunit/tests/export/global-names.inc | 14 ++++++ tests/phpunit/tests/export/global-names.php | 55 +++++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 tests/phpunit/tests/export/global-names.inc create mode 100644 tests/phpunit/tests/export/global-names.php diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index bb846ca..e691bb3 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -50,6 +50,11 @@ function test_func( $var, $num ) { */ function test_special_characters() {} +/** + * \xC0 starts this description. + */ +function test_leading_escape_sequence() {} + /** * This is a class docblock. * diff --git a/tests/phpunit/tests/export/docblocks.php b/tests/phpunit/tests/export/docblocks.php index 3789c30..dc902f0 100644 --- a/tests/phpunit/tests/export/docblocks.php +++ b/tests/phpunit/tests/export/docblocks.php @@ -243,6 +243,18 @@ public function test_special_characters_are_preserved() { ); } + /** + * Test that a leading escape sequence in documentation is preserved. + */ + public function test_leading_escape_sequence_is_preserved() { + $this->assertFunctionHasDocs( + 'test_leading_escape_sequence', + array( + 'description' => '\\xC0 starts this description.', + ) + ); + } + /** * Test that class docs are exported. */ diff --git a/tests/phpunit/tests/export/global-names.inc b/tests/phpunit/tests/export/global-names.inc new file mode 100644 index 0000000..3dd9998 --- /dev/null +++ b/tests/phpunit/tests/export/global-names.inc @@ -0,0 +1,14 @@ +export_data['functions'][0]; + + $this->assertEquals( + array( 'Global_Alias' => 'Global_Alias_Source' ), + $function['aliases'] + ); + $this->assertEquals( 'Global_Parameter', $function['arguments'][0]['type'] ); + $this->assertEquals( + array( 'Global_Doc_Type' ), + $function['doc']['tags'][0]['types'] + ); + } + + /** + * Test class metadata. + */ + public function test_class_metadata() { + $class = $this->export_data['classes'][0]; + + $this->assertEquals( 'Global_Parent', $class['extends'] ); + $this->assertEquals( array( 'Global_Interface' ), $class['implements'] ); + } + + /** + * Test method-use metadata. + */ + public function test_method_use_metadata() { + $this->assertFileUsesMethod( + array( + 'name' => 'global_method', + 'line' => 14, + 'end_line' => 14, + 'class' => 'Global_Class', + 'static' => true, + ) + ); + } +} From 778aef5de4e14810a04f75969de0b02fca3ec911 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 23 Jul 2026 20:01:43 +0200 Subject: [PATCH 04/19] Test hook names verbatim --- tests/phpunit/tests/export/hooks.inc | 1 + tests/phpunit/tests/export/hooks.php | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/tests/phpunit/tests/export/hooks.inc b/tests/phpunit/tests/export/hooks.inc index 54cf8f8..53a65aa 100644 --- a/tests/phpunit/tests/export/hooks.inc +++ b/tests/phpunit/tests/export/hooks.inc @@ -6,3 +6,4 @@ do_action( $variable . '-action' ); do_action( "another-{$variable}-action" ); do_action( 'hook_' . $object->property . '_pre' ); apply_filters( 'plain_filter', $variable, $filter_context ); +do_action( '\xC0 hook' ); diff --git a/tests/phpunit/tests/export/hooks.php b/tests/phpunit/tests/export/hooks.php index cf9d608..10d4b79 100644 --- a/tests/phpunit/tests/export/hooks.php +++ b/tests/phpunit/tests/export/hooks.php @@ -45,5 +45,9 @@ public function test_hook_names_standardized() { 'arguments.1' => '$filter_context' ) ); + + $this->assertFileContainsHook( + array( 'name' => '\\xC0 hook', 'line' => 9 ) + ); } } From 8de43d1463d479456246cce70ce186c757a45861 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 23 Jul 2026 20:05:21 +0200 Subject: [PATCH 05/19] Test PHP string semantics --- tests/phpunit/tests/export/hooks.inc | 2 ++ tests/phpunit/tests/export/hooks.php | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/tests/phpunit/tests/export/hooks.inc b/tests/phpunit/tests/export/hooks.inc index 53a65aa..88a3736 100644 --- a/tests/phpunit/tests/export/hooks.inc +++ b/tests/phpunit/tests/export/hooks.inc @@ -7,3 +7,5 @@ do_action( "another-{$variable}-action" ); do_action( 'hook_' . $object->property . '_pre' ); apply_filters( 'plain_filter', $variable, $filter_context ); do_action( '\xC0 hook' ); +do_action( "\x09tab" ); +do_action( '\x09tab' ); diff --git a/tests/phpunit/tests/export/hooks.php b/tests/phpunit/tests/export/hooks.php index 10d4b79..0e9fe29 100644 --- a/tests/phpunit/tests/export/hooks.php +++ b/tests/phpunit/tests/export/hooks.php @@ -49,5 +49,13 @@ public function test_hook_names_standardized() { $this->assertFileContainsHook( array( 'name' => '\\xC0 hook', 'line' => 9 ) ); + + $this->assertFileContainsHook( + array( 'name' => "\ttab", 'line' => 10 ) + ); + + $this->assertFileContainsHook( + array( 'name' => '\\x09tab', 'line' => 11 ) + ); } } From 877372b3acdf294b515130def292b1d1b7f4d7bc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 23 Jul 2026 20:07:33 +0200 Subject: [PATCH 06/19] Stop confusing names with text --- lib/class-hook-reflector.php | 5 +++ lib/runner.php | 70 ++++++++++++++++++++---------------- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/lib/class-hook-reflector.php b/lib/class-hook-reflector.php index 6b979b9..2d4b815 100644 --- a/lib/class-hook-reflector.php +++ b/lib/class-hook-reflector.php @@ -13,6 +13,11 @@ class Hook_Reflector extends BaseReflector { * @return string */ public function getName() { + $name = $this->node->args[0]->value; + if ( $name instanceof \PhpParser\Node\Scalar\String_ ) { + return $name->value; + } + $printer = new \PhpParser\PrettyPrinter\Standard(); return $this->cleanupName( $printer->prettyPrintExpr( $this->node->args[0]->value ) ); } diff --git a/lib/runner.php b/lib/runner.php index dfda240..0ce5d16 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -102,7 +102,7 @@ function parse_files( $files, $root ) { $func = array( 'name' => $function->getShortName(), 'namespace' => $function->getNamespace(), - 'aliases' => $function->getNamespaceAliases(), + 'aliases' => strip_global_namespace_prefixes( $function->getNamespaceAliases() ), 'line' => $function->getLineNumber(), 'end_line' => $function->getNode()->getAttribute( 'endLine' ), 'arguments' => export_arguments( $function->getArguments() ), @@ -132,8 +132,8 @@ function parse_files( $files, $root ) { 'end_line' => $class->getNode()->getAttribute( 'endLine' ), 'final' => $class->isFinal(), 'abstract' => $class->isAbstract(), - 'extends' => $class->getParentClass(), - 'implements' => $class->getInterfaces(), + 'extends' => strip_global_namespace_prefix( $class->getParentClass() ), + 'implements' => strip_global_namespace_prefixes( $class->getInterfaces() ), 'properties' => export_properties( $class->getProperties(), $class_setup_blueprints, $path ), 'methods' => export_methods( $class->getMethods(), $class_setup_blueprints, $path ), 'doc' => $class_doc, @@ -149,32 +149,41 @@ function parse_files( $files, $root ) { throw $e; } - /* - * nikic/php-parser in version 3 started adding a namespace prefix - * at the start of global names, but this is different than how the - * documentation was previously generated. This removes those prefixes - * only at the start of an output value so embedded reverse soliduses (\) - * in documentation and other source text remain untouched. - */ - array_walk_recursive( - $output, - static function( &$value ) { - if ( is_string( $value ) ) { - // "\wp_kses()" -> "wp_kses()" - $without_global_namespace = preg_replace( - '~^\\\\([A-Z_a-z\x80-\xFF][0-9A-Z_a-z\x80-\xFF]*)([:(\p{Z}]|->|$)~', - '$1$2', - $value, - ); + return $output; +} - if ( $value !== $without_global_namespace ) { - $value = $without_global_namespace; - } - } - } +/** + * Remove a synthetic leading namespace prefix from a global name. + * + * @param mixed $name Name to normalize. + * + * @return mixed + */ +function strip_global_namespace_prefix( $name ) { + if ( ! is_string( $name ) ) { + return $name; + } + + return preg_replace( + '~^\\\\([A-Z_a-z\x80-\xFF][0-9A-Z_a-z\x80-\xFF]*)([:(\p{Z}]|->|$)~', + '$1$2', + $name ); +} - return $output; +/** + * Remove synthetic leading namespace prefixes from global names. + * + * @param array $names Names to normalize. + * + * @return array + */ +function strip_global_namespace_prefixes( array $names ) { + foreach ( $names as $key => $name ) { + $names[ $key ] = strip_global_namespace_prefix( $name ); + } + + return $names; } /** @@ -529,7 +538,7 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), 'content' => preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) ), ); if ( method_exists( $tag, 'getTypes' ) ) { - $tag_data['types'] = $tag->getTypes(); + $tag_data['types'] = strip_global_namespace_prefixes( $tag->getTypes() ); } if ( method_exists( $tag, 'getLink' ) ) { $tag_data['link'] = $tag->getLink(); @@ -697,7 +706,7 @@ function export_arguments( array $arguments ) { $output[] = array( 'name' => $argument->getName(), 'default' => $argument->getDefault(), - 'type' => $argument->getType(), + 'type' => strip_global_namespace_prefix( $argument->getType() ), ); } @@ -745,7 +754,7 @@ function export_methods( array $methods, array $inherited_setup_blueprints = arr $method_data = array( 'name' => $method->getShortName(), 'namespace' => $method->getNamespace(), - 'aliases' => $method->getNamespaceAliases(), + 'aliases' => strip_global_namespace_prefixes( $method->getNamespaceAliases() ), 'line' => $method->getLineNumber(), 'end_line' => $method->getNode()->getAttribute( 'endLine' ), 'final' => $method->isFinal(), @@ -1282,7 +1291,7 @@ function export_uses( array $uses ) { case 'methods': $out[ $type ][] = array( 'name' => $name[1], - 'class' => $name[0], + 'class' => strip_global_namespace_prefix( $name[0] ), 'static' => $element->isStatic(), 'line' => $element->getLineNumber(), 'end_line' => $element->getNode()->getAttribute( 'endLine' ), @@ -1291,6 +1300,7 @@ function export_uses( array $uses ) { default: case 'functions': + $name = strip_global_namespace_prefix( $name ); $out[ $type ][] = array( 'name' => $name, 'line' => $element->getLineNumber(), From f2d6745e9e8619ff32612caf1817620241ba663f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 23 Jul 2026 22:41:34 +0200 Subject: [PATCH 07/19] Preserve expression output --- lib/class-hook-reflector.php | 2 +- lib/class-pretty-printer.php | 19 +++++- lib/runner.php | 68 ++++++++++++++++++--- tests/phpunit/tests/export/global-names.inc | 22 ++++++- tests/phpunit/tests/export/global-names.php | 48 ++++++++++++++- 5 files changed, 145 insertions(+), 14 deletions(-) diff --git a/lib/class-hook-reflector.php b/lib/class-hook-reflector.php index 2d4b815..be10164 100644 --- a/lib/class-hook-reflector.php +++ b/lib/class-hook-reflector.php @@ -18,7 +18,7 @@ public function getName() { return $name->value; } - $printer = new \PhpParser\PrettyPrinter\Standard(); + $printer = new Pretty_Printer(); return $this->cleanupName( $printer->prettyPrintExpr( $this->node->args[0]->value ) ); } diff --git a/lib/class-pretty-printer.php b/lib/class-pretty-printer.php index 7cfd0c5..67e6b9d 100644 --- a/lib/class-pretty-printer.php +++ b/lib/class-pretty-printer.php @@ -5,7 +5,24 @@ /** * Extends default printer for arguments. */ -class Pretty_Printer extends \PhpParser\PrettyPrinter\Standard { +class Pretty_Printer extends \phpDocumentor\Reflection\PrettyPrinter { + /** + * Print names as they appeared before PHP-Parser's name resolution. + * + * PHP-Parser represents resolved global names as fully-qualified names. The + * leading namespace separator is useful in an AST, but adding it to exported + * source expressions changes the established JSON output. + * + * @param \PhpParser\Node\Name\FullyQualified $node Fully-qualified name. + * + * @return string Printed name. + */ + protected function pName_FullyQualified( \PhpParser\Node\Name\FullyQualified $node ): string { + $name = $node->toString(); + + return false === strpos( $name, '\\' ) ? $name : '\\' . $name; + } + /** * Pretty prints an argument. * diff --git a/lib/runner.php b/lib/runner.php index 0ce5d16..19b1d2e 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -90,7 +90,7 @@ function parse_files( $files, $root ) { $out['constants'][] = array( 'name' => $constant->getShortName(), 'line' => $constant->getLineNumber(), - 'value' => $constant->getValue(), + 'value' => export_expression( $constant->getNode()->value ), ); } @@ -186,6 +186,48 @@ function strip_global_namespace_prefixes( array $names ) { return $names; } +/** + * Export an expression without PHP-Parser's synthetic global namespace prefixes. + * + * @param null|\PhpParser\Node\Expr $expression Expression to export. + * + * @return null|string + */ +function export_expression( $expression ) { + if ( null === $expression ) { + return null; + } + + static $printer = null; + + if ( null === $printer ) { + $printer = new Pretty_Printer(); + } + + return $printer->prettyPrintExpr( $expression ); +} + +/** + * Remove synthetic global namespace prefixes from inline DocBlock references. + * + * @param string $text Formatted DocBlock text. + * + * @return string + */ +function strip_global_namespace_prefixes_from_inline_references( $text ) { + return preg_replace_callback( + '~{@(?:link|see)\s+([^}\s]+)~', + static function( $matches ) { + return str_replace( + $matches[1], + strip_global_namespace_prefix( $matches[1] ), + $matches[0] + ); + }, + $text + ); +} + /** * Fixes newline handling in parsed text. * @@ -514,8 +556,12 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), } $output = array( - 'description' => preg_replace( '/[\n\r]+/', ' ', $short_description ), - 'long_description' => format_long_description( strip_docblock_code_snippet_fences( $raw_long_description, $fences ) ), + 'description' => strip_global_namespace_prefixes_from_inline_references( + preg_replace( '/[\n\r]+/', ' ', $short_description ) + ), + 'long_description' => strip_global_namespace_prefixes_from_inline_references( + format_long_description( strip_docblock_code_snippet_fences( $raw_long_description, $fences ) ) + ), 'tags' => array(), ); @@ -535,19 +581,21 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), $tag_data = array( 'name' => $tag->getName(), - 'content' => preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) ), + 'content' => strip_global_namespace_prefixes_from_inline_references( + preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) ) + ), ); if ( method_exists( $tag, 'getTypes' ) ) { $tag_data['types'] = strip_global_namespace_prefixes( $tag->getTypes() ); } if ( method_exists( $tag, 'getLink' ) ) { - $tag_data['link'] = $tag->getLink(); + $tag_data['link'] = strip_global_namespace_prefix( $tag->getLink() ); } if ( method_exists( $tag, 'getVariableName' ) ) { $tag_data['variable'] = $tag->getVariableName(); } if ( method_exists( $tag, 'getReference' ) ) { - $tag_data['refers'] = $tag->getReference(); + $tag_data['refers'] = strip_global_namespace_prefix( $tag->getReference() ); } if ( method_exists( $tag, 'getVersion' ) ) { // Version string. @@ -557,7 +605,9 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), } // Description string. if ( method_exists( $tag, 'getDescription' ) ) { - $description = preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) ); + $description = strip_global_namespace_prefixes_from_inline_references( + preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) ) + ); if ( ! empty( $description ) ) { $tag_data['description'] = $description; } @@ -705,7 +755,7 @@ function export_arguments( array $arguments ) { foreach ( $arguments as $argument ) { $output[] = array( 'name' => $argument->getName(), - 'default' => $argument->getDefault(), + 'default' => export_expression( $argument->getNode()->default ), 'type' => strip_global_namespace_prefix( $argument->getType() ), ); } @@ -728,7 +778,7 @@ function export_properties( array $properties, array $inherited_setup_blueprints 'name' => $property->getName(), 'line' => $property->getLineNumber(), 'end_line' => $property->getNode()->getAttribute( 'endLine' ), - 'default' => $property->getDefault(), + 'default' => export_expression( $property->getNode()->default ), // 'final' => $property->isFinal(), 'static' => $property->isStatic(), 'visibility' => $property->getVisibility(), diff --git a/tests/phpunit/tests/export/global-names.inc b/tests/phpunit/tests/export/global-names.inc index 3dd9998..54bd2f4 100644 --- a/tests/phpunit/tests/export/global-names.inc +++ b/tests/phpunit/tests/export/global-names.inc @@ -5,10 +5,30 @@ use \Global_Alias_Source as Global_Alias; /** * Documents a global type. * + * Calls {@see Global_Doc_Function()} while preserving \xC0 as documentation. + * * @param Global_Doc_Type $value Value. */ -function documented_global_type( \Global_Parameter $value ) {} +function documented_global_type( \Global_Parameter $value, $enabled = true, $nothing = null, $mode = GLOBAL_MODE, $escape = '\xC0', $namespaced = \Vendor\GLOBAL_MODE ) {} class Global_Child extends \Global_Parent implements \Global_Interface {} \Global_Class::global_method(); + +const GLOBAL_CONST = GLOBAL_VALUE; + +define( 'GLOBAL_DEFINED_CONST', global_default( GLOBAL_VALUE ) ); +define( 'NAMESPACED_DEFINED_CONST', \Vendor\global_default( \Vendor\GLOBAL_VALUE ) ); + +class Global_Defaults { + public $enabled = false; + public $mode = GLOBAL_MODE; + + public function create() { + return ( new \Global_Class() )->global_method(); + } + + public function create_namespaced() { + return ( new \Vendor\Global_Class() )->global_method(); + } +} diff --git a/tests/phpunit/tests/export/global-names.php b/tests/phpunit/tests/export/global-names.php index 7972a8b..7c90476 100644 --- a/tests/phpunit/tests/export/global-names.php +++ b/tests/phpunit/tests/export/global-names.php @@ -22,6 +22,19 @@ public function test_function_metadata() { $function['aliases'] ); $this->assertEquals( 'Global_Parameter', $function['arguments'][0]['type'] ); + $this->assertSame( 'true', $function['arguments'][1]['default'] ); + $this->assertSame( 'null', $function['arguments'][2]['default'] ); + $this->assertSame( 'GLOBAL_MODE', $function['arguments'][3]['default'] ); + $this->assertSame( "'\\xC0'", $function['arguments'][4]['default'] ); + $this->assertSame( '\\Vendor\\GLOBAL_MODE', $function['arguments'][5]['default'] ); + $this->assertStringContainsString( + '{@see Global_Doc_Function()}', + $function['doc']['long_description'] + ); + $this->assertStringContainsString( + '\\xC0 as documentation', + $function['doc']['long_description'] + ); $this->assertEquals( array( 'Global_Doc_Type' ), $function['doc']['tags'][0]['types'] @@ -38,6 +51,37 @@ public function test_class_metadata() { $this->assertEquals( array( 'Global_Interface' ), $class['implements'] ); } + /** + * Test expression metadata. + */ + public function test_expression_metadata() { + $this->assertSame( 'GLOBAL_VALUE', $this->export_data['constants'][0]['value'] ); + $this->assertSame( + 'global_default(GLOBAL_VALUE)', + $this->export_data['constants'][1]['value'] + ); + $this->assertSame( + '\\Vendor\\global_default(\\Vendor\\GLOBAL_VALUE)', + $this->export_data['constants'][2]['value'] + ); + + $class = $this->export_data['classes'][1]; + $this->assertSame( 'false', $class['properties'][0]['default'] ); + $this->assertSame( 'GLOBAL_MODE', $class['properties'][1]['default'] ); + + $method = $class['methods'][0]; + $this->assertSame( + 'new Global_Class()', + $method['uses']['methods'][0]['class'] + ); + + $method = $class['methods'][1]; + $this->assertSame( + 'new \\Vendor\\Global_Class()', + $method['uses']['methods'][0]['class'] + ); + } + /** * Test method-use metadata. */ @@ -45,8 +89,8 @@ public function test_method_use_metadata() { $this->assertFileUsesMethod( array( 'name' => 'global_method', - 'line' => 14, - 'end_line' => 14, + 'line' => 16, + 'end_line' => 16, 'class' => 'Global_Class', 'static' => true, ) From f09a8a9282133ad3dfaa5f7c440c1bb012069f64 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 10 Aug 2026 15:28:15 +0400 Subject: [PATCH 08/19] Export hook names as spelled in the source `Hook_Reflector::getName()` short-circuited on `Scalar\String_` nodes and returned the interpreted string value. Escape sequences were therefore resolved, so `do_action( "\x09tab" )` exported a literal tab and `do_action( "\xC0 bad" )` exported a raw 0xC0 byte. That byte is not valid UTF-8, `json_encode()` returns `false` for it, and `wp parser export` silently produced no JSON at all for the whole run. Route string nodes through `Pretty_Printer` like every other expression. The printer returns php-parser's `rawValue` attribute, which is the source-verbatim spelling, and `cleanupName()` strips the quotes. Also check `json_encode()` for failure in `Command::_get_phpdoc_data()` and fail loudly with `json_last_error_msg()` instead of writing an empty file. --- lib/class-command.php | 9 ++++++++- lib/class-hook-reflector.php | 11 ++++++----- tests/phpunit/tests/export/hooks.inc | 1 + tests/phpunit/tests/export/hooks.php | 23 ++++++++++++++++++++++- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/lib/class-command.php b/lib/class-command.php index ee93eb4..4d9ca02 100644 --- a/lib/class-command.php +++ b/lib/class-command.php @@ -152,7 +152,14 @@ protected function _get_phpdoc_data( $path, $format = 'json' ) { $output = parse_files( $files, $path ); if ( 'json' == $format ) { - return json_encode( $output, JSON_PRETTY_PRINT ); + $json = json_encode( $output, JSON_PRETTY_PRINT ); + + if ( false === $json ) { + WP_CLI::error( sprintf( 'Problem encoding the data from %1$s as JSON: %2$s', $path, json_last_error_msg() ) ); + exit; + } + + return $json; } return $output; diff --git a/lib/class-hook-reflector.php b/lib/class-hook-reflector.php index be10164..f159cdd 100644 --- a/lib/class-hook-reflector.php +++ b/lib/class-hook-reflector.php @@ -10,14 +10,15 @@ class Hook_Reflector extends BaseReflector { /** + * Get the hook name as it is spelled in the source. + * + * The name is printed from the source expression instead of read from the + * interpreted string value. Interpreting escape sequences may produce bytes + * that are not valid UTF-8, which cannot be encoded as JSON. + * * @return string */ public function getName() { - $name = $this->node->args[0]->value; - if ( $name instanceof \PhpParser\Node\Scalar\String_ ) { - return $name->value; - } - $printer = new Pretty_Printer(); return $this->cleanupName( $printer->prettyPrintExpr( $this->node->args[0]->value ) ); } diff --git a/tests/phpunit/tests/export/hooks.inc b/tests/phpunit/tests/export/hooks.inc index 88a3736..9b581f9 100644 --- a/tests/phpunit/tests/export/hooks.inc +++ b/tests/phpunit/tests/export/hooks.inc @@ -9,3 +9,4 @@ apply_filters( 'plain_filter', $variable, $filter_context ); do_action( '\xC0 hook' ); do_action( "\x09tab" ); do_action( '\x09tab' ); +do_action( "\xC0 bad" ); diff --git a/tests/phpunit/tests/export/hooks.php b/tests/phpunit/tests/export/hooks.php index 0e9fe29..07a0a47 100644 --- a/tests/phpunit/tests/export/hooks.php +++ b/tests/phpunit/tests/export/hooks.php @@ -51,11 +51,32 @@ public function test_hook_names_standardized() { ); $this->assertFileContainsHook( - array( 'name' => "\ttab", 'line' => 10 ) + array( 'name' => '\\x09tab', 'line' => 10 ) ); $this->assertFileContainsHook( array( 'name' => '\\x09tab', 'line' => 11 ) ); } + + /** + * Test that hook names keep escapes that are invalid UTF-8 once interpreted. + */ + public function test_hook_names_keep_invalid_utf8_escapes() { + + $this->assertFileContainsHook( + array( 'name' => '\\xC0 bad', 'line' => 12 ) + ); + } + + /** + * Test that the exported data can be encoded as JSON. + */ + public function test_export_data_is_json_encodable() { + + $this->assertNotFalse( + json_encode( $this->export_data, JSON_PRETTY_PRINT ), + json_last_error_msg() + ); + } } From 354136800692d8e159e324d11abdc779237c598f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 10 Aug 2026 15:29:35 +0400 Subject: [PATCH 09/19] Print heredoc and nowdoc strings with their delimiters The pretty printer inherits an override that returns PHP-Parser's `rawValue` attribute so escape sequences are not interpreted. PHP-Parser sets that attribute to the body of a doc string, without the delimiters, so `apply_filters( 'f', <<getAttribute( 'kind' ); + + if ( + \PhpParser\Node\Scalar\String_::KIND_HEREDOC === $kind || + \PhpParser\Node\Scalar\String_::KIND_NOWDOC === $kind + ) { + return \PhpParser\PrettyPrinter\Standard::pScalar_String( $node ); + } + + return parent::pScalar_String( $node ); + } + /** * Pretty prints an argument. * diff --git a/tests/phpunit/tests/export/hooks.inc b/tests/phpunit/tests/export/hooks.inc index 9b581f9..10bfc9f 100644 --- a/tests/phpunit/tests/export/hooks.inc +++ b/tests/phpunit/tests/export/hooks.inc @@ -10,3 +10,9 @@ do_action( '\xC0 hook' ); do_action( "\x09tab" ); do_action( '\x09tab' ); do_action( "\xC0 bad" ); +apply_filters( 'heredoc_filter', <<assertFileContainsHook( + array( + 'type' => 'filter', + 'name' => 'heredoc_filter', + 'line' => 13, + 'arguments.0' => "<< '2', + ) + ); + + $this->assertFileContainsHook( + array( + 'type' => 'filter', + 'name' => 'nowdoc_filter', + 'line' => 16, + 'arguments.0' => "<<<'EOT'\nnowdoc \$body\nEOT", + 'arguments.1' => '2', + ) + ); + } + /** * Test that the exported data can be encoded as JSON. */ From 7dbfc5bc1c11fcdc171308978f11c65d9f603e1a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 10 Aug 2026 15:30:02 +0400 Subject: [PATCH 10/19] Document the namespaced-file limitation of printed global names `pName_FullyQualified()` prints single-segment fully-qualified names without the leading backslash regardless of namespace context, so inside a namespaced file the printed form denotes a namespaced symbol rather than the global one. This is an accepted limitation because the parser targets global-namespace WordPress core code. --- lib/class-pretty-printer.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/class-pretty-printer.php b/lib/class-pretty-printer.php index 3cf2d5e..b4ccf2f 100644 --- a/lib/class-pretty-printer.php +++ b/lib/class-pretty-printer.php @@ -13,6 +13,13 @@ class Pretty_Printer extends \phpDocumentor\Reflection\PrettyPrinter { * leading namespace separator is useful in an AST, but adding it to exported * source expressions changes the established JSON output. * + * Single-segment fully-qualified names are therefore printed without the + * leading backslash. Inside a namespaced file the printed form denotes a + * namespaced symbol rather than the global one, for example `\Foo::BAR` is + * printed as `Foo::BAR`, which in a namespaced file would resolve to + * `Vendor\Foo::BAR`. This is an accepted limitation because the parser + * targets global-namespace WordPress core code. + * * @param \PhpParser\Node\Name\FullyQualified $node Fully-qualified name. * * @return string Printed name. From 1d020ff573812af1cd204ea52460e1bf55c38036 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 10 Aug 2026 15:41:35 +0400 Subject: [PATCH 11/19] Preserve inline references appearing in code samples The global namespace prefixes are stripped from inline `{@link}` and `{@see}` references after the DocBlock text has been rendered, so the stripping also reached into rendered code regions and silently deleted the backslash an author had written in a verbatim code sample. Carve out `` regions before stripping, the same way `fix_newlines()` protects the newlines in those regions, so code samples are exported as they were written. --- lib/runner.php | 20 ++++++++- tests/phpunit/tests/export/global-names.inc | 14 ++++++ tests/phpunit/tests/export/global-names.php | 48 +++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/lib/runner.php b/lib/runner.php index 19b1d2e..9932423 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -210,12 +210,27 @@ function export_expression( $expression ) { /** * Remove synthetic global namespace prefixes from inline DocBlock references. * + * A special exception is made for text appearing in `` and `
` tags, as code
+ * samples are reproduced verbatim and any prefix appearing in them was written by hand.
+ *
  * @param string $text Formatted DocBlock text.
  *
  * @return string
  */
 function strip_global_namespace_prefixes_from_inline_references( $text ) {
-	return preg_replace_callback(
+	// Non-naturally occurring string to use as temporary replacement.
+	$replacement_string = '{{{{{}}}}}';
+
+	// Replace inline tag openings within 'code' and 'pre' tags with replacement string.
+	$text = preg_replace_callback(
+		"/(]*>)(.+)(?=<\/code>)/sU",
+		function ( $matches ) use ( $replacement_string ) {
+			return str_replace( '{@', $replacement_string, $matches[1] . $matches[2] );
+		},
+		$text
+	);
+
+	$text = preg_replace_callback(
 		'~{@(?:link|see)\s+([^}\s]+)~',
 		static function( $matches ) {
 			return str_replace(
@@ -226,6 +241,9 @@ static function( $matches ) {
 		},
 		$text
 	);
+
+	// Restore inline tag openings into code blocks.
+	return str_replace( $replacement_string, '{@', $text );
 }
 
 /**
diff --git a/tests/phpunit/tests/export/global-names.inc b/tests/phpunit/tests/export/global-names.inc
index 54bd2f4..b1e1d8e 100644
--- a/tests/phpunit/tests/export/global-names.inc
+++ b/tests/phpunit/tests/export/global-names.inc
@@ -32,3 +32,17 @@ class Global_Defaults {
 		return ( new \Vendor\Global_Class() )->global_method();
 	}
 }
+
+/**
+ * Documents inline references.
+ *
+ * Calls {@see \Global_Doc_Function()} and {@see \Vendor\Thing::m()} in prose.
+ *
+ * Spells `{@see \Global_Inline::method()}` in an inline code span.
+ *
+ * ```php
+ * // Renders {@see \Global_Widget::render()}.
+ * $widget->render();
+ * ```
+ */
+function documented_inline_references() {}
diff --git a/tests/phpunit/tests/export/global-names.php b/tests/phpunit/tests/export/global-names.php
index 7c90476..72cefea 100644
--- a/tests/phpunit/tests/export/global-names.php
+++ b/tests/phpunit/tests/export/global-names.php
@@ -41,6 +41,54 @@ public function test_function_metadata() {
 		);
 	}
 
+	/**
+	 * Test that prefixes are removed from inline references in prose.
+	 */
+	public function test_prefixed_inline_reference_metadata() {
+		$function = $this->export_data['functions'][1];
+
+		$this->assertStringContainsString(
+			'{@see Global_Doc_Function()}',
+			$function['doc']['long_description']
+		);
+	}
+
+	/**
+	 * Test that namespaced inline references keep their prefix.
+	 */
+	public function test_namespaced_inline_reference_metadata() {
+		$function = $this->export_data['functions'][1];
+
+		$this->assertStringContainsString(
+			'{@see \\Vendor\\Thing::m()}',
+			$function['doc']['long_description']
+		);
+	}
+
+	/**
+	 * Test that inline references in code samples are preserved.
+	 */
+	public function test_code_sample_inline_reference_metadata() {
+		$function = $this->export_data['functions'][1];
+
+		$this->assertStringContainsString(
+			'// Renders {@see \\Global_Widget::render()}.',
+			$function['doc']['long_description']
+		);
+	}
+
+	/**
+	 * Test that inline references in inline code spans are preserved.
+	 */
+	public function test_inline_code_span_inline_reference_metadata() {
+		$function = $this->export_data['functions'][1];
+
+		$this->assertStringContainsString(
+			'{@see \\Global_Inline::method()}',
+			$function['doc']['long_description']
+		);
+	}
+
 	/**
 	 * Test class metadata.
 	 */

From 460450cf0cce5347b10717eeeb68f7275ceb2695 Mon Sep 17 00:00:00 2001
From: Jon Surrell 
Date: Mon, 10 Aug 2026 20:08:49 +0400
Subject: [PATCH 12/19] Strip surrounding quotes from hook names containing a
 quote

The quote-stripping pattern required a body free of quote characters, so
a hook name that contained one, like `do_action( "it's" );`, was exported
with the quotes that surround it in the source.

Match the opening quote and require the same quote at the end, allowing
the body to hold the other quote character or an escaped copy of the
delimiter. Only that pair is stripped; the body keeps its source spelling,
so `do_action( 'it\'s' );` exports as `it\'s`. Concatenated expressions
still fall through to the dynamic-name handling below.
---
 lib/class-hook-reflector.php         |  6 ++++--
 tests/phpunit/tests/export/hooks.inc |  2 ++
 tests/phpunit/tests/export/hooks.php | 15 +++++++++++++++
 3 files changed, 21 insertions(+), 2 deletions(-)

diff --git a/lib/class-hook-reflector.php b/lib/class-hook-reflector.php
index f159cdd..29036e5 100644
--- a/lib/class-hook-reflector.php
+++ b/lib/class-hook-reflector.php
@@ -32,8 +32,10 @@ private function cleanupName( $name ) {
 		$matches = array();
 
 		// quotes on both ends of a string
-		if ( preg_match( '/^[\'"]([^\'"]*)[\'"]$/', $name, $matches ) ) {
-			return $matches[1];
+		// The quoted body may contain the other quote character, as in "it's",
+		// or an escaped copy of the quote that delimits it, as in 'it\'s'.
+		if ( preg_match( '/^([\'"])((?:(?!\1)[^\\\\]|\\\\.)*)\1$/s', $name, $matches ) ) {
+			return $matches[2];
 		}
 
 		// two concatenated things, last one of them a variable
diff --git a/tests/phpunit/tests/export/hooks.inc b/tests/phpunit/tests/export/hooks.inc
index 10bfc9f..6a8b466 100644
--- a/tests/phpunit/tests/export/hooks.inc
+++ b/tests/phpunit/tests/export/hooks.inc
@@ -16,3 +16,5 @@ EOT, 2 );
 apply_filters( 'nowdoc_filter', <<<'EOT'
 nowdoc $body
 EOT, 2 );
+do_action( "it's" );
+do_action( 'it\'s' );
diff --git a/tests/phpunit/tests/export/hooks.php b/tests/phpunit/tests/export/hooks.php
index 0fa4eb5..38d8624 100644
--- a/tests/phpunit/tests/export/hooks.php
+++ b/tests/phpunit/tests/export/hooks.php
@@ -95,6 +95,21 @@ public function test_hook_arguments_keep_doc_string_delimiters() {
 		);
 	}
 
+	/**
+	 * Test that hook names containing a quote character lose the quotes that
+	 * surround them in the source.
+	 */
+	public function test_hook_names_containing_quotes() {
+
+		$this->assertFileContainsHook(
+			array( 'name' => "it's", 'line' => 19 )
+		);
+
+		$this->assertFileContainsHook(
+			array( 'name' => "it\\'s", 'line' => 20 )
+		);
+	}
+
 	/**
 	 * Test that the exported data can be encoded as JSON.
 	 */

From 4a9d962bb06f5525d17130231350be8c60862efc Mon Sep 17 00:00:00 2001
From: Jon Surrell 
Date: Mon, 10 Aug 2026 19:37:55 +0400
Subject: [PATCH 13/19] Test the class mapping for known factory function
 receivers

`Method_Call_Reflector::_getClassMapping()` maps a handful of WordPress factory
functions to the class they return, so that `get_current_screen()->add_help_tab()`
is exported as a use of `WP_Screen::add_help_tab()`. The lookup never matched
before this branch, because the printed receiver carried a leading backslash
that the mapping keys do not have. Pin the restored behavior with a test.
---
 .../tests/export/uses/class-mapping.inc       |  5 ++++
 .../tests/export/uses/class-mapping.php       | 30 +++++++++++++++++++
 2 files changed, 35 insertions(+)
 create mode 100644 tests/phpunit/tests/export/uses/class-mapping.inc
 create mode 100644 tests/phpunit/tests/export/uses/class-mapping.php

diff --git a/tests/phpunit/tests/export/uses/class-mapping.inc b/tests/phpunit/tests/export/uses/class-mapping.inc
new file mode 100644
index 0000000..08d422c
--- /dev/null
+++ b/tests/phpunit/tests/export/uses/class-mapping.inc
@@ -0,0 +1,5 @@
+add_help_tab( array() );
+}
diff --git a/tests/phpunit/tests/export/uses/class-mapping.php b/tests/phpunit/tests/export/uses/class-mapping.php
new file mode 100644
index 0000000..e306117
--- /dev/null
+++ b/tests/phpunit/tests/export/uses/class-mapping.php
@@ -0,0 +1,30 @@
+assertFunctionUsesMethod(
+			'show_help'
+			, array(
+				'name'     => 'add_help_tab',
+				'line'     => 4,
+				'end_line' => 4,
+				'class'    => 'WP_Screen',
+				'static'   => false,
+			)
+		);
+	}
+}

From 0b4d163b01058573443681595330551599c944ea Mon Sep 17 00:00:00 2001
From: Jon Surrell 
Date: Tue, 18 Aug 2026 16:16:59 +0400
Subject: [PATCH 14/19] Test documentation references as authored

Inline `{@see}`/`{@link}` references in prose and the reference tokens
of `@see`/`@link` tags are documentation text: the export should carry
the author's spelling, backslash included. Pin that, and pin the
boundary on the other side: `@param` types pass through phpDocumentor's
type resolution, which prefixes every non-keyword type with a synthetic
`\`, so the author's spelling is unrecoverable there and the synthetic
prefix must keep being stripped.
---
 tests/phpunit/tests/export/global-names.inc |  5 +++
 tests/phpunit/tests/export/global-names.php | 35 +++++++++++++++++++--
 2 files changed, 37 insertions(+), 3 deletions(-)

diff --git a/tests/phpunit/tests/export/global-names.inc b/tests/phpunit/tests/export/global-names.inc
index b1e1d8e..10948ae 100644
--- a/tests/phpunit/tests/export/global-names.inc
+++ b/tests/phpunit/tests/export/global-names.inc
@@ -44,5 +44,10 @@ class Global_Defaults {
  * // Renders {@see \Global_Widget::render()}.
  * $widget->render();
  * ```
+ *
+ * @see \Global_See_Target() Compares prefixed references.
+ * @see Global_See_Plain() Compares plain references.
+ * @link https://example.com/reference Explains the behavior.
+ * @param \Global_Prefixed_Type $ignored Unused.
  */
 function documented_inline_references() {}
diff --git a/tests/phpunit/tests/export/global-names.php b/tests/phpunit/tests/export/global-names.php
index 72cefea..434b258 100644
--- a/tests/phpunit/tests/export/global-names.php
+++ b/tests/phpunit/tests/export/global-names.php
@@ -7,7 +7,8 @@
 namespace WP_Parser\Tests;
 
 /**
- * Test that synthetic global namespace prefixes are removed from metadata.
+ * Test that synthetic global namespace prefixes are removed from resolved
+ * metadata while documentation text is exported as authored.
  */
 class Export_Global_Names extends Export_UnitTestCase {
 
@@ -42,17 +43,45 @@ public function test_function_metadata() {
 	}
 
 	/**
-	 * Test that prefixes are removed from inline references in prose.
+	 * Test that inline references in prose are preserved as authored.
 	 */
 	public function test_prefixed_inline_reference_metadata() {
 		$function = $this->export_data['functions'][1];
 
 		$this->assertStringContainsString(
-			'{@see Global_Doc_Function()}',
+			'{@see \\Global_Doc_Function()}',
 			$function['doc']['long_description']
 		);
 	}
 
+	/**
+	 * Test that see tag references are preserved as authored.
+	 */
+	public function test_see_tag_reference_metadata() {
+		$tags = $this->export_data['functions'][1]['doc']['tags'];
+
+		$this->assertSame( '\\Global_See_Target()', $tags[0]['refers'] );
+		$this->assertSame( 'Global_See_Plain()', $tags[1]['refers'] );
+	}
+
+	/**
+	 * Test that link tag targets are preserved as authored.
+	 */
+	public function test_link_tag_metadata() {
+		$tags = $this->export_data['functions'][1]['doc']['tags'];
+
+		$this->assertSame( 'https://example.com/reference', $tags[2]['link'] );
+	}
+
+	/**
+	 * Test that prefixes synthesized by type resolution stay stripped.
+	 */
+	public function test_prefixed_tag_type_metadata() {
+		$tags = $this->export_data['functions'][1]['doc']['tags'];
+
+		$this->assertEquals( array( 'Global_Prefixed_Type' ), $tags[3]['types'] );
+	}
+
 	/**
 	 * Test that namespaced inline references keep their prefix.
 	 */

From edac50a0369fb72b95a6c2a0554a3bc9db2fc088 Mon Sep 17 00:00:00 2001
From: Jon Surrell 
Date: Tue, 18 Aug 2026 16:18:11 +0400
Subject: [PATCH 15/19] Export documentation references as authored
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Stop stripping global namespace prefixes from documentation text:
inline `{@see}`/`{@link}` references in descriptions and tag content,
and the reference tokens of `@see`/`@link` tags. These fields carry the
author's own words — phpDocumentor hands them over verbatim — so any
leading backslash present was written by hand and any rewrite imposes
a spelling the author didn't choose. The consumer that renders these
references (the developer.wordpress.org theme re-parses the inline tags
at render time) owns display policy.

This removes the code-region protection machinery along with the strip:
with nothing rewriting the text, code samples and inline code spans are
verbatim by construction, and the existing tests for them become plain
regression pins.

Tag types and argument types keep the strip: those values pass through
phpDocumentor's type resolution, which synthesizes a leading `\` on
every non-keyword type regardless of how the author spelled it, so the
prefix there is resolver output, not authorship.
---
 lib/runner.php | 59 +++++---------------------------------------------
 1 file changed, 6 insertions(+), 53 deletions(-)

diff --git a/lib/runner.php b/lib/runner.php
index 9932423..11ebbbd 100644
--- a/lib/runner.php
+++ b/lib/runner.php
@@ -207,45 +207,6 @@ function export_expression( $expression ) {
 	return $printer->prettyPrintExpr( $expression );
 }
 
-/**
- * Remove synthetic global namespace prefixes from inline DocBlock references.
- *
- * A special exception is made for text appearing in `` and `
` tags, as code
- * samples are reproduced verbatim and any prefix appearing in them was written by hand.
- *
- * @param string $text Formatted DocBlock text.
- *
- * @return string
- */
-function strip_global_namespace_prefixes_from_inline_references( $text ) {
-	// Non-naturally occurring string to use as temporary replacement.
-	$replacement_string = '{{{{{}}}}}';
-
-	// Replace inline tag openings within 'code' and 'pre' tags with replacement string.
-	$text = preg_replace_callback(
-		"/(]*>)(.+)(?=<\/code>)/sU",
-		function ( $matches ) use ( $replacement_string ) {
-			return str_replace( '{@', $replacement_string, $matches[1] . $matches[2] );
-		},
-		$text
-	);
-
-	$text = preg_replace_callback(
-		'~{@(?:link|see)\s+([^}\s]+)~',
-		static function( $matches ) {
-			return str_replace(
-				$matches[1],
-				strip_global_namespace_prefix( $matches[1] ),
-				$matches[0]
-			);
-		},
-		$text
-	);
-
-	// Restore inline tag openings into code blocks.
-	return str_replace( $replacement_string, '{@', $text );
-}
-
 /**
  * Fixes newline handling in parsed text.
  *
@@ -574,12 +535,8 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(),
 	}
 
 	$output = array(
-		'description'      => strip_global_namespace_prefixes_from_inline_references(
-			preg_replace( '/[\n\r]+/', ' ', $short_description )
-		),
-		'long_description' => strip_global_namespace_prefixes_from_inline_references(
-			format_long_description( strip_docblock_code_snippet_fences( $raw_long_description, $fences ) )
-		),
+		'description'      => preg_replace( '/[\n\r]+/', ' ', $short_description ),
+		'long_description' => format_long_description( strip_docblock_code_snippet_fences( $raw_long_description, $fences ) ),
 		'tags'             => array(),
 	);
 
@@ -599,21 +556,19 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(),
 
 		$tag_data = array(
 			'name'    => $tag->getName(),
-			'content' => strip_global_namespace_prefixes_from_inline_references(
-				preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) )
-			),
+			'content' => preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) ),
 		);
 		if ( method_exists( $tag, 'getTypes' ) ) {
 			$tag_data['types'] = strip_global_namespace_prefixes( $tag->getTypes() );
 		}
 		if ( method_exists( $tag, 'getLink' ) ) {
-			$tag_data['link'] = strip_global_namespace_prefix( $tag->getLink() );
+			$tag_data['link'] = $tag->getLink();
 		}
 		if ( method_exists( $tag, 'getVariableName' ) ) {
 			$tag_data['variable'] = $tag->getVariableName();
 		}
 		if ( method_exists( $tag, 'getReference' ) ) {
-			$tag_data['refers'] = strip_global_namespace_prefix( $tag->getReference() );
+			$tag_data['refers'] = $tag->getReference();
 		}
 		if ( method_exists( $tag, 'getVersion' ) ) {
 			// Version string.
@@ -623,9 +578,7 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(),
 			}
 			// Description string.
 			if ( method_exists( $tag, 'getDescription' ) ) {
-				$description = strip_global_namespace_prefixes_from_inline_references(
-					preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) )
-				);
+				$description = preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) );
 				if ( ! empty( $description ) ) {
 					$tag_data['description'] = $description;
 				}

From 02a20350efc2986629aaf10eda27e8f5239e2614 Mon Sep 17 00:00:00 2001
From: Jon Surrell 
Date: Thu, 23 Jul 2026 22:41:34 +0200
Subject: [PATCH 16/19] Normalize global names in the diff tool

Extracted from the original "Preserve expression output" commit so the
diff tool's changes can land separately from the exporter's.

The diff normalization must follow the same rules the exporter uses when
it strips PHP-Parser's synthetic global namespace prefixes, otherwise the
normalized output no longer matches what the exporter produces.
---
 prep-diff.php            | 46 ++++++++++++++++++++++++++++++++++++++--
 tests/prep-diff-test.php | 21 +++++++++++++-----
 2 files changed, 60 insertions(+), 7 deletions(-)

diff --git a/prep-diff.php b/prep-diff.php
index 42e69c8..be02fbc 100644
--- a/prep-diff.php
+++ b/prep-diff.php
@@ -75,10 +75,11 @@ function wp_parser_prep_diff_is_simple_name_record_list( array $list ) {
  * Normalizes scalar values that should not affect output comparisons.
  *
  * @param mixed       $value Scalar value.
+ * @param array       $path  Current JSON path.
  * @param string|null $key   Parent object key.
  * @return mixed Normalized value.
  */
-function wp_parser_prep_diff_normalize_scalar( $value, $key ) {
+function wp_parser_prep_diff_normalize_scalar( $value, array $path, $key ) {
 	if ( in_array( $key, array( 'line', 'end_line', 'startLine', 'endLine' ), true ) ) {
 		return 0;
 	}
@@ -91,6 +92,47 @@ function wp_parser_prep_diff_normalize_scalar( $value, $key ) {
 		return $value;
 	}
 
+	if ( in_array( $key, array( 'content', 'description', 'long_description' ), true ) ) {
+		return preg_replace_callback(
+			'~{@(?:link|see)\s+([^}\s]+)~',
+			static function( $matches ) {
+				return str_replace(
+					$matches[1],
+					wp_parser_prep_diff_normalize_global_names( $matches[1] ),
+					$matches[0]
+				);
+			},
+			$value
+		);
+	}
+
+	$parent_key = 1 < count( $path ) ? $path[ count( $path ) - 2 ] : null;
+	$normalize = in_array(
+		$key,
+		array( 'class', 'default', 'extends', 'link', 'refers', 'type', 'value' ),
+		true
+	);
+
+	$normalize = $normalize
+		|| in_array( $parent_key, array( 'aliases', 'implements', 'types' ), true )
+		|| (
+			'name' === $key
+			&& (
+				wp_parser_prep_diff_path_ends_with( $path, array( 'uses', 'functions', '[]', 'name' ) )
+				|| wp_parser_prep_diff_path_ends_with( $path, array( 'uses', 'methods', '[]', 'name' ) )
+			)
+		);
+
+	return $normalize ? wp_parser_prep_diff_normalize_global_names( $value ) : $value;
+}
+
+/**
+ * Remove synthetic global namespace prefixes from semantic names.
+ *
+ * @param string $value Name or expression to normalize.
+ * @return string Normalized value.
+ */
+function wp_parser_prep_diff_normalize_global_names( $value ) {
 	// "\wp_kses()" -> "wp_kses()".
 	$without_global_namespace = preg_replace(
 		'~(^|\p{Z})\\\\([A-Z_a-z\x80-\xFF][0-9A-Z_a-z\x80-\xFF]*)([:(\p{Z}]|->|$)~',
@@ -222,7 +264,7 @@ function wp_parser_prep_diff_should_sort_list( array $path, array $list ) {
  */
 function wp_parser_prep_diff_normalize( $value, array $path = array(), $key = null ) {
 	if ( ! is_array( $value ) ) {
-		return wp_parser_prep_diff_normalize_scalar( $value, $key );
+		return wp_parser_prep_diff_normalize_scalar( $value, $path, $key );
 	}
 
 	if ( wp_parser_prep_diff_is_list( $value ) ) {
diff --git a/tests/prep-diff-test.php b/tests/prep-diff-test.php
index e4860e5..5e968a3 100644
--- a/tests/prep-diff-test.php
+++ b/tests/prep-diff-test.php
@@ -65,16 +65,19 @@ function assert_true( $condition, $message ) {
 					'name'      => 'beta',
 					'namespace' => 'global',
 					'arguments' => array(
-						array( 'name' => '$first', 'type' => '' ),
+						array( 'name' => '$first', 'type' => '\\Global_Type', 'default' => '\\false' ),
 						array( 'name' => '$second', 'type' => '' ),
 					),
+					'hooks'     => array(
+						array( 'name' => '\\x09tab', 'type' => 'action', 'line' => 10, 'end_line' => 10 ),
+					),
 					'doc'       => array(
 						'tags'             => array(
 							array( 'name' => 'since', 'content' => '1.0.0' ),
 							array( 'name' => 'param', 'content' => 'First.', 'variable' => '$first' ),
 						),
 						'long_description' => '',
-						'description'      => 'Calls \\alpha().',
+						'description'      => 'Calls {@see \\alpha()}; preserves \\xC0.',
 					),
 				),
 			),
@@ -105,7 +108,7 @@ function assert_true( $condition, $message ) {
 					'name'      => 'beta',
 					'line'      => 98,
 					'doc'       => array(
-						'description'      => 'Calls alpha().',
+						'description'      => 'Calls {@see alpha()}; preserves \\xC0.',
 						'long_description' => '',
 						'tags'             => array(
 							array( 'name' => 'since', 'content' => '1.0.0' ),
@@ -113,9 +116,12 @@ function assert_true( $condition, $message ) {
 						),
 					),
 					'arguments' => array(
-						array( 'type' => '', 'name' => '$first' ),
+						array( 'default' => 'false', 'type' => 'Global_Type', 'name' => '$first' ),
 						array( 'type' => '', 'name' => '$second' ),
 					),
+					'hooks'     => array(
+						array( 'end_line' => 100, 'line' => 100, 'type' => 'action', 'name' => '\\x09tab' ),
+					),
 					'uses'      => array(
 						'functions' => array(
 							array( 'end_line' => 30, 'line' => 30, 'name' => 'alpha' ),
@@ -140,7 +146,12 @@ function assert_true( $condition, $message ) {
 assert_true( array( '$first', '$second' ) === array_column( $decoded[1]['functions'][0]['arguments'], 'name' ), 'Function argument order should be preserved.' );
 assert_true( array( 'since', 'param' ) === array_column( $decoded[1]['functions'][0]['doc']['tags'], 'name' ), 'Doc tag order should be preserved.' );
 assert_true( array( 'alpha', 'zeta' ) === array_column( $decoded[1]['functions'][0]['uses']['functions'], 'name' ), 'Function uses should sort by name.' );
-assert_true( array_keys( $decoded[1]['functions'][0] ) === array( 'arguments', 'doc', 'line', 'name', 'namespace', 'uses' ), 'Object keys should be sorted.' );
+assert_true( array_keys( $decoded[1]['functions'][0] ) === array( 'arguments', 'doc', 'hooks', 'line', 'name', 'namespace', 'uses' ), 'Object keys should be sorted.' );
+assert_true(
+	'Calls {@see alpha()}; preserves \\xC0.' === $decoded[1]['functions'][0]['doc']['description'],
+	'Documentation escape sequences should be preserved.'
+);
+assert_true( '\\x09tab' === $decoded[1]['functions'][0]['hooks'][0]['name'], 'Hook names should be preserved.' );
 
 $changed = json_decode( $b, true );
 $changed[1]['functions'][0]['name'] = 'changed';

From da20168b54e74e88f7bd1ab05679ad746ca7f7a2 Mon Sep 17 00:00:00 2001
From: Jon Surrell 
Date: Mon, 10 Aug 2026 19:36:47 +0400
Subject: [PATCH 17/19] Run the diff normalization tests and match the
 exporter's rules

`tests/prep-diff-test.php` was a standalone script that nothing executed:
`phpunit.xml.dist` only loads `tests/phpunit/tests/`, and both `composer test`
and CI run PHPUnit with that configuration. Move it under `tests/phpunit/tests/`
as a test case so that it runs with the rest of the suite.

`prep-diff.php` normalized printed expressions with its own prose-oriented
regular expression, which also matched global names appearing inside string
literals. An argument default of `'see \Foo bar'` was rewritten to
`'see Foo bar'`, which is exactly the mangled output the script is meant to
expose, so a regression in expression printing diffed clean. Reuse the
exporter's `WP_Parser\strip_global_namespace_prefix()` instead, which only
strips a prefix at the start of a value.

Inline documentation references are unaffected: they never contain whitespace,
so both rules produce the same result for them. Diffs of output generated
before and after this branch now show the string-literal fix instead of hiding
it, which is intended.
---
 prep-diff.php                     |  28 +--
 tests/phpunit/tests/prep-diff.php | 273 ++++++++++++++++++++++++++++++
 tests/prep-diff-test.php          | 164 ------------------
 3 files changed, 282 insertions(+), 183 deletions(-)
 create mode 100644 tests/phpunit/tests/prep-diff.php
 delete mode 100644 tests/prep-diff-test.php

diff --git a/prep-diff.php b/prep-diff.php
index be02fbc..e564130 100644
--- a/prep-diff.php
+++ b/prep-diff.php
@@ -14,6 +14,13 @@
  *     diff -u before.norm.json after.norm.json
  */
 
+/*
+ * The exporter's own normalization rules are reused so that this script cannot
+ * hide a difference in them. The file only declares namespaced functions, so it
+ * is safe to load without the Composer autoloader.
+ */
+require_once __DIR__ . '/lib/runner.php';
+
 /**
  * Checks if an array is a JSON list.
  *
@@ -98,7 +105,7 @@ function wp_parser_prep_diff_normalize_scalar( $value, array $path, $key ) {
 			static function( $matches ) {
 				return str_replace(
 					$matches[1],
-					wp_parser_prep_diff_normalize_global_names( $matches[1] ),
+					\WP_Parser\strip_global_namespace_prefix( $matches[1] ),
 					$matches[0]
 				);
 			},
@@ -123,24 +130,7 @@ static function( $matches ) {
 			)
 		);
 
-	return $normalize ? wp_parser_prep_diff_normalize_global_names( $value ) : $value;
-}
-
-/**
- * Remove synthetic global namespace prefixes from semantic names.
- *
- * @param string $value Name or expression to normalize.
- * @return string Normalized value.
- */
-function wp_parser_prep_diff_normalize_global_names( $value ) {
-	// "\wp_kses()" -> "wp_kses()".
-	$without_global_namespace = preg_replace(
-		'~(^|\p{Z})\\\\([A-Z_a-z\x80-\xFF][0-9A-Z_a-z\x80-\xFF]*)([:(\p{Z}]|->|$)~',
-		'$1$2$3',
-		$value
-	);
-
-	return null === $without_global_namespace ? $value : $without_global_namespace;
+	return $normalize ? \WP_Parser\strip_global_namespace_prefix( $value ) : $value;
 }
 
 /**
diff --git a/tests/phpunit/tests/prep-diff.php b/tests/phpunit/tests/prep-diff.php
new file mode 100644
index 0000000..56ed090
--- /dev/null
+++ b/tests/phpunit/tests/prep-diff.php
@@ -0,0 +1,273 @@
+ array( 'pipe', 'r' ),
+			1 => array( 'pipe', 'w' ),
+			2 => array( 'pipe', 'w' ),
+		);
+
+		$process = proc_open(
+			escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( $script ),
+			$descriptor_spec,
+			$pipes
+		);
+
+		$this->assertIsResource( $process, 'Unable to start prep-diff.php.' );
+
+		fwrite( $pipes[0], $json );
+		fclose( $pipes[0] );
+
+		$output = stream_get_contents( $pipes[1] );
+		$error  = stream_get_contents( $pipes[2] );
+
+		fclose( $pipes[1] );
+		fclose( $pipes[2] );
+
+		$status = proc_close( $process );
+
+		$this->assertSame( 0, $status, trim( $error ) );
+
+		return $output;
+	}
+
+	/**
+	 * Returns generated JSON for a build.
+	 *
+	 * @return string JSON with parser output in source order.
+	 */
+	protected function get_json() {
+
+		return json_encode(
+			array(
+				array(
+					'root'       => '/tmp/build-a',
+					'path'       => 'beta.php',
+					'call_graph' => array(
+						array( 'name' => 'zeta', 'line' => 9, 'end_line' => 9 ),
+						array( 'name' => 'alpha', 'line' => 3, 'end_line' => 3 ),
+					),
+					'functions'  => array(
+						array(
+							'uses'      => array(
+								'functions' => array(
+									array( 'name' => 'zeta', 'line' => 9, 'end_line' => 9 ),
+									array( 'name' => 'alpha', 'line' => 3, 'end_line' => 3 ),
+								),
+							),
+							'line'      => 20,
+							'name'      => 'beta',
+							'namespace' => 'global',
+							'arguments' => array(
+								array( 'name' => '$first', 'type' => '\\Global_Type', 'default' => '\\false' ),
+								array( 'name' => '$second', 'type' => '' ),
+							),
+							'hooks'     => array(
+								array( 'name' => '\\x09tab', 'type' => 'action', 'line' => 10, 'end_line' => 10 ),
+							),
+							'doc'       => array(
+								'tags'             => array(
+									array( 'name' => 'since', 'content' => '1.0.0' ),
+									array( 'name' => 'param', 'content' => 'First.', 'variable' => '$first' ),
+								),
+								'long_description' => '',
+								'description'      => 'Calls {@see \\alpha()}; preserves \\xC0.',
+							),
+						),
+					),
+				),
+				array(
+					'path' => 'alpha.php',
+					'root' => '/tmp/build-a',
+				),
+			)
+		);
+	}
+
+	/**
+	 * Returns generated JSON for an equivalent build with shuffled output.
+	 *
+	 * @return string JSON with the same content in a different order.
+	 */
+	protected function get_shuffled_json() {
+
+		return json_encode(
+			array(
+				array(
+					'root' => '/tmp/build-b',
+					'path' => 'alpha.php',
+				),
+				array(
+					'path'       => 'beta.php',
+					'root'       => '/tmp/build-b',
+					'call_graph' => array(
+						array( 'end_line' => 90, 'line' => 90, 'name' => 'zeta' ),
+						array( 'end_line' => 30, 'line' => 30, 'name' => 'alpha' ),
+					),
+					'functions'  => array(
+						array(
+							'namespace' => 'global',
+							'name'      => 'beta',
+							'line'      => 98,
+							'doc'       => array(
+								'description'      => 'Calls {@see alpha()}; preserves \\xC0.',
+								'long_description' => '',
+								'tags'             => array(
+									array( 'name' => 'since', 'content' => '1.0.0' ),
+									array( 'name' => 'param', 'content' => 'First.', 'variable' => '$first' ),
+								),
+							),
+							'arguments' => array(
+								array( 'default' => 'false', 'type' => 'Global_Type', 'name' => '$first' ),
+								array( 'type' => '', 'name' => '$second' ),
+							),
+							'hooks'     => array(
+								array( 'end_line' => 100, 'line' => 100, 'type' => 'action', 'name' => '\\x09tab' ),
+							),
+							'uses'      => array(
+								'functions' => array(
+									array( 'end_line' => 30, 'line' => 30, 'name' => 'alpha' ),
+									array( 'end_line' => 90, 'line' => 90, 'name' => 'zeta' ),
+								),
+							),
+						),
+					),
+				),
+			)
+		);
+	}
+
+	/**
+	 * Test that equivalent output with incidental differences normalizes identically.
+	 */
+	public function test_equivalent_output_normalizes_identically() {
+
+		$this->assertSame(
+			$this->normalize( $this->get_json() ),
+			$this->normalize( $this->get_shuffled_json() )
+		);
+	}
+
+	/**
+	 * Test that unordered parser collections are sorted.
+	 */
+	public function test_unordered_collections_are_sorted() {
+
+		$decoded = json_decode( $this->normalize( $this->get_json() ), true );
+
+		$this->assertSame( 'alpha.php', $decoded[0]['path'] );
+		$this->assertSame(
+			array( 'alpha', 'zeta' ),
+			array_column( $decoded[1]['call_graph'], 'name' )
+		);
+		$this->assertSame(
+			array( 'alpha', 'zeta' ),
+			array_column( $decoded[1]['functions'][0]['uses']['functions'], 'name' )
+		);
+		$this->assertSame(
+			array( 'arguments', 'doc', 'hooks', 'line', 'name', 'namespace', 'uses' ),
+			array_keys( $decoded[1]['functions'][0] )
+		);
+	}
+
+	/**
+	 * Test that ordered documentation data is left in source order.
+	 */
+	public function test_documentation_order_is_preserved() {
+
+		$decoded = json_decode( $this->normalize( $this->get_json() ), true );
+
+		$this->assertSame(
+			array( '$first', '$second' ),
+			array_column( $decoded[1]['functions'][0]['arguments'], 'name' )
+		);
+		$this->assertSame(
+			array( 'since', 'param' ),
+			array_column( $decoded[1]['functions'][0]['doc']['tags'], 'name' )
+		);
+	}
+
+	/**
+	 * Test that literal escape sequences are preserved.
+	 */
+	public function test_literal_escape_sequences_are_preserved() {
+
+		$decoded = json_decode( $this->normalize( $this->get_json() ), true );
+
+		$this->assertSame(
+			'Calls {@see alpha()}; preserves \\xC0.',
+			$decoded[1]['functions'][0]['doc']['description']
+		);
+		$this->assertSame( '\\x09tab', $decoded[1]['functions'][0]['hooks'][0]['name'] );
+	}
+
+	/**
+	 * Test that printed expressions only lose an anchored global prefix.
+	 *
+	 * Printed expressions are normalized with the same rule the exporter applies,
+	 * so that global names appearing inside string literals are left alone.
+	 *
+	 * @dataProvider data_printed_expressions
+	 *
+	 * @param string $key      Key holding the printed expression.
+	 * @param string $value    Printed expression.
+	 * @param string $expected Expected normalized expression.
+	 */
+	public function test_printed_expressions_normalize_anchored_prefixes( $key, $value, $expected ) {
+
+		$decoded = json_decode( $this->normalize( json_encode( array( $key => $value ) ) ), true );
+
+		$this->assertSame( $expected, $decoded[ $key ] );
+	}
+
+	/**
+	 * Data provider for printed expressions.
+	 *
+	 * @return array[] Key, printed expression, and expected normalized expression.
+	 */
+	public function data_printed_expressions() {
+
+		return array(
+			'string literal containing a global name' => array( 'default', "'see \\Foo bar'", "'see \\Foo bar'" ),
+			'string literal in a constant value'      => array( 'value', "'see \\Foo bar'", "'see \\Foo bar'" ),
+			'printed global name'                     => array( 'default', '\\Foo', 'Foo' ),
+			'printed namespaced name'                 => array( 'default', '\\Vendor\\Thing', '\\Vendor\\Thing' ),
+		);
+	}
+
+	/**
+	 * Test that real content changes remain visible.
+	 */
+	public function test_content_changes_remain_visible() {
+
+		$changed = json_decode( $this->get_shuffled_json(), true );
+
+		$changed[1]['functions'][0]['name'] = 'changed';
+
+		$this->assertNotSame(
+			$this->normalize( $this->get_json() ),
+			$this->normalize( json_encode( $changed ) )
+		);
+	}
+}
diff --git a/tests/prep-diff-test.php b/tests/prep-diff-test.php
deleted file mode 100644
index 5e968a3..0000000
--- a/tests/prep-diff-test.php
+++ /dev/null
@@ -1,164 +0,0 @@
- array( 'pipe', 'r' ),
-		1 => array( 'pipe', 'w' ),
-		2 => array( 'pipe', 'w' ),
-	);
-
-	$process = proc_open(
-		escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( $script ),
-		$descriptor_spec,
-		$pipes
-	);
-
-	if ( ! is_resource( $process ) ) {
-		throw new RuntimeException( 'Unable to start prep-diff.php.' );
-	}
-
-	fwrite( $pipes[0], $json );
-	fclose( $pipes[0] );
-
-	$output = stream_get_contents( $pipes[1] );
-	$error  = stream_get_contents( $pipes[2] );
-
-	fclose( $pipes[1] );
-	fclose( $pipes[2] );
-
-	$status = proc_close( $process );
-
-	if ( 0 !== $status ) {
-		throw new RuntimeException( trim( $error ) );
-	}
-
-	return $output;
-}
-
-function assert_true( $condition, $message ) {
-	if ( ! $condition ) {
-		fwrite( STDERR, $message . PHP_EOL );
-		exit( 1 );
-	}
-}
-
-$a = json_encode(
-	array(
-		array(
-			'root'      => '/tmp/build-a',
-			'path'      => 'beta.php',
-			'call_graph' => array(
-				array( 'name' => 'zeta', 'line' => 9, 'end_line' => 9 ),
-				array( 'name' => 'alpha', 'line' => 3, 'end_line' => 3 ),
-			),
-			'functions' => array(
-				array(
-					'uses'      => array(
-						'functions' => array(
-							array( 'name' => 'zeta', 'line' => 9, 'end_line' => 9 ),
-							array( 'name' => 'alpha', 'line' => 3, 'end_line' => 3 ),
-						),
-					),
-					'line'      => 20,
-					'name'      => 'beta',
-					'namespace' => 'global',
-					'arguments' => array(
-						array( 'name' => '$first', 'type' => '\\Global_Type', 'default' => '\\false' ),
-						array( 'name' => '$second', 'type' => '' ),
-					),
-					'hooks'     => array(
-						array( 'name' => '\\x09tab', 'type' => 'action', 'line' => 10, 'end_line' => 10 ),
-					),
-					'doc'       => array(
-						'tags'             => array(
-							array( 'name' => 'since', 'content' => '1.0.0' ),
-							array( 'name' => 'param', 'content' => 'First.', 'variable' => '$first' ),
-						),
-						'long_description' => '',
-						'description'      => 'Calls {@see \\alpha()}; preserves \\xC0.',
-					),
-				),
-			),
-		),
-		array(
-			'path' => 'alpha.php',
-			'root' => '/tmp/build-a',
-		),
-	)
-);
-
-$b = json_encode(
-	array(
-		array(
-			'root' => '/tmp/build-b',
-			'path' => 'alpha.php',
-		),
-		array(
-			'path'      => 'beta.php',
-			'root'      => '/tmp/build-b',
-			'call_graph' => array(
-				array( 'end_line' => 90, 'line' => 90, 'name' => 'zeta' ),
-				array( 'end_line' => 30, 'line' => 30, 'name' => 'alpha' ),
-			),
-			'functions' => array(
-				array(
-					'namespace' => 'global',
-					'name'      => 'beta',
-					'line'      => 98,
-					'doc'       => array(
-						'description'      => 'Calls {@see alpha()}; preserves \\xC0.',
-						'long_description' => '',
-						'tags'             => array(
-							array( 'name' => 'since', 'content' => '1.0.0' ),
-							array( 'name' => 'param', 'content' => 'First.', 'variable' => '$first' ),
-						),
-					),
-					'arguments' => array(
-						array( 'default' => 'false', 'type' => 'Global_Type', 'name' => '$first' ),
-						array( 'type' => '', 'name' => '$second' ),
-					),
-					'hooks'     => array(
-						array( 'end_line' => 100, 'line' => 100, 'type' => 'action', 'name' => '\\x09tab' ),
-					),
-					'uses'      => array(
-						'functions' => array(
-							array( 'end_line' => 30, 'line' => 30, 'name' => 'alpha' ),
-							array( 'end_line' => 90, 'line' => 90, 'name' => 'zeta' ),
-						),
-					),
-				),
-			),
-		),
-	)
-);
-
-$a_normalized = normalize_with_prep_diff( $script, $a );
-$b_normalized = normalize_with_prep_diff( $script, $b );
-
-assert_true( $a_normalized === $b_normalized, 'Equivalent shuffled JSON should normalize identically.' );
-
-$decoded = json_decode( $a_normalized, true );
-
-assert_true( 'alpha.php' === $decoded[0]['path'], 'Top-level files should sort by path.' );
-assert_true( array( 'alpha', 'zeta' ) === array_column( $decoded[1]['call_graph'], 'name' ), 'Simple name records should sort by name.' );
-assert_true( array( '$first', '$second' ) === array_column( $decoded[1]['functions'][0]['arguments'], 'name' ), 'Function argument order should be preserved.' );
-assert_true( array( 'since', 'param' ) === array_column( $decoded[1]['functions'][0]['doc']['tags'], 'name' ), 'Doc tag order should be preserved.' );
-assert_true( array( 'alpha', 'zeta' ) === array_column( $decoded[1]['functions'][0]['uses']['functions'], 'name' ), 'Function uses should sort by name.' );
-assert_true( array_keys( $decoded[1]['functions'][0] ) === array( 'arguments', 'doc', 'hooks', 'line', 'name', 'namespace', 'uses' ), 'Object keys should be sorted.' );
-assert_true(
-	'Calls {@see alpha()}; preserves \\xC0.' === $decoded[1]['functions'][0]['doc']['description'],
-	'Documentation escape sequences should be preserved.'
-);
-assert_true( '\\x09tab' === $decoded[1]['functions'][0]['hooks'][0]['name'], 'Hook names should be preserved.' );
-
-$changed = json_decode( $b, true );
-$changed[1]['functions'][0]['name'] = 'changed';
-
-assert_true(
-	$a_normalized !== normalize_with_prep_diff( $script, json_encode( $changed ) ),
-	'Real content changes should remain visible.'
-);
-
-echo "prep-diff tests passed\n";

From 6ce9808dbe7011667eac44daa12172509ddef04b Mon Sep 17 00:00:00 2001
From: Jon Surrell 
Date: Tue, 18 Aug 2026 18:27:40 +0400
Subject: [PATCH 18/19] Test diff normalization leaves documentation text alone

The exporter now hands documentation text through as authored: inline
references in descriptions and tag content, and the reference tokens of
`@see`/`@link` tags. A difference in those fields between two builds is
a real behavior change, so the diff normalization must not erase it.
---
 tests/phpunit/tests/prep-diff.php | 38 +++++++++++++++++++++++++++++--
 1 file changed, 36 insertions(+), 2 deletions(-)

diff --git a/tests/phpunit/tests/prep-diff.php b/tests/phpunit/tests/prep-diff.php
index 56ed090..e21d7bb 100644
--- a/tests/phpunit/tests/prep-diff.php
+++ b/tests/phpunit/tests/prep-diff.php
@@ -131,7 +131,7 @@ protected function get_shuffled_json() {
 							'name'      => 'beta',
 							'line'      => 98,
 							'doc'       => array(
-								'description'      => 'Calls {@see alpha()}; preserves \\xC0.',
+								'description'      => 'Calls {@see \\alpha()}; preserves \\xC0.',
 								'long_description' => '',
 								'tags'             => array(
 									array( 'name' => 'since', 'content' => '1.0.0' ),
@@ -216,12 +216,46 @@ public function test_literal_escape_sequences_are_preserved() {
 		$decoded = json_decode( $this->normalize( $this->get_json() ), true );
 
 		$this->assertSame(
-			'Calls {@see alpha()}; preserves \\xC0.',
+			'Calls {@see \\alpha()}; preserves \\xC0.',
 			$decoded[1]['functions'][0]['doc']['description']
 		);
 		$this->assertSame( '\\x09tab', $decoded[1]['functions'][0]['hooks'][0]['name'] );
 	}
 
+	/**
+	 * Test that documentation text passes through as authored.
+	 *
+	 * The exporter no longer rewrites documentation text, so a difference in
+	 * these fields is a real behavior change that the diff must show.
+	 *
+	 * @dataProvider data_documentation_text
+	 *
+	 * @param string $key   Key holding documentation text.
+	 * @param string $value Documentation text.
+	 */
+	public function test_documentation_text_passes_through( $key, $value ) {
+
+		$decoded = json_decode( $this->normalize( json_encode( array( $key => $value ) ) ), true );
+
+		$this->assertSame( $value, $decoded[ $key ] );
+	}
+
+	/**
+	 * Data provider for documentation text.
+	 *
+	 * @return array[] Key and documentation text.
+	 */
+	public function data_documentation_text() {
+
+		return array(
+			'inline reference in a description'      => array( 'description', 'Calls {@see \\alpha()}.' ),
+			'inline reference in a long description' => array( 'long_description', 'Calls {@link \\alpha()}.' ),
+			'inline reference in tag content'        => array( 'content', 'See {@see \\Widget::render()}.' ),
+			'see tag reference'                      => array( 'refers', '\\alpha()' ),
+			'link tag target'                        => array( 'link', '\\alpha()' ),
+		);
+	}
+
 	/**
 	 * Test that printed expressions only lose an anchored global prefix.
 	 *

From 9b41ff95a023587f43a5fa697e660b632dcbf45f Mon Sep 17 00:00:00 2001
From: Jon Surrell 
Date: Tue, 18 Aug 2026 18:28:17 +0400
Subject: [PATCH 19/19] Stop normalizing documentation text in the diff tool
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Match the exporter: documentation text is exported as authored, so the
diff tool has nothing to reconcile in descriptions, tag content, or the
`refers`/`link` tokens — and normalizing them would hide a real change
in exactly the fields the exporter promises not to touch. Expression
and type fields keep the anchored-prefix normalization the exporter
still applies.
---
 prep-diff.php | 16 +---------------
 1 file changed, 1 insertion(+), 15 deletions(-)

diff --git a/prep-diff.php b/prep-diff.php
index e564130..7b224d0 100644
--- a/prep-diff.php
+++ b/prep-diff.php
@@ -99,24 +99,10 @@ function wp_parser_prep_diff_normalize_scalar( $value, array $path, $key ) {
 		return $value;
 	}
 
-	if ( in_array( $key, array( 'content', 'description', 'long_description' ), true ) ) {
-		return preg_replace_callback(
-			'~{@(?:link|see)\s+([^}\s]+)~',
-			static function( $matches ) {
-				return str_replace(
-					$matches[1],
-					\WP_Parser\strip_global_namespace_prefix( $matches[1] ),
-					$matches[0]
-				);
-			},
-			$value
-		);
-	}
-
 	$parent_key = 1 < count( $path ) ? $path[ count( $path ) - 2 ] : null;
 	$normalize = in_array(
 		$key,
-		array( 'class', 'default', 'extends', 'link', 'refers', 'type', 'value' ),
+		array( 'class', 'default', 'extends', 'type', 'value' ),
 		true
 	);