From 76ac331cb22c72cfd73b5d109110a99f728db6d0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 18 Aug 2026 21:51:47 +0400 Subject: [PATCH 1/2] Add failing tests for backslash loss in imported meta WordPress metadata APIs unslash their input, so backslashes in tag, argument, alias, extends, implements, and properties meta are silently destroyed on import. Pin the expected values before fixing the importer. --- tests/phpunit/tests/import/file.php | 161 ++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/tests/phpunit/tests/import/file.php b/tests/phpunit/tests/import/file.php index 2b55890..9eff2b3 100644 --- a/tests/phpunit/tests/import/file.php +++ b/tests/phpunit/tests/import/file.php @@ -213,4 +213,165 @@ public function test_function_snippet_metadata_preserves_backslashes() { $this->assertEquals( $snippets, get_post_meta( $post->ID, '_wp-parser_code_snippets', true ) ); $this->assertEquals( $setup_blueprints, get_post_meta( $post->ID, '_wp-parser_setup_blueprints', true ) ); } + + /** + * Test that WordPress metadata slashing does not alter DocBlock tags. + */ + public function test_function_tag_metadata_preserves_backslashes() { + + $posts = get_posts( + array( 'post_type' => $this->importer->post_type_function ) + ); + $post = $posts[0]; + + $function_data = $this->export_data['functions'][0]; + $tags = array( + array( + 'name' => 'see', + 'content' => '', + 'refers' => '\Docs\Example::method()', + ), + array( + 'name' => 'param', + 'content' => 'A namespaced parameter.', + 'types' => array( '\Foo', '\Foo\Bar', 'Vendor\Foo' ), + 'variable' => '$var', + ), + ); + + $function_data['doc']['tags'] = $tags; + + $this->importer->import_function( $function_data ); + + $this->assertEquals( $tags, get_post_meta( $post->ID, '_wp-parser_tags', true ) ); + } + + /** + * Test that WordPress metadata slashing does not alter argument metadata. + */ + public function test_function_argument_metadata_preserves_backslashes() { + + $posts = get_posts( + array( 'post_type' => $this->importer->post_type_function ) + ); + $post = $posts[0]; + + $function_data = $this->export_data['functions'][0]; + $arguments = array( + array( + 'name' => '$leading', + 'default' => null, + 'type' => '\Foo', + ), + array( + 'name' => '$qualified', + 'default' => null, + 'type' => '\Foo\Bar', + ), + array( + 'name' => '$relative', + 'default' => '\Vendor\Foo::DEFAULT_VALUE', + 'type' => 'Vendor\Foo', + ), + ); + + $function_data['arguments'] = $arguments; + + $this->importer->import_function( $function_data ); + + $this->assertEquals( $arguments, get_post_meta( $post->ID, '_wp-parser_args', true ) ); + } + + /** + * Test that WordPress metadata slashing does not alter namespace aliases. + */ + public function test_function_alias_metadata_preserves_backslashes() { + + $posts = get_posts( + array( 'post_type' => $this->importer->post_type_function ) + ); + $post = $posts[0]; + + $function_data = $this->export_data['functions'][0]; + $aliases = array( + 'Leading' => '\Foo', + 'Qualified' => '\Foo\Bar', + 'Relative' => 'Vendor\Foo', + ); + + $function_data['aliases'] = $aliases; + + $this->importer->import_function( $function_data ); + + $this->assertEquals( $aliases, get_post_meta( $post->ID, '_wp_parser_aliases', true ) ); + } + + /** + * Test that WordPress metadata slashing does not alter class metadata. + */ + public function test_class_metadata_preserves_backslashes() { + + $properties = array( + array( + 'name' => '$example', + 'line' => 12, + 'end_line' => 12, + 'default' => '\Vendor\Foo::DEFAULT_VALUE', + 'static' => false, + 'visibility' => 'public', + 'doc' => array( + 'description' => '', + 'long_description' => '', + 'tags' => array( + array( + 'name' => 'var', + 'content' => 'A namespaced property.', + 'types' => array( '\Foo', '\Foo\Bar', 'Vendor\Foo' ), + 'variable' => '', + ), + ), + ), + ), + ); + + $class_data = array( + 'name' => 'Slashing_Example', + 'namespace' => 'Vendor\Docs', + 'line' => 10, + 'end_line' => 14, + 'final' => false, + 'abstract' => false, + 'extends' => '\Foo\Bar', + 'implements' => array( '\Foo', 'Vendor\Foo' ), + 'properties' => $properties, + 'methods' => array(), + 'doc' => array( + 'description' => 'A class with namespaced relatives.', + 'long_description' => '', + 'tags' => array(), + ), + ); + + $file_data = $this->export_data; + $file_data['functions'] = array(); + $file_data['classes'] = array( $class_data ); + $file_data['hooks'] = array(); + + $this->importer->import_file( $file_data, true ); + + $posts = get_posts( + array( 'post_type' => $this->importer->post_type_class ) + ); + + $this->assertCount( 1, $posts ); + + $post = $posts[0]; + + $this->assertEquals( '\Foo\Bar', get_post_meta( $post->ID, '_wp-parser_extends', true ) ); + $this->assertEquals( array( '\Foo', 'Vendor\Foo' ), get_post_meta( $post->ID, '_wp-parser_implements', true ) ); + $this->assertEquals( $properties, get_post_meta( $post->ID, '_wp-parser_properties', true ) ); + + // The namespace is already compensated for; it must not be slashed twice. + $this->assertEquals( 'Vendor\Docs', get_post_meta( $post->ID, '_wp_parser_namespace', true ) ); + } } From 48211e928d5ec25b22a48ead94e37bc11584a58c Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 18 Aug 2026 21:53:03 +0400 Subject: [PATCH 2/2] Slash imported tag, argument, alias, and class meta update_post_meta() unslashes its input, which silently destroyed backslashes in namespaced values: \Foo became Foo, \Foo\Bar became FooBar, and Vendor\Foo became VendorFoo. Compensate with map_deep( $value, 'wp_slash' ), matching the treatment already applied to snippets and Blueprints. _wp_parser_namespace is left alone; it is already compensated with addslashes() and slashing it again would double the separators. --- lib/class-importer.php | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/class-importer.php b/lib/class-importer.php index c1c264e..4299f98 100644 --- a/lib/class-importer.php +++ b/lib/class-importer.php @@ -433,9 +433,12 @@ protected function import_class( array $data, $import_ignored = false ) { // Set class-specific meta update_post_meta( $class_id, '_wp-parser_final', (string) $data['final'] ); update_post_meta( $class_id, '_wp-parser_abstract', (string) $data['abstract'] ); - update_post_meta( $class_id, '_wp-parser_extends', $data['extends'] ); - update_post_meta( $class_id, '_wp-parser_implements', $data['implements'] ); - update_post_meta( $class_id, '_wp-parser_properties', $data['properties'] ); + // Metadata APIs unslash their input. map_deep() reaches every nested + // string, preserving the namespace separators in class relatives and + // property types. + update_post_meta( $class_id, '_wp-parser_extends', map_deep( $data['extends'], 'wp_slash' ) ); + update_post_meta( $class_id, '_wp-parser_implements', map_deep( $data['implements'], 'wp_slash' ) ); + update_post_meta( $class_id, '_wp-parser_properties', map_deep( $data['properties'], 'wp_slash' ) ); // Now add the methods foreach ( $data['methods'] as $method ) { @@ -743,13 +746,16 @@ public function import_item( array $data, $parent_post_id = 0, $import_ignored = $data['doc']['tags']['deprecated'] = $this->file_meta['deprecated']; } + // Metadata APIs unslash their input. map_deep() reaches every nested + // string, preserving the namespace separators in argument and alias + // types. if ( $post_data['post_type'] !== $this->post_type_class ) { - $anything_updated[] = update_post_meta( $post_id, '_wp-parser_args', $data['arguments'] ); + $anything_updated[] = update_post_meta( $post_id, '_wp-parser_args', map_deep( $data['arguments'], 'wp_slash' ) ); } // If the post type is using namespace aliases, record them. if ( ! empty( $data['aliases'] ) ) { - $anything_updated[] = update_post_meta( $post_id, '_wp_parser_aliases', (array) $data['aliases'] ); + $anything_updated[] = update_post_meta( $post_id, '_wp_parser_aliases', map_deep( (array) $data['aliases'], 'wp_slash' ) ); } // Recored the namespace if there is one. @@ -759,7 +765,11 @@ public function import_item( array $data, $parent_post_id = 0, $import_ignored = $anything_updated[] = update_post_meta( $post_id, '_wp-parser_line_num', (string) $data['line'] ); $anything_updated[] = update_post_meta( $post_id, '_wp-parser_end_line_num', (string) $data['end_line'] ); - $anything_updated[] = update_post_meta( $post_id, '_wp-parser_tags', $data['doc']['tags'] ); + + // Metadata APIs unslash their input. map_deep() reaches every nested + // string, preserving the namespace separators in tag types and + // references such as `@param \Foo\Bar` and `@see \Foo\Bar`. + $anything_updated[] = update_post_meta( $post_id, '_wp-parser_tags', map_deep( $data['doc']['tags'], 'wp_slash' ) ); // Metadata APIs unslash their input. map_deep() reaches retained JSON // objects as well as arrays, preserving backslashes in PHP and Blueprint