diff --git a/.readme-partials/USING.md b/.readme-partials/USING.md index 3b2528f7..54b617e9 100644 --- a/.readme-partials/USING.md +++ b/.readme-partials/USING.md @@ -13,11 +13,13 @@ To make use of the WP-CLI testing framework, you need to complete the following "lint": "run-linter-tests", "phpcs": "run-phpcs-tests", "phpcbf": "run-phpcbf-cleanup", + "phpstan": "run-phpstan-tests", "phpunit": "run-php-unit-tests", "prepare-tests": "install-package-tests", "test": [ "@lint", "@phpcs", + "@phpstan", "@phpunit", "@behat" ] @@ -76,7 +78,9 @@ To make use of the WP-CLI testing framework, you need to complete the following ``` All other [PHPCS configuration options](https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki/Annotated-Ruleset) are, of course, available. -6. Update your composer dependencies and regenerate your autoloader and binary folders: +6. Optionally add a `phpstan-feature-files.neon.dist` file to the package root to also run PHPStan over the PHP snippets embedded in your feature files. See [Analysing the PHP blocks in feature files](#analysing-the-php-blocks-in-feature-files) below. + +7. Update your composer dependencies and regenerate your autoloader and binary folders: ```bash composer update ``` @@ -92,9 +96,75 @@ You can use the following commands to control the tests: * `composer lint` - Run only the linting test suite. * `composer phpcs` - Run only the code sniffer test suite. * `composer phpcbf` - Run only the code sniffer cleanup. +* `composer phpstan` - Run only the static analysis. * `composer phpunit` - Run only the unit test suite. * `composer behat` - Run only the functional test suite. +### Analysing the PHP blocks in feature files + +Feature files embed PHP snippets in docstrings, which none of the static analysis tools normally +look at: + +```gherkin +Given a wp-content/mu-plugins/test-harness.php file: + """ + given. + 🪪 argument.type +``` + +The defaults in `phpstan/feature-files.neon` are applied first, so the file only needs to hold what +it wants to change. An empty file is enough to run with the defaults, and a level of its own looks +like this: + +```neon +parameters: + level: 4 +``` + +Do note that snippets in feature files are fixtures, not production code, and that they run inside a +WordPress installation the analysis knows nothing about. Expect to have to ignore errors that are +not actually wrong, such as functions a scenario deliberately leaves undefined. + +An ignore matches against the extracted file rather than the feature file it came from. Those files +are named `_L_E.php`, with the feature file relative to the +`features` directory, so ignoring an error for a whole feature file takes a pattern: + +```neon +parameters: + ignoreErrors: + - + identifier: function.notFound + path: */shutdown-handler.feature_L*.php +``` + +Since the blocks are analysed in more than one run (see below), an ignore that no run matches is not +reported. A pattern that matches nothing at all therefore goes unnoticed, so it is worth checking +that the error it targets is really gone. + +Two kinds of blocks are left out of the analysis, and are listed at the end of the run: + +* Blocks that are not standalone PHP, such as snippets holding a placeholder that Behat substitutes + (`get_the_title( {POST_ID} )`) or code that is deliberately broken to test error handling. PHPStan + stops analysing altogether when a single file fails to parse, so these have to be skipped. +* Docstrings that neither belong to a step creating a `.php` file nor open with ` given. + 🪪 argument.type +``` + +The defaults in `phpstan/feature-files.neon` are applied first, so the file only needs to hold what +it wants to change. An empty file is enough to run with the defaults, and a level of its own looks +like this: + +```neon +parameters: + level: 4 +``` + +Do note that snippets in feature files are fixtures, not production code, and that they run inside a +WordPress installation the analysis knows nothing about. Expect to have to ignore errors that are +not actually wrong, such as functions a scenario deliberately leaves undefined. + +An ignore matches against the extracted file rather than the feature file it came from. Those files +are named `_L_E.php`, with the feature file relative to the +`features` directory, so ignoring an error for a whole feature file takes a pattern: + +```neon +parameters: + ignoreErrors: + - + identifier: function.notFound + path: */shutdown-handler.feature_L*.php +``` + +Since the blocks are analysed in more than one run (see below), an ignore that no run matches is not +reported. A pattern that matches nothing at all therefore goes unnoticed, so it is worth checking +that the error it targets is really gone. + +Two kinds of blocks are left out of the analysis, and are listed at the end of the run: + +* Blocks that are not standalone PHP, such as snippets holding a placeholder that Behat substitutes + (`get_the_title( {POST_ID} )`) or code that is deliberately broken to test error handling. PHPStan + stops analysing altogether when a single file fails to parse, so these have to be skipped. +* Docstrings that neither belong to a step creating a `.php` file nor open with `/dev/null || mktemp -d -t 'feature_phpstan') + trap 'rm -rf "$TEMP_DIR"' EXIT HUP INT TERM + + # Results are only reported when the extraction they are based on succeeded. + if php "$DIR/utils/phpstan-feature-files.php" extract features "$TEMP_DIR/blocks" + then + { + echo "includes:" + echo " - $DIR/phpstan/feature-files.neon" + echo " - $FEATURE_CONFIG" + + # The functions in the `WP_CLI\Utils` namespace are pulled in through the + # `files` autoloader, which does not make them known to PHPStan. + if [ -d "vendor/wp-cli/wp-cli" ] + then + echo "parameters:" + echo " scanDirectories:" + echo " - $(pwd)/vendor/wp-cli/wp-cli" + fi + } > "$TEMP_DIR/phpstan-feature-files.neon" + + # Blocks are spread over batches that do not declare the same symbol twice, + # so that PHPStan does not resolve a name to another block's declaration. + for BATCH in "$TEMP_DIR"/blocks/batch* + do + [ -d "$BATCH" ] || continue + + NAME="$(basename "$BATCH")" + + vendor/bin/phpstan --memory-limit=2048M analyse \ + --configuration="$TEMP_DIR/phpstan-feature-files.neon" \ + --error-format=json \ + --no-progress \ + "$BATCH" > "$TEMP_DIR/$NAME.json" 2>"$TEMP_DIR/$NAME.stderr" + + # The findings are reported against the feature files they came from, + # so PHPStan's own output is only of interest when it produced none. + if [ ! -s "$TEMP_DIR/$NAME.json" ] && [ -s "$TEMP_DIR/$NAME.stderr" ] + then + cat "$TEMP_DIR/$NAME.stderr" >&2 + fi + done + + # The glob stays unexpanded when no batch produced results, which is the + # case for a package whose feature files hold no PHP block at all. The + # report is still worth running, as it lists the blocks that were skipped. + set -- "$TEMP_DIR"/batch*.json + if [ -f "$1" ] + then + php "$DIR/utils/phpstan-feature-files.php" report "$TEMP_DIR/blocks" "$@" || EXIT_CODE=$? + else + php "$DIR/utils/phpstan-feature-files.php" report "$TEMP_DIR/blocks" || EXIT_CODE=$? + fi + else + EXIT_CODE=1 + fi +fi + +exit $EXIT_CODE diff --git a/composer.json b/composer.json index c3250787..b0f0ec82 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "type": "phpcodesniffer-standard", "require": { "php": ">=7.2.24", + "ext-tokenizer": "*", "behat/behat": "^v3.15.0", "dealerdirect/phpcodesniffer-composer-installer": "^0.4.3 || ^0.5 || ^0.6.2 || ^0.7.1 || ^1.0.0", "php-parallel-lint/php-console-highlighter": "^1.0", diff --git a/phpstan-feature-files.neon.dist b/phpstan-feature-files.neon.dist new file mode 100644 index 00000000..46fa8e98 --- /dev/null +++ b/phpstan-feature-files.neon.dist @@ -0,0 +1,8 @@ +# Opts this package into the static analysis of the PHP blocks in its feature files. +# +# The defaults in `phpstan/feature-files.neon` are applied first. Packages using the +# testing framework pick up `extension.neon` through phpstan/extension-installer, so +# they do not have to include it themselves. + +includes: + - extension.neon diff --git a/phpstan/feature-files.neon b/phpstan/feature-files.neon new file mode 100644 index 00000000..8ce37243 --- /dev/null +++ b/phpstan/feature-files.neon @@ -0,0 +1,27 @@ +# Defaults for the static analysis of the PHP blocks embedded in Behat feature files. +# +# A package opts into the analysis by adding a `phpstan-feature-files.neon` (or +# `phpstan-feature-files.neon.dist`) file to its root. These defaults are always +# applied first, so that file only needs to hold what it wants to change. +# +# The snippets in feature files are fixtures rather than production code, so the +# level is kept below the one that asks for type declarations everywhere. + +parameters: + level: 5 + treatPhpDocTypesAsCertain: false + + # Most packages will not run into all of the errors ignored below. + reportUnmatchedIgnoredErrors: false + + ignoreErrors: + # `wp eval-file` passes the remaining positional arguments to the file it runs. + - + identifier: variable.undefined + message: '#^Variable \$args might not be defined\.$#' + + # Files a snippet pulls in are created by other steps while the scenario runs. + - identifier: include.fileNotFound + - identifier: includeOnce.fileNotFound + - identifier: require.fileNotFound + - identifier: requireOnce.fileNotFound diff --git a/tests/tests/TestPhpStanFeatureFiles.php b/tests/tests/TestPhpStanFeatureFiles.php new file mode 100644 index 00000000..275957a3 --- /dev/null +++ b/tests/tests/TestPhpStanFeatureFiles.php @@ -0,0 +1,819 @@ +temp_dir = Utils\get_temp_dir() . uniqid( 'wp-cli-test-phpstan-feature-files-', true ); + $this->features_dir = $this->temp_dir . '/features'; + $this->target_dir = $this->temp_dir . '/extracted'; + + mkdir( $this->temp_dir ); + mkdir( $this->features_dir ); + } + + protected function tear_down(): void { + if ( is_dir( $this->temp_dir ) ) { + $this->remove_dir( $this->temp_dir ); + } + + parent::tear_down(); + } + + /** + * Recursively removes a directory and its contents. + * + * @param string $dir The directory to remove. + */ + private function remove_dir( $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $iterator as $file ) { + if ( $file->isDir() ) { + rmdir( $file->getPathname() ); + } else { + unlink( $file->getPathname() ); + } + } + + rmdir( $dir ); + } + + /** + * Runs the phpstan-feature-files.php script from within the temporary directory. + * + * @param string[] $args Arguments to pass to the script. + * @return array{output: string, exit_code: int} Combined output and exit code of the script. + */ + private function run_script( array $args ): array { + $script = dirname( dirname( __DIR__ ) ) . DIRECTORY_SEPARATOR . 'utils' . DIRECTORY_SEPARATOR . 'phpstan-feature-files.php'; + + // `php.ini` is loaded as usual here, as the script needs ext-tokenizer. + $command = escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( $script ); + + foreach ( $args as $arg ) { + $command .= ' ' . escapeshellarg( $arg ); + } + + $cd_command = Utils\is_windows() ? 'cd /d ' : 'cd '; + $command = $cd_command . escapeshellarg( $this->temp_dir ) . ' && ' . $command . ' 2>&1'; + + $output = array(); + $exit_code = 0; + + exec( $command, $output, $exit_code ); + + return array( + 'output' => implode( "\n", $output ), + 'exit_code' => $exit_code, + ); + } + + /** + * Creates a feature file in the features directory. + * + * @param string $relative_path Path relative to the features directory. + * @param string $contents Contents of the feature file. + * @return string Full path to the created file. + */ + private function create_feature_file( $relative_path, $contents ): string { + $path = $this->features_dir . '/' . $relative_path; + + $directory = dirname( $path ); + if ( ! is_dir( $directory ) ) { + mkdir( $directory, 0777, true ); + } + + file_put_contents( $path, $contents ); + + return $path; + } + + /** + * Returns the paths of all extracted files, relative to the target directory. + * + * @return string[] Sorted list of relative file paths. + */ + private function get_extracted_files(): array { + if ( ! is_dir( $this->target_dir ) ) { + return array(); + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $this->target_dir, \FilesystemIterator::SKIP_DOTS ) + ); + + $files = array(); + + foreach ( $iterator as $file ) { + if ( $file->isFile() && 'php' === $file->getExtension() ) { + $files[] = str_replace( '\\', '/', substr( $file->getPathname(), strlen( $this->target_dir ) + 1 ) ); + } + } + + sort( $files ); + + return $files; + } + + /** + * Returns the contents of an extracted file. + * + * @param string $relative_path Path relative to the target directory. + * @return string Contents of the file. + */ + private function get_extracted_contents( $relative_path ): string { + $contents = file_get_contents( $this->target_dir . '/' . $relative_path ); + + return false === $contents ? '' : $contents; + } + + /** + * Returns the manifest that extraction wrote to the target directory. + * + * @return array Decoded manifest. + */ + private function get_manifest(): array { + $contents = file_get_contents( $this->target_dir . '/manifest.json' ); + + $manifest = false === $contents ? null : json_decode( $contents, true ); + + return is_array( $manifest ) ? $manifest : array(); + } + + /** + * Writes a file holding the JSON output of a PHPStan run. + * + * @param string $name Name of the file to write. + * @param array>> $messages Messages per extracted file, relative to the target directory. + * @return string Full path to the created file. + */ + private function create_phpstan_results( $name, array $messages ): string { + $files = array(); + $total = 0; + + foreach ( $messages as $relative_path => $file_messages ) { + $files[ $this->target_dir . '/' . $relative_path ] = array( + 'errors' => count( $file_messages ), + 'messages' => $file_messages, + ); + + $total += count( $file_messages ); + } + + $path = $this->temp_dir . '/' . $name; + + file_put_contents( + $path, + (string) json_encode( + array( + 'totals' => array( + 'errors' => 0, + 'file_errors' => $total, + ), + 'files' => (object) $files, + 'errors' => array(), + ) + ) + ); + + return $path; + } + + public function test_extracts_block_with_opening_tag(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . "\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( array( 'batch0/example.feature_L5_E8.php' ), $this->get_extracted_files() ); + + // The opening tag goes on the first line, and the block is padded with one + // empty line per preceding line of the feature file, so that reported line + // numbers keep matching. The tag the block brought along is dropped. + $this->assertSame( + "get_extracted_contents( 'batch0/example.feature_L5_E8.php' ) + ); + } + + public function test_extracts_block_without_opening_tag(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . "\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " \$foo = 'bar';\n" + . " \"\"\"\n" + ); + + $result = $this->run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( array( 'batch0/example.feature_L5_E7.php' ), $this->get_extracted_files() ); + $this->assertSame( + "get_extracted_contents( 'batch0/example.feature_L5_E7.php' ) + ); + } + + public function test_extracts_multiple_blocks_from_one_feature_file(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: Two PHP blocks\n" + . " Given a first.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( + array( + 'batch0/example.feature_L4_E7.php', + 'batch0/example.feature_L9_E12.php', + ), + $this->get_extracted_files() + ); + $this->assertSame( + "get_extracted_contents( 'batch0/example.feature_L9_E12.php' ) + ); + } + + public function test_extracts_from_nested_directories(): void { + $this->create_feature_file( + 'sub/nested.feature', + "Feature: Nested\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( array( 'batch0/sub/nested.feature_L4_E7.php' ), $this->get_extracted_files() ); + } + + public function test_extraction_preserves_relative_indentation_and_empty_lines(): void { + $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( + "get_extracted_contents( 'batch0/example.feature_L4_E10.php' ) + ); + } + + public function test_extraction_skips_docstrings_that_are_not_php_files(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: An expectation about a file\n" + . " Then the wp-config.php file should contain:\n" + . " \"\"\"\n" + . " if ( defined( 'X' ) === false ) { define( 'X', true ); }\n" + . " \"\"\"\n" + ); + + $result = $this->run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( array(), $this->get_extracted_files() ); + } + + /** + * Anything in front of the opening tag counts as inline HTML, which makes a + * `declare()` or `namespace` statement a fatal error. PHPStan stops analysing + * altogether when a single file fails to parse, so the padding goes after the + * opening tag rather than in front of it. + */ + public function test_extraction_keeps_declare_and_namespace_statements_valid(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: A strict block\n" + . " Given a strict.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( + array( + 'batch0/example.feature_L10_E13.php', + 'batch0/example.feature_L4_E7.php', + ), + $this->get_extracted_files() + ); + $this->assertSame( array(), $this->get_manifest()['skipped'] ); + + foreach ( $this->get_extracted_files() as $extracted ) { + $output = array(); + $exit_code = 0; + exec( + escapeshellarg( PHP_BINARY ) . ' -l ' . escapeshellarg( $this->target_dir . '/' . $extracted ) . ' 2>&1', + $output, + $exit_code + ); + + $this->assertSame( 0, $exit_code, implode( "\n", $output ) ); + } + } + + public function test_extraction_skips_blocks_that_are_not_standalone_php(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: A block holding a Behat placeholder\n" + . " Given a placeholder.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( array( 'batch0/example.feature_L10_E13.php' ), $this->get_extracted_files() ); + + $skipped = $this->get_manifest()['skipped']; + + $this->assertCount( 1, $skipped ); + $this->assertSame( 4, $skipped[0]['line'] ); + $this->assertStringContainsString( 'features/example.feature', str_replace( '\\', '/', $skipped[0]['file'] ) ); + $this->assertStringContainsString( 'syntax error', $skipped[0]['reason'] ); + } + + public function test_extraction_keeps_blocks_declaring_the_same_symbol_apart(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: First definition\n" + . " Given a first.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( + array( + 'batch0/example.feature_L4_E7.php', + 'batch1/example.feature_L10_E13.php', + 'batch2/example.feature_L16_E20.php', + ), + $this->get_extracted_files() + ); + } + + public function test_extraction_keeps_blocks_without_shared_symbols_together(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: A class with methods\n" + . " Given a first.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + + // Methods are not global declarations, so both blocks fit into one batch. + $this->assertSame( + array( + 'batch0/example.feature_L12_E17.php', + 'batch0/example.feature_L4_E9.php', + ), + $this->get_extracted_files() + ); + } + + public function test_extraction_keeps_unrelated_files_in_target_directory(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " target_dir ); + file_put_contents( $this->target_dir . '/keep-me.txt', 'important' ); + mkdir( $this->target_dir . '/batch0', 0777, true ); + file_put_contents( $this->target_dir . '/batch0/stale.feature_L1_E2.php', 'run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertFileExists( $this->target_dir . '/keep-me.txt' ); + $this->assertSame( 'important', file_get_contents( $this->target_dir . '/keep-me.txt' ) ); + $this->assertFileDoesNotExist( $this->target_dir . '/batch0/stale.feature_L1_E2.php' ); + } + + public function test_extraction_refuses_to_use_the_source_directory_as_target(): void { + $contents = "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " create_feature_file( 'example.feature', $contents ); + + $result = $this->run_script( array( 'extract', 'features', 'features' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertFileExists( $feature_file ); + $this->assertSame( $contents, file_get_contents( $feature_file ) ); + } + + public function test_extraction_refuses_to_use_the_current_directory_as_target(): void { + $contents = "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " create_feature_file( 'example.feature', $contents ); + + $result = $this->run_script( array( 'extract', 'features', '.' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertDirectoryExists( $this->features_dir ); + $this->assertSame( $contents, file_get_contents( $feature_file ) ); + } + + public function test_extraction_reports_unterminated_docstring(): void { + $this->create_feature_file( + 'unterminated.feature', + "Feature: Unterminated\n" + . " Scenario: Unterminated docstring\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertStringContainsString( 'Unterminated docstring', $result['output'] ); + } + + public function test_extraction_reports_missing_source_directory(): void { + $result = $this->run_script( array( 'extract', 'does-not-exist', 'extracted' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertStringContainsString( 'does not exist', $result['output'] ); + } + + public function test_missing_arguments_are_reported(): void { + $result = $this->run_script( array( 'extract', 'features' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertStringContainsString( 'Usage:', $result['output'] ); + } + + public function test_report_maps_errors_back_onto_the_feature_file(): void { + $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->create_phpstan_results( + 'batch0.json', + array( + 'batch0/example.feature_L4_E7.php' => array( + array( + 'message' => 'Variable $undefined might not be defined.', + 'line' => 6, + 'ignorable' => true, + 'identifier' => 'variable.undefined', + ), + ), + ) + ); + + $result = $this->run_script( array( 'report', 'extracted', 'batch0.json' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertStringContainsString( 'features/example.feature', str_replace( '\\', '/', $result['output'] ) ); + $this->assertStringContainsString( 'Variable $undefined might not be defined.', $result['output'] ); + $this->assertStringContainsString( 'variable.undefined', $result['output'] ); + $this->assertMatchesRegularExpression( '/^\s+6\s+Variable/m', $result['output'] ); + $this->assertStringContainsString( 'Found 1 error(s)', $result['output'] ); + } + + /** + * The path PHPStan reports is not necessarily spelled the way the extraction + * wrote it: macOS resolves `/var` to `/private/var`, Windows has a short and + * a long form of a directory name. + */ + public function test_report_maps_errors_from_a_differently_spelled_path(): void { + $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' ) ); + + $resolved = realpath( $this->target_dir ); + + $this->assertNotFalse( $resolved ); + + // Report the file through a spelling that differs from the one the + // extraction was given, the way the platforms above do it. The `/./` + // segment reproduces that on every platform. + file_put_contents( + $this->temp_dir . '/batch0.json', + (string) json_encode( + array( + 'totals' => array( + 'errors' => 0, + 'file_errors' => 1, + ), + 'files' => array( + str_replace( '\\', '/', $resolved ) . '/./batch0/example.feature_L4_E7.php' => array( + 'errors' => 1, + 'messages' => array( + array( + 'message' => 'Variable $undefined might not be defined.', + 'line' => 6, + 'ignorable' => true, + 'identifier' => 'variable.undefined', + ), + ), + ), + ), + 'errors' => array(), + ) + ) + ); + + $result = $this->run_script( array( 'report', 'extracted', 'batch0.json' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertStringNotContainsString( 'Unexpected file', $result['output'] ); + $this->assertStringContainsString( 'features/example.feature', str_replace( '\\', '/', $result['output'] ) ); + } + + public function test_report_tolerates_results_without_messages(): void { + $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' ) ); + + file_put_contents( + $this->temp_dir . '/batch0.json', + (string) json_encode( + array( + 'files' => array( + $this->target_dir . '/batch0/example.feature_L4_E7.php' => array( 'errors' => 1 ), + ), + ) + ) + ); + + $result = $this->run_script( array( 'report', 'extracted', 'batch0.json' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertStringNotContainsString( 'Warning', $result['output'] ); + } + + public function test_report_without_results_lists_skipped_blocks(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: A block holding a Behat placeholder\n" + . " Given a placeholder.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + // A package whose feature files hold no analysable block produces no + // PHPStan results at all, which must not be reported as a failure. + $result = $this->run_script( array( 'report', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertStringContainsString( 'Skipped 1 PHP block(s)', $result['output'] ); + $this->assertStringContainsString( 'No errors', $result['output'] ); + } + + public function test_report_succeeds_without_errors(): void { + $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->create_phpstan_results( 'batch0.json', array() ); + + $result = $this->run_script( array( 'report', 'extracted', 'batch0.json' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertStringContainsString( 'No errors', $result['output'] ); + } + + public function test_report_mentions_skipped_blocks(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: A block holding a Behat placeholder\n" + . " Given a placeholder.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + $this->create_phpstan_results( 'batch0.json', array() ); + + $result = $this->run_script( array( 'report', 'extracted', 'batch0.json' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertStringContainsString( 'Skipped 1 PHP block(s)', $result['output'] ); + $this->assertStringContainsString( 'example.feature:4', str_replace( '\\', '/', $result['output'] ) ); + } + + public function test_report_fails_on_unreadable_results(): void { + $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' ) ); + file_put_contents( $this->temp_dir . '/batch0.json', 'not json' ); + + $result = $this->run_script( array( 'report', 'extracted', 'batch0.json' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertStringContainsString( 'Could not read the PHPStan results', $result['output'] ); + } + + public function test_report_fails_without_a_manifest(): void { + $result = $this->run_script( array( 'report', 'extracted', 'batch0.json' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertStringContainsString( 'Could not read the manifest', $result['output'] ); + } +} diff --git a/utils/phpstan-feature-files.php b/utils/phpstan-feature-files.php new file mode 100644 index 00000000..b4339ef1 --- /dev/null +++ b/utils/phpstan-feature-files.php @@ -0,0 +1,761 @@ +getPathname(); + + if ( $fileinfo->isDir() ) { + $contents = new FilesystemIterator( $pathname ); + if ( ! $contents->valid() ) { + rmdir( $pathname ); + } + } elseif ( preg_match( EXTRACTED_FILE_PATTERN, $fileinfo->getFilename() ) ) { + unlink( $pathname ); + } + } +} + +/** + * Determine whether a step creates a PHP file. + * + * The docstring following such a step holds the contents of a PHP file, while + * docstrings following other steps -- an expectation about the contents of a + * file, for example -- are not necessarily PHP code. A docstring that opens + * with `}>|null Blocks, or null on an unterminated docstring. + */ +function collect_blocks( array $lines ) { + $blocks = []; + $in_docstring = false; + $is_php_block = false; + $has_content = false; + $start_line = 0; + $docstring_lines = []; + + foreach ( $lines as $index => $line ) { + $trimmed = trim( $line ); + + if ( 0 === strpos( $trimmed, '"""' ) || 0 === strpos( $trimmed, "'''" ) ) { + if ( ! $in_docstring ) { + $in_docstring = true; + $is_php_block = $index > 0 && is_php_file_step( $lines[ $index - 1 ] ); + $has_content = false; + $docstring_lines = []; + $start_line = $index; + } else { + $in_docstring = false; + + if ( $is_php_block && ! empty( $docstring_lines ) ) { + $blocks[] = [ + 'start' => $start_line, + 'end' => $index, + 'lines' => $docstring_lines, + ]; + } + } + continue; + } + + if ( $in_docstring ) { + // A block opening with `} $block Block to render. + * @return string Source of the standalone PHP file. + */ +function render_block( array $block ) { + $min_indent = PHP_INT_MAX; + foreach ( $block['lines'] as $code_line ) { + if ( '' !== trim( $code_line ) ) { + preg_match( '/^[ \t]*/', $code_line, $matches ); + $min_indent = min( $min_indent, strlen( $matches[0] ) ); + } + } + if ( PHP_INT_MAX === $min_indent ) { + $min_indent = 0; + } + + $out_lines = []; + for ( $i = 0; $i <= $block['start']; $i++ ) { + $out_lines[ $i ] = "\n"; + } + $out_lines[0] = " $code_line ) { + if ( '' === trim( $code_line ) ) { + $out_lines[ $line_idx ] = "\n"; + continue; + } + + $code_line = substr( $code_line, $min_indent ); + + if ( ! $tag_dropped ) { + $tag_dropped = true; + + $without_tag = preg_replace( '/^\s*<\?php\b/', '', $code_line, 1, $count ); + if ( $count > 0 ) { + $code_line = '' === trim( (string) $without_tag ) ? "\n" : (string) $without_tag; + } + } + + $out_lines[ $line_idx ] = $code_line; + } + + $source = implode( '', $out_lines ); + + return "\n" === substr( $source, -1 ) ? $source : $source . "\n"; +} + +/** + * Determine whether a block can be parsed as standalone PHP. + * + * A single unparsable file makes PHPStan abort the whole run, and feature files + * legitimately contain snippets that are not standalone PHP: deliberate syntax + * errors, or Behat placeholders such as `{USER_ID}` that are substituted before + * the snippet is ever written to disk. + * + * @param string $source Source of the standalone PHP file. + * @return string|null Parse error message, or null when the source parses. + */ +function get_parse_error( $source ) { + try { + // The tokens are of no interest here, only whether the source parses at all. + token_get_all( $source, TOKEN_PARSE ); + } catch ( ParseError $exception ) { + return $exception->getMessage(); + } + + return null; +} + +/** + * Collect the names of the classes and functions a block declares globally. + * + * PHPStan resolves a name to a single declaration, so two blocks declaring the + * same class produce errors about members that only exist on the other block's + * version of it. Declarations nested in a conditional are not detected, which + * can only lead to blocks sharing a batch that would better be kept apart. + * + * @param string $source Source of the standalone PHP file. + * @return string[] Lowercased names of the declared symbols. + */ +function get_declared_symbols( $source ) { + $declarations = [ T_CLASS, T_INTERFACE, T_TRAIT, T_FUNCTION ]; + if ( defined( 'T_ENUM' ) ) { + $declarations[] = constant( 'T_ENUM' ); + } + + $tokens = token_get_all( $source ); + $count = count( $tokens ); + $symbols = []; + + // Brace depths at which the body of a class or function was opened, so that + // methods and nested functions are not mistaken for global declarations. + $body_stack = []; + $depth = 0; + $pending_body = false; + $previous = null; + + for ( $index = 0; $index < $count; $index++ ) { + $token = $tokens[ $index ]; + + if ( ! is_array( $token ) ) { + if ( '{' === $token ) { + ++$depth; + if ( $pending_body ) { + $body_stack[] = $depth; + $pending_body = false; + } + } elseif ( '}' === $token ) { + if ( ! empty( $body_stack ) && end( $body_stack ) === $depth ) { + array_pop( $body_stack ); + } + --$depth; + } elseif ( ';' === $token ) { + $pending_body = false; + } + + $previous = $token; + continue; + } + + if ( T_CURLY_OPEN === $token[0] || T_DOLLAR_OPEN_CURLY_BRACES === $token[0] ) { + ++$depth; + $previous = $token; + continue; + } + + if ( T_WHITESPACE === $token[0] || T_COMMENT === $token[0] || T_DOC_COMMENT === $token[0] ) { + continue; + } + + // `use function foo;` and `use Foo;` are imports, not declarations. + if ( in_array( $token[0], $declarations, true ) && ! ( is_array( $previous ) && T_USE === $previous[0] ) ) { + $name = null; + + for ( $lookahead = $index + 1; $lookahead < $count; $lookahead++ ) { + $next = $tokens[ $lookahead ]; + + if ( is_array( $next ) && ( T_WHITESPACE === $next[0] || T_COMMENT === $next[0] || T_DOC_COMMENT === $next[0] ) ) { + continue; + } + + // A function returning by reference. + if ( '&' === $next ) { + continue; + } + + if ( is_array( $next ) && T_STRING === $next[0] ) { + $name = $next[1]; + } + + break; + } + + if ( null !== $name ) { + $pending_body = true; + + if ( empty( $body_stack ) ) { + $symbols[] = strtolower( $name ); + } + } + } + + $previous = $token; + } + + return array_values( array_unique( $symbols ) ); +} + +/** + * Distribute blocks over batches that do not declare the same symbol twice. + * + * @param array $blocks Blocks to distribute. + * @return int[] Batch number for each block, keyed by the block's key. + */ +function assign_batches( array $blocks ) { + $batches = []; + $assignment = []; + + foreach ( $blocks as $key => $block ) { + $batch = 0; + + while ( isset( $batches[ $batch ] ) && array_intersect( $block['symbols'], $batches[ $batch ] ) ) { + ++$batch; + } + + if ( ! isset( $batches[ $batch ] ) ) { + $batches[ $batch ] = []; + } + + $batches[ $batch ] = array_merge( $batches[ $batch ], $block['symbols'] ); + $assignment[ $key ] = $batch; + } + + return $assignment; +} + +/** + * Extract the PHP blocks of a source directory of feature files to a target directory. + * + * @param string $source_dir Source directory containing .feature files. + * @param string $target_dir Target directory to output extracted .php files. + * @return bool Whether extraction completed successfully. + */ +function extract_feature_php( $source_dir, $target_dir ) { + $source_dir = rtrim( str_replace( '\\', '/', $source_dir ), '/' ); + $target_dir = rtrim( str_replace( '\\', '/', $target_dir ), '/' ); + + if ( ! is_dir( $source_dir ) ) { + fwrite( STDERR, sprintf( 'Source directory "%s" does not exist.', $source_dir ) . PHP_EOL ); + return false; + } + + if ( ! is_valid_target_dir( $target_dir, $source_dir ) ) { + fwrite( STDERR, sprintf( 'Refusing to use "%s" as target directory.', $target_dir ) . PHP_EOL ); + return false; + } + + remove_extracted_files( $target_dir ); + + $success = true; + $blocks = []; + $skipped = []; + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $source_dir, FilesystemIterator::SKIP_DOTS ) + ); + + $feature_files = []; + foreach ( $iterator as $file ) { + if ( $file->isFile() && 'feature' === $file->getExtension() ) { + $feature_files[] = str_replace( '\\', '/', $file->getPathname() ); + } + } + + // The order determines the batch a block ends up in, so keep it stable. + sort( $feature_files ); + + foreach ( $feature_files as $filepath ) { + $relative = substr( $filepath, strlen( $source_dir ) + 1 ); + $lines = file( $filepath ); + + if ( false === $lines ) { + fwrite( STDERR, sprintf( 'Could not read "%s".', $filepath ) . PHP_EOL ); + $success = false; + continue; + } + + $found = collect_blocks( $lines ); + + if ( null === $found ) { + fwrite( STDERR, sprintf( 'Unterminated docstring in "%s".', $filepath ) . PHP_EOL ); + $success = false; + continue; + } + + foreach ( $found as $block ) { + $source = render_block( $block ); + $error = get_parse_error( $source ); + + if ( null !== $error ) { + $skipped[] = [ + 'file' => $filepath, + 'line' => $block['start'] + 1, + 'reason' => $error, + ]; + continue; + } + + $blocks[] = [ + 'feature' => $filepath, + 'name' => $relative . '_L' . ( $block['start'] + 1 ) . '_E' . ( $block['end'] + 1 ) . '.php', + 'source' => $source, + 'symbols' => get_declared_symbols( $source ), + ]; + } + } + + $assignment = assign_batches( $blocks ); + $manifest = [ + 'source_dir' => $source_dir, + 'blocks' => [], + 'skipped' => $skipped, + ]; + + foreach ( $blocks as $key => $block ) { + $relative_target = 'batch' . $assignment[ $key ] . '/' . $block['name']; + $target_file = $target_dir . '/' . $relative_target; + $target_subdir = dirname( $target_file ); + + if ( ! is_dir( $target_subdir ) && ! mkdir( $target_subdir, 0777, true ) && ! is_dir( $target_subdir ) ) { + fwrite( STDERR, sprintf( 'Could not create directory "%s".', $target_subdir ) . PHP_EOL ); + $success = false; + continue; + } + + if ( false === file_put_contents( $target_file, $block['source'] ) ) { + fwrite( STDERR, sprintf( 'Could not write "%s".', $target_file ) . PHP_EOL ); + $success = false; + continue; + } + + $manifest['blocks'][ $relative_target ] = $block['feature']; + } + + if ( ! is_dir( $target_dir ) && ! mkdir( $target_dir, 0777, true ) && ! is_dir( $target_dir ) ) { + fwrite( STDERR, sprintf( 'Could not create directory "%s".', $target_dir ) . PHP_EOL ); + return false; + } + + $encoded = json_encode( $manifest ); + + if ( false === $encoded || false === file_put_contents( $target_dir . '/' . MANIFEST_FILE, $encoded ) ) { + fwrite( STDERR, sprintf( 'Could not write the manifest to "%s".', $target_dir ) . PHP_EOL ); + return false; + } + + return $success; +} + +/** + * Read the manifest of an extraction. + * + * @param string $target_dir Target directory containing extracted .php files. + * @return array{source_dir: string, blocks: array, skipped: array}|null Manifest, or null when it cannot be read. + */ +function read_manifest( $target_dir ) { + $path = rtrim( str_replace( '\\', '/', $target_dir ), '/' ) . '/' . MANIFEST_FILE; + + if ( ! is_file( $path ) ) { + return null; + } + + $contents = file_get_contents( $path ); + + if ( false === $contents ) { + return null; + } + + $manifest = json_decode( $contents, true ); + + if ( ! is_array( $manifest ) || ! isset( $manifest['blocks'] ) || ! is_array( $manifest['blocks'] ) ) { + return null; + } + + $manifest['skipped'] = isset( $manifest['skipped'] ) && is_array( $manifest['skipped'] ) ? $manifest['skipped'] : []; + + return $manifest; +} + +/** + * Turn the errors PHPStan reported for the extracted files back into errors + * about the feature files they came from. + * + * @param array $blocks Feature file for each extracted file, keyed by its path relative to the target directory. + * @param string $target_dir Target directory containing extracted .php files. + * @param array> $results PHPStan results, decoded from its JSON output. + * @return array{errors: array>, generic: string[]} Errors per feature file, plus errors not tied to a file. + */ +function map_errors( array $blocks, $target_dir, array $results ) { + $target_dir = rtrim( str_replace( '\\', '/', $target_dir ), '/' ); + + // The path PHPStan reports and the path the extraction wrote are not + // necessarily spelled the same: macOS resolves `/var` to `/private/var` and + // Windows has both a short and a long form of a directory name. Both + // spellings of every extracted file are therefore looked up. + $lookup = []; + foreach ( $blocks as $relative_target => $feature ) { + $path = $target_dir . '/' . $relative_target; + $real = realpath( $path ); + + $lookup[ normalize_path( $path ) ] = $feature; + + if ( false !== $real ) { + $lookup[ normalize_path( $real ) ] = $feature; + } + } + + $errors = []; + $generic = []; + + foreach ( $results as $result ) { + if ( isset( $result['errors'] ) && is_array( $result['errors'] ) ) { + foreach ( $result['errors'] as $error ) { + $generic[] = (string) $error; + } + } + + if ( ! isset( $result['files'] ) || ! is_array( $result['files'] ) ) { + continue; + } + + foreach ( $result['files'] as $path => $file ) { + $path = (string) $path; + $real = realpath( $path ); + $feature = null; + + foreach ( [ $path, false === $real ? null : $real ] as $candidate ) { + if ( null !== $candidate && isset( $lookup[ normalize_path( $candidate ) ] ) ) { + $feature = $lookup[ normalize_path( $candidate ) ]; + break; + } + } + + if ( null === $feature ) { + $generic[] = sprintf( 'Unexpected file "%s" in the PHPStan results.', str_replace( '\\', '/', $path ) ); + continue; + } + + if ( ! isset( $errors[ $feature ] ) ) { + $errors[ $feature ] = []; + } + + if ( ! isset( $file['messages'] ) || ! is_array( $file['messages'] ) ) { + continue; + } + + foreach ( $file['messages'] as $message ) { + if ( ! is_array( $message ) ) { + continue; + } + + $errors[ $feature ][] = [ + 'line' => isset( $message['line'] ) ? (int) $message['line'] : 0, + 'message' => isset( $message['message'] ) ? (string) $message['message'] : '', + 'identifier' => isset( $message['identifier'] ) ? (string) $message['identifier'] : '', + ]; + } + } + } + + foreach ( $errors as $feature => $messages ) { + usort( + $messages, + function ( $a, $b ) { + return $a['line'] <=> $b['line']; + } + ); + $errors[ $feature ] = $messages; + } + + ksort( $errors ); + + return [ + 'errors' => $errors, + 'generic' => $generic, + ]; +} + +/** + * Report the errors PHPStan found in the PHP blocks of feature files. + * + * @param string $target_dir Target directory containing extracted .php files. + * @param string[] $json_files Files holding the JSON output of a PHPStan run. + * @return bool Whether the blocks are free of errors. + */ +function report_feature_php( $target_dir, array $json_files ) { + $manifest = read_manifest( $target_dir ); + + if ( null === $manifest ) { + fwrite( STDERR, sprintf( 'Could not read the manifest in "%s".', $target_dir ) . PHP_EOL ); + return false; + } + + $results = []; + + foreach ( $json_files as $json_file ) { + $contents = is_file( $json_file ) ? file_get_contents( $json_file ) : false; + $decoded = false === $contents ? null : json_decode( $contents, true ); + + if ( ! is_array( $decoded ) ) { + fwrite( STDERR, sprintf( 'Could not read the PHPStan results from "%s".', $json_file ) . PHP_EOL ); + return false; + } + + $results[] = $decoded; + } + + $mapped = map_errors( $manifest['blocks'], $target_dir, $results ); + $total = 0; + + foreach ( $mapped['errors'] as $feature => $messages ) { + echo PHP_EOL . ' ' . $feature . PHP_EOL; + + foreach ( $messages as $message ) { + ++$total; + + // A message can span multiple lines, for example when it explains a deprecation. + $text = str_replace( "\n", PHP_EOL . ' ', rtrim( str_replace( "\r\n", "\n", $message['message'] ) ) ); + + printf( ' %-6d %s%s', $message['line'], $text, PHP_EOL ); + + if ( '' !== $message['identifier'] ) { + printf( ' 🪪 %s%s', $message['identifier'], PHP_EOL ); + } + } + } + + foreach ( $mapped['generic'] as $message ) { + ++$total; + echo PHP_EOL . ' ' . $message . PHP_EOL; + } + + if ( ! empty( $manifest['skipped'] ) ) { + printf( + '%s [NOTE] Skipped %d PHP block(s) that are not standalone PHP.%s', + PHP_EOL, + count( $manifest['skipped'] ), + PHP_EOL + ); + + foreach ( $manifest['skipped'] as $skipped ) { + printf( ' %s:%d: %s%s', $skipped['file'], $skipped['line'], $skipped['reason'], PHP_EOL ); + } + } + + if ( 0 === $total ) { + printf( '%s [OK] No errors in the PHP blocks of the feature files.%s', PHP_EOL, PHP_EOL ); + return true; + } + + printf( '%s [ERROR] Found %d error(s) in the PHP blocks of the feature files.%s', PHP_EOL, $total, PHP_EOL ); + + return false; +} + +// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound +if ( ! function_exists( 'token_get_all' ) ) { + fwrite( STDERR, 'The PHP tokenizer extension is required to analyse the PHP blocks in feature files.' . PHP_EOL ); + exit( 1 ); +} + +$wp_cli_tests_args = array_slice( $argv, 1 ); +$wp_cli_tests_action = array_shift( $wp_cli_tests_args ); + +if ( 'extract' === $wp_cli_tests_action && 2 === count( $wp_cli_tests_args ) ) { + exit( extract_feature_php( $wp_cli_tests_args[0], $wp_cli_tests_args[1] ) ? 0 : 1 ); +} + +if ( 'report' === $wp_cli_tests_action && count( $wp_cli_tests_args ) >= 1 ) { + exit( report_feature_php( array_shift( $wp_cli_tests_args ), $wp_cli_tests_args ) ? 0 : 1 ); +} + +fwrite( + STDERR, + 'Usage: phpstan-feature-files.php extract ' . PHP_EOL + . ' phpstan-feature-files.php report ...' . PHP_EOL +); +exit( 1 );