From 1298a46691a14b700bd7b157519ea8637c1a379e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 09:04:08 +0000 Subject: [PATCH 1/4] Write fixed feature file blocks back with spaces and no trailing whitespace A PHP block embedded in a feature file was handed to PHP_CodeSniffer as it stood, so the standard reindented it with tabs while the docstring around it stayed indented with spaces, and every fixed block came back mixing the two. The block is now converted on the way in and back on the way out: what is left of a line once the shared docstring indentation is off is indented with tabs for the check and the fixer, which is what the standard wants to see, and comes back indented with four spaces per level, which is what a feature file wants to hold. The two conversions are tab-stop expansions of each other, so a block that is already indented the way the standard asks for round-trips unchanged, and one that arrives with tabs -- from an earlier run of the fixer -- is normalized on the next one. The two runs of whitespace are converted apart rather than as one, as the tabs the fixer produced count from the start of the line as the fixer saw it, not from the start of the line in the feature file. Syncing a block back also trims trailing whitespace now. The fixer leaves some behind wherever it breaks a line, and the sniff that would clean that up cannot be part of the run, as it also wants the padding in front of the block gone -- `--exclude` takes sniff codes and not message codes, so the sniff has to go as a whole. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SeC6Day9YHEaDh4NZZtDSS --- .readme-partials/USING.md | 7 ++ README.md | 7 ++ phpcs/feature-files.sh | 5 + tests/tests/TestExtractFeaturePhp.php | 140 +++++++++++++++++++++++++- utils/extract-feature-php.php | 101 ++++++++++++++++++- 5 files changed, 250 insertions(+), 10 deletions(-) diff --git a/.readme-partials/USING.md b/.readme-partials/USING.md index 94a96d63..be28717c 100644 --- a/.readme-partials/USING.md +++ b/.readme-partials/USING.md @@ -199,6 +199,13 @@ Unlike the analysis above, a docstring that merely opens with `create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( + "\n\n\n\nget_extracted_contents( 'example.feature_L4_E10_HASPHP.php' ) + ); + } + public function test_extraction_skips_docstrings_that_are_not_php_files(): void { $this->create_feature_file( 'example.feature', @@ -343,6 +370,65 @@ public function test_update_syncs_fixes_back_into_the_feature_file(): void { ); } + public function test_update_drops_trailing_whitespace(): void { + // The fixer leaves the whitespace it broke a line at behind, and the sniff + // that would clean that up cannot be part of the run, as it also wants the + // padding in front of the block gone. + $feature_file = $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + $extracted = $this->target_dir . '/example.feature_L4_E7_HASPHP.php'; + file_put_contents( $extracted, "\n\n\n\nrun_script( array( 'update', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( + "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " create_feature_file( 'example.feature', $contents ); + + $this->run_script( array( 'extract', 'features', 'extracted' ) ); + $result = $this->run_script( array( 'update', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( $contents, file_get_contents( $feature_file ) ); + } + public function test_update_does_not_add_the_generated_opening_tag(): void { $feature_file = $this->create_feature_file( 'example.feature', @@ -384,7 +470,7 @@ public function test_update_without_changes_leaves_the_feature_file_untouched(): . "\n" . " assertSame( $contents, file_get_contents( $feature_file ) ); } - public function test_update_preserves_mixed_tab_and_space_indentation(): void { - // Extraction takes a shared prefix off the block rather than a number of - // characters, so a tab never comes back as a space or the other way round. + public function test_update_converts_tab_indentation_to_spaces(): void { + // A block is checked as the tab-indented file the standard expects, so a + // block that arrives with tabs -- written that way, or left behind by a + // fixer that did not convert them back -- comes back with spaces. + $contents = "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " create_feature_file( 'example.feature', $contents ); + + $this->run_script( array( 'extract', 'features', 'extracted' ) ); + $result = $this->run_script( array( 'update', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( + "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'update', 'features', 'extracted' ) ); $this->assertSame( 0, $result['exit_code'], $result['output'] ); - $this->assertSame( $contents, file_get_contents( $feature_file ) ); + $this->assertSame( + "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " , has_php_tag: bool} $block Block to render. * @return string Source of the standalone PHP file. */ @@ -48,7 +123,7 @@ function render_fixable_block( array $block ) { if ( '' === trim( $code_line ) ) { $out_lines[ $line_idx ] = "\n"; } else { - $out_lines[ $line_idx ] = substr( $code_line, $indent_length ); + $out_lines[ $line_idx ] = indent_with_tabs( substr( $code_line, $indent_length ) ); } } @@ -134,6 +209,9 @@ function extract_feature_php( $source_dir, $target_dir ) { * the first line instead would make a block whose opening tag is indented * deeper than the code below it drift further to the right on every run. * + * The prefix is measured, not reproduced verbatim: the caller writes it back as + * spaces, along with the rest of the indentation of the line. + * * @param string[] $feature_lines Lines of the feature file. * @param int $code_start Index of the first line of code. * @param int $code_end Index of the last line of code. @@ -328,12 +406,25 @@ function ( $a, $b ) { continue; } - $fixed_line = $indent . $line_content; - if ( "\n" !== substr( $fixed_line, -1 ) ) { - $fixed_line .= "\n"; + // The line ending is put back the way it was found, so that a + // feature file using CRLF keeps doing so. + $eol = "\n"; + if ( 1 === preg_match( '/\r?\n$/', $line_content, $eol_match ) ) { + $eol = $eol_match[0]; + $line_content = substr( $line_content, 0, -strlen( $eol ) ); } - $fixed_lines[] = $fixed_line; + // Indentation goes back to spaces, undoing what extraction did to + // have the block checked as the tab-indented file the standard + // expects. Trailing whitespace goes with it: the fixer leaves some + // behind wherever it breaks a line, and the sniff that would clean + // that up cannot be part of the run, as it also wants the padding + // gone. See render_fixable_block() and `phpcs/feature-files.sh`. + // The two runs are converted apart rather than as one: extraction + // took the shared prefix off before handing the rest to the fixer, so + // the tabs it produced count from the start of the line as the fixer + // saw it, not from the start of the line in the feature file. + $fixed_lines[] = rtrim( indent_with_spaces( $indent ) . indent_with_spaces( $line_content ), " \t" ) . $eol; } $num_code_lines = ( $code_end - $code_start + 1 ); From 026744e35de433aa38b327ec18849c6e89f253ad Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 09:17:19 +0000 Subject: [PATCH 2/4] Configure PHPCS for space indentation instead of converting around it The previous commit had extraction rewrite a block's indentation to tabs and syncing rewrite it back to spaces, and had syncing trim the trailing whitespace the fixer leaves behind wherever it breaks a line. Both are things PHP_CodeSniffer can be told, so tell it instead. The sniffs that indent take a `tabIndent` property, and a ruleset can exclude a single message code where `--exclude` only takes whole sniffs. Neither can be said on the command line, so the run now uses a `WP_CLI_CS_Feature_Files` ruleset that turns `Generic.WhiteSpace.ScopeIndent` and `WordPress.Arrays.ArrayIndentation` around, swaps `Generic.WhiteSpace.DisallowSpaceIndent` for its opposite, and puts `Squiz.WhiteSpace.SuperfluousWhitespace` back to work with only the two message codes that would eat the padding in front of a block left out. A block is now checked and fixed as the space-indented code it is, so nothing has to be converted on the way in or out and `utils/extract-feature-php.php` is back to what it was. A tab or a run of trailing whitespace in a block is reported like any other violation rather than quietly rewritten, so `composer phpcs` says what `composer phpcbf` is about to do, and a block that already has tabs is brought back in line rather than left as it is. Fixing a heredoc is left to the sniffs as well, which know not to touch what is inside one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SeC6Day9YHEaDh4NZZtDSS --- .readme-partials/USING.md | 17 ++- README.md | 17 ++- WP_CLI_CS_Feature_Files/ruleset.xml | 49 +++++++++ phpcs/feature-files.sh | 12 +- tests/tests/TestExtractFeaturePhp.php | 140 +----------------------- tests/tests/TestFeatureFilesRuleset.php | 116 ++++++++++++++++++++ utils/extract-feature-php.php | 101 +---------------- 7 files changed, 196 insertions(+), 256 deletions(-) create mode 100644 WP_CLI_CS_Feature_Files/ruleset.xml create mode 100644 tests/tests/TestFeatureFilesRuleset.php diff --git a/.readme-partials/USING.md b/.readme-partials/USING.md index be28717c..caf2d0b6 100644 --- a/.readme-partials/USING.md +++ b/.readme-partials/USING.md @@ -199,25 +199,24 @@ Unlike the analysis above, a docstring that merely opens with ` - + diff --git a/README.md b/README.md index 4c1584bc..a858b7c3 100644 --- a/README.md +++ b/README.md @@ -210,25 +210,24 @@ Unlike the analysis above, a docstring that merely opens with ` - + diff --git a/WP_CLI_CS_Feature_Files/ruleset.xml b/WP_CLI_CS_Feature_Files/ruleset.xml new file mode 100644 index 00000000..cb5719d9 --- /dev/null +++ b/WP_CLI_CS_Feature_Files/ruleset.xml @@ -0,0 +1,49 @@ + + + + Coding standard for the PHP blocks embedded in Behat feature files + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpcs/feature-files.sh b/phpcs/feature-files.sh index 281df71c..806e9e9f 100644 --- a/phpcs/feature-files.sh +++ b/phpcs/feature-files.sh @@ -12,11 +12,15 @@ # spans several major versions of PHP_CodeSniffer and of the standards it # builds on, and not every entry exists in all of them. # +# Only what cannot be said on the command line -- a sniff property, a single +# message code -- is declared in the `WP_CLI_CS_Feature_Files` ruleset, which is +# what tells the sniffs that indent that a feature file indents with spaces. +# # A package replaces these defaults wholesale by adding a # `phpcs-feature-files.xml` (or `phpcs-feature-files.xml.dist`) ruleset to its # root, which is then used as the standard instead. -WP_CLI_TESTS_FEATURE_STANDARD="WP_CLI_CS" +WP_CLI_TESTS_FEATURE_STANDARD="WP_CLI_CS_Feature_Files" # Warnings are advisory, and the fixer must not rewrite a feature file over # something the check does not report. @@ -36,12 +40,6 @@ WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Squiz.Commenting.F WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Generic.PHP.RequireStrictTypes" WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,WordPress.Files.FileName" WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Universal.WhiteSpace.PrecisionAlignment" -# Only the part of this sniff that looks at the start and the end of the file -# is in the way, but `--exclude` takes sniff codes and not message codes, so it -# has to go as a whole. Syncing a fixed block back into its feature file trims -# the trailing whitespace the sniff would have caught. See -# `utils/extract-feature-php.php`. -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Squiz.WhiteSpace.SuperfluousWhitespace" # A block is a fixture, not production code. Snippets exist to set up a # scenario, run inside a throwaway WordPress installation, are written to be diff --git a/tests/tests/TestExtractFeaturePhp.php b/tests/tests/TestExtractFeaturePhp.php index 61edbef2..908d01ed 100644 --- a/tests/tests/TestExtractFeaturePhp.php +++ b/tests/tests/TestExtractFeaturePhp.php @@ -153,33 +153,6 @@ public function test_extraction_preserves_relative_indentation_and_empty_lines() ); } - public function test_extraction_indents_the_block_with_tabs(): void { - // Feature files indent with spaces, the standard the blocks are checked - // against indents with tabs. What does not add up to a full tab stop stays - // a run of spaces, so that alignment survives the conversion. - $this->create_feature_file( - 'example.feature', - "Feature: Example\n" - . " Scenario: A PHP block\n" - . " Given a test.php file:\n" - . " \"\"\"\n" - . " run_script( array( 'extract', 'features', 'extracted' ) ); - - $this->assertSame( 0, $result['exit_code'], $result['output'] ); - $this->assertSame( - "\n\n\n\nget_extracted_contents( 'example.feature_L4_E10_HASPHP.php' ) - ); - } - public function test_extraction_skips_docstrings_that_are_not_php_files(): void { $this->create_feature_file( 'example.feature', @@ -370,65 +343,6 @@ public function test_update_syncs_fixes_back_into_the_feature_file(): void { ); } - public function test_update_drops_trailing_whitespace(): void { - // The fixer leaves the whitespace it broke a line at behind, and the sniff - // that would clean that up cannot be part of the run, as it also wants the - // padding in front of the block gone. - $feature_file = $this->create_feature_file( - 'example.feature', - "Feature: Example\n" - . " Scenario: A PHP block\n" - . " Given a test.php file:\n" - . " \"\"\"\n" - . " run_script( array( 'extract', 'features', 'extracted' ) ); - - $extracted = $this->target_dir . '/example.feature_L4_E7_HASPHP.php'; - file_put_contents( $extracted, "\n\n\n\nrun_script( array( 'update', 'features', 'extracted' ) ); - - $this->assertSame( 0, $result['exit_code'], $result['output'] ); - $this->assertSame( - "Feature: Example\n" - . " Scenario: A PHP block\n" - . " Given a test.php file:\n" - . " \"\"\"\n" - . " create_feature_file( 'example.feature', $contents ); - - $this->run_script( array( 'extract', 'features', 'extracted' ) ); - $result = $this->run_script( array( 'update', 'features', 'extracted' ) ); - - $this->assertSame( 0, $result['exit_code'], $result['output'] ); - $this->assertSame( $contents, file_get_contents( $feature_file ) ); - } - public function test_update_does_not_add_the_generated_opening_tag(): void { $feature_file = $this->create_feature_file( 'example.feature', @@ -470,7 +384,7 @@ public function test_update_without_changes_leaves_the_feature_file_untouched(): . "\n" . " assertSame( $contents, file_get_contents( $feature_file ) ); } - public function test_update_converts_tab_indentation_to_spaces(): void { - // A block is checked as the tab-indented file the standard expects, so a - // block that arrives with tabs -- written that way, or left behind by a - // fixer that did not convert them back -- comes back with spaces. - $contents = "Feature: Example\n" - . " Scenario: A PHP block\n" - . " Given a test.php file:\n" - . " \"\"\"\n" - . " create_feature_file( 'example.feature', $contents ); - - $this->run_script( array( 'extract', 'features', 'extracted' ) ); - $result = $this->run_script( array( 'update', 'features', 'extracted' ) ); - - $this->assertSame( 0, $result['exit_code'], $result['output'] ); - $this->assertSame( - "Feature: Example\n" - . " Scenario: A PHP block\n" - . " Given a test.php file:\n" - . " \"\"\"\n" - . " run_script( array( 'update', 'features', 'extracted' ) ); $this->assertSame( 0, $result['exit_code'], $result['output'] ); - $this->assertSame( - "Feature: Example\n" - . " Scenario: A PHP block\n" - . " Given a test.php file:\n" - . " \"\"\"\n" - . " assertSame( $contents, file_get_contents( $feature_file ) ); } public function test_extraction_refuses_to_use_a_root_directory_as_target(): void { diff --git a/tests/tests/TestFeatureFilesRuleset.php b/tests/tests/TestFeatureFilesRuleset.php new file mode 100644 index 00000000..4b33dd37 --- /dev/null +++ b/tests/tests/TestFeatureFilesRuleset.php @@ -0,0 +1,116 @@ +temp_file = Utils\get_temp_dir() . uniqid( 'wp-cli-test-feature-ruleset-', true ) . '.php'; + } + + protected function tear_down(): void { + if ( file_exists( $this->temp_file ) ) { + unlink( $this->temp_file ); + } + + parent::tear_down(); + } + + /** + * Runs PHPCBF over a block and returns what it made of it. + * + * @param string $block Contents of the block, as extraction would write them. + * @return string Contents of the block after fixing. + */ + private function fix( $block ): string { + file_put_contents( $this->temp_file, $block ); + + $phpcbf = dirname( dirname( __DIR__ ) ) . '/vendor/squizlabs/php_codesniffer/bin/phpcbf'; + + $command = escapeshellarg( PHP_BINARY ) + . ' ' . escapeshellarg( $phpcbf ) + . ' ' . escapeshellarg( '--standard=WP_CLI_CS_Feature_Files' ) + . ' ' . escapeshellarg( '--sniffs=' . self::SNIFFS ) + . ' ' . escapeshellarg( $this->temp_file ) + . ' 2>&1'; + + $output = array(); + $exit_code = 0; + + exec( $command, $output, $exit_code ); + + // PHPCBF reports 1 when it fixed something and 0 when there was nothing + // to fix. Anything above that is the run itself having gone wrong. + $this->assertLessThanOrEqual( 1, $exit_code, implode( "\n", $output ) ); + + return (string) file_get_contents( $this->temp_file ); + } + + public function test_a_block_is_fixed_to_space_indentation(): void { + $this->assertSame( + "\n\n\nfix( "\n\n\nassertSame( $block, $this->fix( $block ) ); + } + + public function test_an_array_is_fixed_to_space_indentation(): void { + // Array indentation is a sniff of its own, with a tab setting of its own. + $this->assertSame( + "\n\n\n 1,\n);\n", + $this->fix( "\n\n\n 1,\n);\n" ) + ); + } + + public function test_trailing_whitespace_is_removed(): void { + // The fixer leaves whitespace behind wherever it breaks a line, so the + // sniff that cleans that up has to be part of the run. + $this->assertSame( + "\n\n\nfix( "\n\n\nassertSame( $block, $this->fix( $block ) ); + } +} diff --git a/utils/extract-feature-php.php b/utils/extract-feature-php.php index 45522774..c52fe224 100644 --- a/utils/extract-feature-php.php +++ b/utils/extract-feature-php.php @@ -16,77 +16,6 @@ */ const EXTRACTED_FILE_PATTERN = '/^(.*\.feature)_L(\d+)_E(\d+)_(HASPHP|NOPHP)\.php$/'; -/** - * Width of a tab stop, in spaces. - * - * `WordPress-Core`, which `WP_CLI_CS` builds on, indents with one tab per level - * and has PHP_CodeSniffer read a tab as four columns. Converting the blocks at - * the same width is what makes the two conversions below meet in the middle. - */ -const TAB_WIDTH = 4; - -/** - * Determine how many columns a run of indentation covers. - * - * A tab advances to the next tab stop rather than by a fixed amount, which is - * what keeps the two conversions below each other's inverse. - * - * @param string $indent Indentation to measure. - * @param int $tab_width Width of a tab stop, in spaces. - * @return int Number of columns the indentation covers. - */ -function get_indent_width( $indent, $tab_width ) { - $width = 0; - - for ( $i = 0, $length = strlen( $indent ); $i < $length; $i++ ) { - if ( "\t" === $indent[ $i ] ) { - $width += $tab_width - ( $width % $tab_width ); - } else { - ++$width; - } - } - - return $width; -} - -/** - * Indent a line with tabs. - * - * Whatever is left over past the last full tab stop stays a run of spaces, so - * that a line aligned to something rather than indented keeps its alignment. - * - * @param string $line Line to convert. - * @param int $tab_width Width of a tab stop, in spaces. - * @return string Line indented with tabs. - */ -function indent_with_tabs( $line, $tab_width = TAB_WIDTH ) { - if ( 1 !== preg_match( '/^[ \t]+/', $line, $matches ) ) { - return $line; - } - - $width = get_indent_width( $matches[0], $tab_width ); - - return str_repeat( "\t", intdiv( $width, $tab_width ) ) - . str_repeat( ' ', $width % $tab_width ) - . substr( $line, strlen( $matches[0] ) ); -} - -/** - * Indent a line with spaces. - * - * @param string $line Line to convert. - * @param int $tab_width Width of a tab stop, in spaces. - * @return string Line indented with spaces. - */ -function indent_with_spaces( $line, $tab_width = TAB_WIDTH ) { - if ( 1 !== preg_match( '/^[ \t]+/', $line, $matches ) ) { - return $line; - } - - return str_repeat( ' ', get_indent_width( $matches[0], $tab_width ) ) - . substr( $line, strlen( $matches[0] ) ); -} - /** * Turn a PHP block into the source of a standalone PHP file. * @@ -100,10 +29,6 @@ function indent_with_spaces( $line, $tab_width = TAB_WIDTH ) { * block brings along stays where it is. A fix is written back into the feature * file, so the checked copy has to line up with the block it came from. * - * What is left of a line once the shared indentation is off is indented with - * tabs, which is how the standard the block is checked against wants it. Feature - * files indent with spaces, so update_feature_php() converts it back. - * * @param array{start: int, lines: array, has_php_tag: bool} $block Block to render. * @return string Source of the standalone PHP file. */ @@ -123,7 +48,7 @@ function render_fixable_block( array $block ) { if ( '' === trim( $code_line ) ) { $out_lines[ $line_idx ] = "\n"; } else { - $out_lines[ $line_idx ] = indent_with_tabs( substr( $code_line, $indent_length ) ); + $out_lines[ $line_idx ] = substr( $code_line, $indent_length ); } } @@ -209,9 +134,6 @@ function extract_feature_php( $source_dir, $target_dir ) { * the first line instead would make a block whose opening tag is indented * deeper than the code below it drift further to the right on every run. * - * The prefix is measured, not reproduced verbatim: the caller writes it back as - * spaces, along with the rest of the indentation of the line. - * * @param string[] $feature_lines Lines of the feature file. * @param int $code_start Index of the first line of code. * @param int $code_end Index of the last line of code. @@ -406,25 +328,12 @@ function ( $a, $b ) { continue; } - // The line ending is put back the way it was found, so that a - // feature file using CRLF keeps doing so. - $eol = "\n"; - if ( 1 === preg_match( '/\r?\n$/', $line_content, $eol_match ) ) { - $eol = $eol_match[0]; - $line_content = substr( $line_content, 0, -strlen( $eol ) ); + $fixed_line = $indent . $line_content; + if ( "\n" !== substr( $fixed_line, -1 ) ) { + $fixed_line .= "\n"; } - // Indentation goes back to spaces, undoing what extraction did to - // have the block checked as the tab-indented file the standard - // expects. Trailing whitespace goes with it: the fixer leaves some - // behind wherever it breaks a line, and the sniff that would clean - // that up cannot be part of the run, as it also wants the padding - // gone. See render_fixable_block() and `phpcs/feature-files.sh`. - // The two runs are converted apart rather than as one: extraction - // took the shared prefix off before handing the rest to the fixer, so - // the tabs it produced count from the start of the line as the fixer - // saw it, not from the start of the line in the feature file. - $fixed_lines[] = rtrim( indent_with_spaces( $indent ) . indent_with_spaces( $line_content ), " \t" ) . $eol; + $fixed_lines[] = $fixed_line; } $num_code_lines = ( $code_end - $code_start + 1 ); From 1b59acf87184384709e91f6c782efd9ce405677f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 09:40:25 +0000 Subject: [PATCH 3/4] Say why the feature file defaults live in two files, and fix a dead exclusion `Generic.PHP.CharacterBeforePHPOpenTag` is not a sniff. The sniff is called `Generic.PHP.CharacterBeforePHPOpeningTag`, so the exclusion has been doing nothing since it was added, and nothing went wrong because `--exclude` passes over a name it cannot resolve. That is the same leniency the list relies on to survive a standard that renames a sniff, and the reason the list cannot simply move into the ruleset: naming a sniff that does not resolve there aborts the whole run with exit code 3. Both files now say which half of the defaults they hold and why, so the split reads as a decision rather than as an accident. A package supplying a ruleset of its own replaces both halves, which the documentation now says as well. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SeC6Day9YHEaDh4NZZtDSS --- .readme-partials/USING.md | 5 +++++ README.md | 5 +++++ WP_CLI_CS_Feature_Files/ruleset.xml | 11 +++++++---- phpcs/feature-files.sh | 11 +++++++---- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.readme-partials/USING.md b/.readme-partials/USING.md index caf2d0b6..f4081de7 100644 --- a/.readme-partials/USING.md +++ b/.readme-partials/USING.md @@ -223,6 +223,11 @@ over which sniff applies; what a sniff has to be told rather than simply left ou ``` +Such a ruleset takes the place of both halves of the defaults, so the sniffs listed in +`phpcs/feature-files.sh` are no longer left out for it either. Starting it from +`WP_CLI_CS_Feature_Files` rather than from `WP_CLI_CS`, as above, at least keeps the indentation and +the whitespace handling. + The blocks are left alone when a run is narrowed down to a path, as in `composer phpcs -- src/`, since such an argument is about the files of the package itself. diff --git a/README.md b/README.md index a858b7c3..c6f2b814 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,11 @@ over which sniff applies; what a sniff has to be told rather than simply left ou ``` +Such a ruleset takes the place of both halves of the defaults, so the sniffs listed in +`phpcs/feature-files.sh` are no longer left out for it either. Starting it from +`WP_CLI_CS_Feature_Files` rather than from `WP_CLI_CS`, as above, at least keeps the indentation and +the whitespace handling. + The blocks are left alone when a run is narrowed down to a path, as in `composer phpcs -- src/`, since such an argument is about the files of the package itself. diff --git a/WP_CLI_CS_Feature_Files/ruleset.xml b/WP_CLI_CS_Feature_Files/ruleset.xml index cb5719d9..7d344518 100644 --- a/WP_CLI_CS_Feature_Files/ruleset.xml +++ b/WP_CLI_CS_Feature_Files/ruleset.xml @@ -9,10 +9,13 @@ it takes to turn that around is a property and a message code, neither of which can be said on the command line, so it is said here. - The sniffs that are only ever left out stay in `phpcs/feature-files.sh`: an - exclusion there passes over a sniff the installed PHP_CodeSniffer does not - know, while one here aborts the whole run over it. Nothing in this file is - newer than PHP_CodeSniffer 3.0 or WordPressCS 1.0. + The sniffs that are only ever left out stay on the command line, in + `phpcs/feature-files.sh`, because the exclude option passes over a sniff the + installed PHP_CodeSniffer does not know, while naming one here aborts the + whole run over it. Every sniff named below has been part of PHP_CodeSniffer + or WordPressCS for long enough to be worth that, and naming it here is what + makes a name that no longer resolves an error rather than a line that + quietly stops doing anything. --> diff --git a/phpcs/feature-files.sh b/phpcs/feature-files.sh index 806e9e9f..dd32dcc2 100644 --- a/phpcs/feature-files.sh +++ b/phpcs/feature-files.sh @@ -12,9 +12,12 @@ # spans several major versions of PHP_CodeSniffer and of the standards it # builds on, and not every entry exists in all of them. # -# Only what cannot be said on the command line -- a sniff property, a single -# message code -- is declared in the `WP_CLI_CS_Feature_Files` ruleset, which is -# what tells the sniffs that indent that a feature file indents with spaces. +# The other half of the defaults is the `WP_CLI_CS_Feature_Files` ruleset, which +# holds what a command line cannot express: a sniff property, and an exclusion +# narrowed down to a single message code. What it names is long-standing enough +# to be worth naming in a ruleset, where a name that does not resolve is an +# error rather than a line that quietly does nothing. The list below cannot +# afford that, which is the whole of why the defaults live in two files. # # A package replaces these defaults wholesale by adding a # `phpcs-feature-files.xml` (or `phpcs-feature-files.xml.dist`) ruleset to its @@ -32,7 +35,7 @@ WP_CLI_TESTS_FEATURE_ARGS="--warning-severity=0" # the shared docstring indentation is taken off before the check and put back # afterwards, so none of the sniffs looking at a file as a whole apply. WP_CLI_TESTS_FEATURE_EXCLUDES="Generic.Files.InlineHTML" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Generic.PHP.CharacterBeforePHPOpenTag" +WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Generic.PHP.CharacterBeforePHPOpeningTag" WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Generic.Files.LineEndings" WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,PSR2.Files.EndFileNewline" WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,PSR12.Files.FileHeader" From dbfd4cec31348cc6a8847576fa74a7f4feb2f24a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 10:07:40 +0000 Subject: [PATCH 4/4] Move the feature file defaults into the ruleset, and drop the shell file The exclusions were kept on the command line so that a name the installed PHP_CodeSniffer cannot resolve would be passed over rather than abort the run. That does not hold up. `WP_CLI_CS` already excludes five sniffs by name, so a standard that renames one takes `composer phpcs` down for every package before the blocks are ever reached, and the leniency bought nothing the main run does not already forgo. It is not even leniency worth having: an exclusion that stops resolving does not switch the sniff off, it switches the sniff back on, so the check starts reporting and the fixer starts rewriting feature files over something that was meant to be left alone. Aborting is the better failure. What the leniency did do was hide a dead entry for as long as it existed, which is an argument against it rather than for it. So the whole of the defaults now lives in `WP_CLI_CS_Feature_Files`: the exclusions, the warning severity, and the sniff configuration that could never have been anything but a ruleset. `phpcs/feature-files.sh` has nothing left to hold and is gone, and both scripts lose the sourcing along with the argument splitting it needed. `Generic.PHP.CharacterBeforePHPOpeningTag` is dropped rather than carried over. It is not part of `WP_CLI_CS`, so excluding it never did anything, and a ruleset resolves every name it is given. The blocks of `wp-cli/ability-command` come out of this byte for byte as they did before it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SeC6Day9YHEaDh4NZZtDSS --- .readme-partials/USING.md | 19 ++++---- README.md | 19 ++++---- WP_CLI_CS_Feature_Files/ruleset.xml | 71 +++++++++++++++++++++++------ bin/run-phpcbf-cleanup | 17 +++---- bin/run-phpcs-tests | 17 +++---- phpcs/feature-files.sh | 63 ------------------------- 6 files changed, 86 insertions(+), 120 deletions(-) delete mode 100644 phpcs/feature-files.sh diff --git a/.readme-partials/USING.md b/.readme-partials/USING.md index f4081de7..6b070a72 100644 --- a/.readme-partials/USING.md +++ b/.readme-partials/USING.md @@ -204,12 +204,12 @@ for this run: a block is checked, and fixed, as the space-indented code it is, a find its way into one is reported and fixed like any other violation. Trailing whitespace is reported too, as the fixer leaves some behind wherever it breaks a line. -The defaults leave out the sniffs that look at a block as if it were a file of its own, along with -those that ask of a fixture what is only worth asking of production code. They live in -`phpcs/feature-files.sh` and are shared by the check and the fixer, so that the two cannot disagree -over which sniff applies; what a sniff has to be told rather than simply left out lives in the -`WP_CLI_CS_Feature_Files` ruleset. A package replaces both wholesale by adding a -`phpcs-feature-files.xml` (or `phpcs-feature-files.xml.dist`) ruleset to its root: +All of this is the `WP_CLI_CS_Feature_Files` ruleset, which is `WP_CLI_CS` with the sniffs that +indent turned around, and with the sniffs that look at a block as if it were a file of its own left +out, along with those that ask of a fixture what is only worth asking of production code. The check +and the fixer both use it, so that the two cannot disagree over which sniff applies to a block. A +package replaces it wholesale by adding a `phpcs-feature-files.xml` (or +`phpcs-feature-files.xml.dist`) ruleset to its root: ```xml @@ -223,10 +223,9 @@ over which sniff applies; what a sniff has to be told rather than simply left ou ``` -Such a ruleset takes the place of both halves of the defaults, so the sniffs listed in -`phpcs/feature-files.sh` are no longer left out for it either. Starting it from -`WP_CLI_CS_Feature_Files` rather than from `WP_CLI_CS`, as above, at least keeps the indentation and -the whitespace handling. +Starting from `WP_CLI_CS_Feature_Files`, as above, keeps the defaults and adds to them. Starting +from `WP_CLI_CS` instead gives up all of them, including the space indentation, and leaves the +package to say for itself what a padded block cannot satisfy. The blocks are left alone when a run is narrowed down to a path, as in `composer phpcs -- src/`, since such an argument is about the files of the package itself. diff --git a/README.md b/README.md index c6f2b814..7a48d30f 100644 --- a/README.md +++ b/README.md @@ -215,12 +215,12 @@ for this run: a block is checked, and fixed, as the space-indented code it is, a find its way into one is reported and fixed like any other violation. Trailing whitespace is reported too, as the fixer leaves some behind wherever it breaks a line. -The defaults leave out the sniffs that look at a block as if it were a file of its own, along with -those that ask of a fixture what is only worth asking of production code. They live in -`phpcs/feature-files.sh` and are shared by the check and the fixer, so that the two cannot disagree -over which sniff applies; what a sniff has to be told rather than simply left out lives in the -`WP_CLI_CS_Feature_Files` ruleset. A package replaces both wholesale by adding a -`phpcs-feature-files.xml` (or `phpcs-feature-files.xml.dist`) ruleset to its root: +All of this is the `WP_CLI_CS_Feature_Files` ruleset, which is `WP_CLI_CS` with the sniffs that +indent turned around, and with the sniffs that look at a block as if it were a file of its own left +out, along with those that ask of a fixture what is only worth asking of production code. The check +and the fixer both use it, so that the two cannot disagree over which sniff applies to a block. A +package replaces it wholesale by adding a `phpcs-feature-files.xml` (or +`phpcs-feature-files.xml.dist`) ruleset to its root: ```xml @@ -234,10 +234,9 @@ over which sniff applies; what a sniff has to be told rather than simply left ou ``` -Such a ruleset takes the place of both halves of the defaults, so the sniffs listed in -`phpcs/feature-files.sh` are no longer left out for it either. Starting it from -`WP_CLI_CS_Feature_Files` rather than from `WP_CLI_CS`, as above, at least keeps the indentation and -the whitespace handling. +Starting from `WP_CLI_CS_Feature_Files`, as above, keeps the defaults and adds to them. Starting +from `WP_CLI_CS` instead gives up all of them, including the space indentation, and leaves the +package to say for itself what a padded block cannot satisfy. The blocks are left alone when a run is narrowed down to a path, as in `composer phpcs -- src/`, since such an argument is about the files of the package itself. diff --git a/WP_CLI_CS_Feature_Files/ruleset.xml b/WP_CLI_CS_Feature_Files/ruleset.xml index 7d344518..d753252c 100644 --- a/WP_CLI_CS_Feature_Files/ruleset.xml +++ b/WP_CLI_CS_Feature_Files/ruleset.xml @@ -4,21 +4,63 @@ Coding standard for the PHP blocks embedded in Behat feature files + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -40,9 +82,8 @@ diff --git a/bin/run-phpcbf-cleanup b/bin/run-phpcbf-cleanup index 677a13ec..f4c7b458 100755 --- a/bin/run-phpcbf-cleanup +++ b/bin/run-phpcbf-cleanup @@ -25,8 +25,8 @@ done DIR="$(cd -P "$(dirname "$SOURCE")/.." && pwd)" # A ruleset of the same purpose in the package root replaces the defaults -# wholesale. Both scripts read the defaults from the same file, so that the -# check and the fixer cannot disagree over which sniff applies to a block. +# wholesale. Both scripts use the same ruleset otherwise, so that the check and +# the fixer cannot disagree over which sniff applies to a block. FEATURE_STANDARD="" for CANDIDATE in "phpcs-feature-files.xml" "phpcs-feature-files.xml.dist" do @@ -37,13 +37,9 @@ do fi done -FEATURE_ARGS="" -if [ -z "$FEATURE_STANDARD" ] && [ -f "$DIR/phpcs/feature-files.sh" ] +if [ -z "$FEATURE_STANDARD" ] then - . "$DIR/phpcs/feature-files.sh" - FEATURE_STANDARD="$WP_CLI_TESTS_FEATURE_STANDARD" - # Holds no path, so leaving it unquoted below splits it into arguments. - FEATURE_ARGS="$WP_CLI_TESTS_FEATURE_ARGS" + FEATURE_STANDARD="WP_CLI_CS_Feature_Files" fi # An argument naming what to fix applies to the files of the package itself, so @@ -57,7 +53,7 @@ do esac done -if [ "$FIX_BLOCKS" -eq 1 ] && [ -d "features" ] && [ -n "$FEATURE_STANDARD" ] \ +if [ "$FIX_BLOCKS" -eq 1 ] && [ -d "features" ] \ && [ -f "$DIR/utils/extract-feature-php.php" ] then TEMP_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'feature_phpcbf') @@ -68,8 +64,7 @@ then then if [ -n "$(ls -A "$TEMP_DIR" 2>/dev/null)" ] then - # shellcheck disable=SC2086 # Intentional word splitting. - vendor/bin/phpcbf --standard="$FEATURE_STANDARD" $FEATURE_ARGS \ + vendor/bin/phpcbf --standard="$FEATURE_STANDARD" \ "$TEMP_DIR" >/dev/null || EXIT_CODE=$? php "$DIR/utils/extract-feature-php.php" update features "$TEMP_DIR" >/dev/null || EXIT_CODE=$? diff --git a/bin/run-phpcs-tests b/bin/run-phpcs-tests index f1a632a9..3c2a9f04 100755 --- a/bin/run-phpcs-tests +++ b/bin/run-phpcs-tests @@ -44,8 +44,8 @@ done DIR="$(cd -P "$(dirname "$SOURCE")/.." && pwd)" # A ruleset of the same purpose in the package root replaces the defaults -# wholesale. Both scripts read the defaults from the same file, so that the -# check and the fixer cannot disagree over which sniff applies to a block. +# wholesale. Both scripts use the same ruleset otherwise, so that the check and +# the fixer cannot disagree over which sniff applies to a block. FEATURE_STANDARD="" for CANDIDATE in "phpcs-feature-files.xml" "phpcs-feature-files.xml.dist" do @@ -56,13 +56,9 @@ do fi done -FEATURE_ARGS="" -if [ -z "$FEATURE_STANDARD" ] && [ -f "$DIR/phpcs/feature-files.sh" ] +if [ -z "$FEATURE_STANDARD" ] then - . "$DIR/phpcs/feature-files.sh" - FEATURE_STANDARD="$WP_CLI_TESTS_FEATURE_STANDARD" - # Holds no path, so leaving it unquoted below splits it into arguments. - FEATURE_ARGS="$WP_CLI_TESTS_FEATURE_ARGS" + FEATURE_STANDARD="WP_CLI_CS_Feature_Files" fi # An argument naming what to check applies to the files of the package itself, @@ -76,7 +72,7 @@ do esac done -if [ "$CHECK_BLOCKS" -eq 1 ] && [ -d "features" ] && [ -n "$FEATURE_STANDARD" ] \ +if [ "$CHECK_BLOCKS" -eq 1 ] && [ -d "features" ] \ && [ -f "$DIR/utils/extract-feature-php.php" ] then TEMP_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'feature_phpcs') @@ -93,8 +89,7 @@ then # `--basepath` reduces the reported paths to the part that is worth # showing, which also keeps PHPCS from truncating them from the left # once they grow past the width of the report. - # shellcheck disable=SC2086 # Intentional word splitting. - vendor/bin/phpcs --standard="$FEATURE_STANDARD" $FEATURE_ARGS \ + vendor/bin/phpcs --standard="$FEATURE_STANDARD" \ --basepath="$TEMP_DIR" "$TEMP_DIR" >"$PHPCS_OUTPUT" 2>&1 || EXIT_CODE=$? # Findings are reported against the feature files they came from. diff --git a/phpcs/feature-files.sh b/phpcs/feature-files.sh deleted file mode 100644 index dd32dcc2..00000000 --- a/phpcs/feature-files.sh +++ /dev/null @@ -1,63 +0,0 @@ -# Defaults for the code style check of the PHP blocks embedded in Behat feature -# files, shared by `run-phpcs-tests` and `run-phpcbf-cleanup`. -# -# Keeping the list in one place is what makes the check and the fixer agree: a -# sniff excluded for one but not the other would have the fixer rewrite feature -# files over something the check never reports, or have the check report -# something the fixer refuses to touch. -# -# The exclusions are passed on the command line rather than declared in a -# ruleset because a ruleset aborts the whole run over a sniff that the installed -# PHP_CodeSniffer does not know, while `--exclude` passes over it. The list -# spans several major versions of PHP_CodeSniffer and of the standards it -# builds on, and not every entry exists in all of them. -# -# The other half of the defaults is the `WP_CLI_CS_Feature_Files` ruleset, which -# holds what a command line cannot express: a sniff property, and an exclusion -# narrowed down to a single message code. What it names is long-standing enough -# to be worth naming in a ruleset, where a name that does not resolve is an -# error rather than a line that quietly does nothing. The list below cannot -# afford that, which is the whole of why the defaults live in two files. -# -# A package replaces these defaults wholesale by adding a -# `phpcs-feature-files.xml` (or `phpcs-feature-files.xml.dist`) ruleset to its -# root, which is then used as the standard instead. - -WP_CLI_TESTS_FEATURE_STANDARD="WP_CLI_CS_Feature_Files" - -# Warnings are advisory, and the fixer must not rewrite a feature file over -# something the check does not report. -WP_CLI_TESTS_FEATURE_ARGS="--warning-severity=0" - -# A block is not a file. It is padded with one empty line per preceding line of -# the feature file so that reported line numbers match it, and one that does not -# bring its own opening tag is given one. Neither is part of the snippet, and -# the shared docstring indentation is taken off before the check and put back -# afterwards, so none of the sniffs looking at a file as a whole apply. -WP_CLI_TESTS_FEATURE_EXCLUDES="Generic.Files.InlineHTML" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Generic.PHP.CharacterBeforePHPOpeningTag" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Generic.Files.LineEndings" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,PSR2.Files.EndFileNewline" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,PSR12.Files.FileHeader" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Squiz.Commenting.FileComment" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Generic.PHP.RequireStrictTypes" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,WordPress.Files.FileName" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Universal.WhiteSpace.PrecisionAlignment" - -# A block is a fixture, not production code. Snippets exist to set up a -# scenario, run inside a throwaway WordPress installation, are written to be -# read at a glance, and are routinely a single class or function on their own. -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,WordPress.NamingConventions.PrefixAllGlobals" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,WordPress.WP.GlobalVariablesOverride" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,WordPress.PHP.YodaConditions" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Universal.Files.SeparateFunctionsFromOO" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Generic.Files.OneObjectStructurePerFile" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Universal.Namespaces.OneDeclarationPerFile" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Universal.Namespaces.DisallowCurlyBraceSyntax" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Universal.Namespaces.DisallowDeclarationWithoutName" -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,PSR2.Methods.FunctionClosingBrace" - -# A snippet testing error handling is deliberately incomplete. -WP_CLI_TESTS_FEATURE_EXCLUDES="$WP_CLI_TESTS_FEATURE_EXCLUDES,Generic.CodeAnalysis.EmptyStatement" - -WP_CLI_TESTS_FEATURE_ARGS="$WP_CLI_TESTS_FEATURE_ARGS --exclude=$WP_CLI_TESTS_FEATURE_EXCLUDES"