diff --git a/prep-diff.php b/prep-diff.php index 42e69c8..7b224d0 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. * @@ -75,10 +82,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,14 +99,24 @@ function wp_parser_prep_diff_normalize_scalar( $value, $key ) { return $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 + $parent_key = 1 < count( $path ) ? $path[ count( $path ) - 2 ] : null; + $normalize = in_array( + $key, + array( 'class', 'default', 'extends', 'type', 'value' ), + true ); - return null === $without_global_namespace ? $value : $without_global_namespace; + $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\strip_global_namespace_prefix( $value ) : $value; } /** @@ -222,7 +240,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/phpunit/tests/prep-diff.php b/tests/phpunit/tests/prep-diff.php new file mode 100644 index 0000000..e21d7bb --- /dev/null +++ b/tests/phpunit/tests/prep-diff.php @@ -0,0 +1,307 @@ + 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 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. + * + * 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 e4860e5..0000000 --- a/tests/prep-diff-test.php +++ /dev/null @@ -1,153 +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' => '' ), - array( 'name' => '$second', 'type' => '' ), - ), - 'doc' => array( - 'tags' => array( - array( 'name' => 'since', 'content' => '1.0.0' ), - array( 'name' => 'param', 'content' => 'First.', 'variable' => '$first' ), - ), - 'long_description' => '', - 'description' => 'Calls \\alpha().', - ), - ), - ), - ), - 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 alpha().', - 'long_description' => '', - 'tags' => array( - array( 'name' => 'since', 'content' => '1.0.0' ), - array( 'name' => 'param', 'content' => 'First.', 'variable' => '$first' ), - ), - ), - 'arguments' => array( - array( 'type' => '', 'name' => '$first' ), - array( 'type' => '', 'name' => '$second' ), - ), - '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', 'line', 'name', 'namespace', 'uses' ), 'Object keys should be sorted.' ); - -$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";