From b30a367b9b6ccf9dba1b2b5c40c157a0458d97a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:16:00 +0000 Subject: [PATCH 01/17] Initial plan From cb2df63eb045cd9fe915127ce255590a639b1b07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:28:12 +0000 Subject: [PATCH 02/17] Add new i18n audit command with multiple output formats Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- composer.json | 1 + features/audit.feature | 258 ++++++++++++++++ i18n-command.php | 12 + src/AuditCommand.php | 648 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 919 insertions(+) create mode 100644 features/audit.feature create mode 100644 src/AuditCommand.php diff --git a/composer.json b/composer.json index fb52886e..410135fd 100644 --- a/composer.json +++ b/composer.json @@ -41,6 +41,7 @@ "bundled": true, "commands": [ "i18n", + "i18n audit", "i18n make-pot", "i18n make-json", "i18n make-mo", diff --git a/features/audit.feature b/features/audit.feature new file mode 100644 index 00000000..d2459a11 --- /dev/null +++ b/features/audit.feature @@ -0,0 +1,258 @@ +Feature: Audit strings in a WordPress project + + Background: + Given a WP install + + Scenario: Audits a plugin for translation issues + Given an empty foo-plugin directory + And a foo-plugin/foo-plugin.php file: + """ + static function () { + if ( ! function_exists( 'mb_ereg' ) ) { + WP_CLI::error( 'The mbstring extension is required for string extraction to work reliably.' ); + } + }, + ) +); diff --git a/src/AuditCommand.php b/src/AuditCommand.php new file mode 100644 index 00000000..0580303c --- /dev/null +++ b/src/AuditCommand.php @@ -0,0 +1,648 @@ + + * : Directory to scan for string extraction. + * + * [--slug=] + * : Plugin or theme slug. Defaults to the source directory's basename. + * + * [--domain=] + * : Text domain to look for in the source code, unless the `--ignore-domain` option is used. + * By default, the "Text Domain" header of the plugin or theme is used. + * If none is provided, it falls back to the project slug. + * + * [--ignore-domain] + * : Ignore the text domain completely and extract strings with any text domain. + * + * [--include=] + * : Comma-separated list of files and paths that should be used for string extraction. + * If provided, only these files and folders will be taken into account. + * + * [--exclude=] + * : Comma-separated list of files and paths that should be ignored for string extraction. + * Defaults to 'node_modules,.git,.svn,vendor,Gruntfile.js,webpack.config.js,*.min.js,test,tests'. + * + * [--skip-js] + * : Skips JavaScript string extraction. + * + * [--skip-php] + * : Skips PHP string extraction. + * + * [--skip-blade] + * : Skips Blade-PHP string extraction. + * + * [--skip-block-json] + * : Skips string extraction from block.json files. + * + * [--skip-theme-json] + * : Skips string extraction from theme.json files. + * + * [--format=] + * : Output format for the audit results. + * --- + * default: plaintext + * options: + * - plaintext + * - json + * - github-actions + * --- + * + * ## EXAMPLES + * + * # Audit a plugin for possible translation issues. + * $ wp i18n audit wp-content/plugins/hello-world + * + * # Audit a plugin and output results as JSON. + * $ wp i18n audit wp-content/plugins/hello-world --format=json + * + * # Audit a plugin with GitHub Actions annotations format. + * $ wp i18n audit wp-content/plugins/hello-world --format=github-actions + * + * @when before_wp_load + * + * @throws WP_CLI\ExitException + */ + public function __invoke( $args, $assoc_args ) { + $this->source = realpath( $args[0] ); + if ( ! $this->source || ! is_dir( $this->source ) ) { + WP_CLI::error( 'Not a valid source directory.' ); + } + + $this->slug = Utils\get_flag_value( $assoc_args, 'slug', Utils\basename( $this->source ) ); + $this->domain = Utils\get_flag_value( $assoc_args, 'domain', null ); + $this->skip_js = Utils\get_flag_value( $assoc_args, 'skip-js', $this->skip_js ); + $this->skip_php = Utils\get_flag_value( $assoc_args, 'skip-php', $this->skip_php ); + $this->skip_blade = Utils\get_flag_value( $assoc_args, 'skip-blade', $this->skip_blade ); + $this->skip_block_json = Utils\get_flag_value( $assoc_args, 'skip-block-json', $this->skip_block_json ); + $this->skip_theme_json = Utils\get_flag_value( $assoc_args, 'skip-theme-json', $this->skip_theme_json ); + $this->format = Utils\get_flag_value( $assoc_args, 'format', $this->format ); + $ignore_domain = Utils\get_flag_value( $assoc_args, 'ignore-domain', false ); + + $include = Utils\get_flag_value( $assoc_args, 'include', [] ); + if ( ! empty( $include ) ) { + $this->include = array_map( 'trim', explode( ',', $include ) ); + } + + $exclude = Utils\get_flag_value( $assoc_args, 'exclude', [] ); + if ( ! empty( $exclude ) ) { + $this->exclude = array_map( 'trim', explode( ',', $exclude ) ); + } + + $this->get_main_file_data(); + + if ( null === $this->domain ) { + if ( ! empty( $this->main_file_data['Text Domain'] ) && ! $ignore_domain ) { + $this->domain = $this->main_file_data['Text Domain']; + } else { + $this->domain = $this->slug; + } + } + + WP_CLI::debug( + sprintf( + 'Auditing strings for %s, using "%s" as text domain.', + $this->slug, + $this->domain + ), + 'make-pot' + ); + + $translations = $this->extract_strings(); + + $issues = $this->audit_strings( $translations ); + + $this->output_results( $issues ); + + $issue_count = count( $issues ); + + if ( $issue_count > 0 ) { + WP_CLI::warning( sprintf( 'Found %d %s.', $issue_count, Utils\pluralize( 'issue', $issue_count ) ) ); + } else { + WP_CLI::success( 'No issues found.' ); + } + } + + /** + * Returns the file data of the main plugin or theme file. + * + * @return array + */ + protected function get_main_file_data() { + if ( ! empty( $this->main_file_data ) ) { + return $this->main_file_data; + } + + $type = FileDataExtractor::get_file_type( $this->source ); + + if ( 'unknown' === $type ) { + WP_CLI::debug( 'Could not detect file type.', 'make-pot' ); + } else { + WP_CLI::debug( sprintf( '%s file detected.', ucfirst( $type ) ), 'make-pot' ); + } + + $this->project_type = $type; + + $location = FileDataExtractor::find_main_file( $this->source, $type ); + + if ( ! $location ) { + return []; + } + + $this->main_file_path = $location; + + return $this->main_file_data = FileDataExtractor::get_file_data( $location, $type ); + } + + /** + * Returns the file headers for a given project type. + * + * @param string $type Project type. + * @return array + */ + protected function get_file_headers( $type ) { + switch ( $type ) { + case 'plugin': + return [ + 'Plugin Name', + 'Plugin URI', + 'Description', + 'Author', + 'Author URI', + 'Version', + 'License', + 'Domain Path', + 'Text Domain', + ]; + case 'theme': + return [ + 'Theme Name', + 'Theme URI', + 'Description', + 'Author', + 'Author URI', + 'Version', + 'License', + 'Domain Path', + 'Text Domain', + ]; + default: + return []; + } + } + + /** + * Extracts strings from source code. + * + * @return Translations A Translation set. + */ + protected function extract_strings() { + $translations = new Translations(); + + if ( $this->domain ) { + $translations->setDomain( $this->domain ); + } + + $is_theme = isset( $this->main_file_data['Theme Name'] ); + + try { + if ( ! $this->skip_php ) { + $options = [ + // Extract 'Template Name' headers in theme files. + 'wpExtractTemplates' => $is_theme, + // Extract 'Title' and 'Description' headers from pattern files. + 'wpExtractPatterns' => $is_theme, + 'include' => $this->include, + 'exclude' => $this->exclude, + 'extensions' => [ 'php' ], + 'addReferences' => $this->location, + ]; + PhpCodeExtractor::fromDirectory( $this->source, $translations, $options ); + } + + if ( ! $this->skip_blade ) { + $options = [ + 'include' => $this->include, + 'exclude' => $this->exclude, + 'extensions' => [ 'blade.php' ], + 'addReferences' => $this->location, + ]; + BladeCodeExtractor::fromDirectory( $this->source, $translations, $options ); + } + + if ( ! $this->skip_js ) { + JsCodeExtractor::fromDirectory( + $this->source, + $translations, + [ + 'include' => $this->include, + 'exclude' => $this->exclude, + 'extensions' => [ 'js', 'jsx' ], + 'addReferences' => $this->location, + ] + ); + + MapCodeExtractor::fromDirectory( + $this->source, + $translations, + [ + 'include' => $this->include, + 'exclude' => $this->exclude, + 'extensions' => [ 'map' ], + 'addReferences' => $this->location, + ] + ); + } + + if ( ! $this->skip_block_json ) { + BlockExtractor::fromDirectory( + $this->source, + $translations, + [ + 'schema' => JsonSchemaExtractor::BLOCK_JSON_SOURCE, + 'schemaFallback' => JsonSchemaExtractor::BLOCK_JSON_FALLBACK, + // Only look for block.json files in any folder, nothing else. + 'restrictFileNames' => [ 'block.json' ], + 'include' => $this->include, + 'exclude' => $this->exclude, + 'extensions' => [ 'json' ], + 'addReferences' => $this->location, + ] + ); + } + + if ( ! $this->skip_theme_json ) { + JsonSchemaExtractor::fromDirectory( + $this->source, + $translations, + [ + // Only look for theme.json files in any folder, nothing else. + 'restrictFileNames' => [ 'theme.json' ], + 'schema' => JsonSchemaExtractor::THEME_JSON_SOURCE, + 'schemaFallback' => JsonSchemaExtractor::THEME_JSON_FALLBACK, + 'include' => $this->include, + 'exclude' => $this->exclude, + 'extensions' => [ 'json' ], + 'addReferences' => $this->location, + ] + ); + + // Themes can have style variations in the top-level "styles" folder. + // They're like theme.json but can have any name. + if ( $is_theme ) { + JsonSchemaExtractor::fromDirectory( + $this->source, + $translations, + [ + 'restrictDirectories' => [ 'styles' ], + 'schema' => JsonSchemaExtractor::THEME_JSON_SOURCE, + 'schemaFallback' => JsonSchemaExtractor::THEME_JSON_FALLBACK, + 'include' => $this->include, + 'exclude' => $this->exclude, + 'extensions' => [ 'json' ], + 'addReferences' => $this->location, + ] + ); + } + } + } catch ( \Exception $e ) { + WP_CLI::error( $e->getMessage() ); + } + + return $translations; + } + + /** + * Audits strings. + * + * Goes through all extracted strings to find possible mistakes. + * + * @param Translations $translations Translations object. + * @return array Array of issues found. + */ + protected function audit_strings( $translations ) { + $issues = []; + + foreach ( $translations as $translation ) { + /** @var Translation $translation */ + + $references = $translation->getReferences(); + + // File headers don't have any file references. + if ( ! $translation->hasReferences() ) { + continue; + } + + $file = $references[0][0]; + $line = $references[0][1] ?? null; + $location = $line ? "$file:$line" : $file; + + // Check 1: Flag strings with placeholders that should have translator comments. + if ( + ! $translation->hasExtractedComments() && + preg_match( self::SPRINTF_PLACEHOLDER_REGEX, $translation->getOriginal(), $placeholders ) >= 1 + ) { + $issues[] = [ + 'file' => $file, + 'line' => $line, + 'message' => sprintf( + 'The string "%s" contains placeholders but has no "translators:" comment to clarify their meaning.', + $translation->getOriginal() + ), + 'code' => 'missing-translator-comment', + ]; + } + + // Check 2: Flag strings with different translator comments. + if ( $translation->hasExtractedComments() ) { + $comments = $translation->getExtractedComments(); + + // Remove plugin header information from comments. + $comments = array_filter( + $comments, + function ( $comment ) { + /** @var ParsedComment|string $comment */ + /** @var string $file_header */ + foreach ( $this->get_file_headers( $this->project_type ) as $file_header ) { + if ( 0 === strpos( ( $comment instanceof ParsedComment ? $comment->getComment() : $comment ), $file_header ) ) { + return null; + } + } + + return $comment; + } + ); + + $unique_comments = array(); + + // Remove duplicate comments. + $comments = array_filter( + $comments, + function ( $comment ) use ( &$unique_comments ) { + /** @var ParsedComment|string $comment */ + if ( in_array( ( $comment instanceof ParsedComment ? $comment->getComment() : $comment ), $unique_comments, true ) ) { + return null; + } + + $unique_comments[] = ( $comment instanceof ParsedComment ? $comment->getComment() : $comment ); + + return $comment; + } + ); + + $comments_count = count( $comments ); + + if ( $comments_count > 1 ) { + $issues[] = [ + 'file' => $file, + 'line' => $line, + 'message' => sprintf( + "The string \"%s\" has %d different translator comments.\n%s", + $translation->getOriginal(), + $comments_count, + implode( "\n", $unique_comments ) + ), + 'code' => 'multiple-translator-comments', + ]; + } + } + + $non_placeholder_content = trim( preg_replace( '`^([\'"])(.*)\1$`Ds', '$2', $translation->getOriginal() ) ); + $non_placeholder_content = preg_replace( self::SPRINTF_PLACEHOLDER_REGEX, '', $non_placeholder_content ); + + // Check 3: Flag empty strings without any translatable content. + if ( '' === $non_placeholder_content ) { + $issues[] = [ + 'file' => $file, + 'line' => $line, + 'message' => 'Found string without translatable content.', + 'code' => 'empty-string', + ]; + } + + // Check 4: Flag strings with multiple unordered placeholders (%s %s %s vs. %1$s %2$s %3$s). + $unordered_matches_count = preg_match_all( self::UNORDERED_SPRINTF_PLACEHOLDER_REGEX, $translation->getOriginal(), $unordered_matches ); + $unordered_matches = $unordered_matches[0]; + + if ( $unordered_matches_count >= 2 ) { + $issues[] = [ + 'file' => $file, + 'line' => $line, + 'message' => 'Multiple placeholders should be ordered.', + 'code' => 'unordered-placeholders', + ]; + } + + if ( $translation->hasPlural() ) { + preg_match_all( self::SPRINTF_PLACEHOLDER_REGEX, $translation->getOriginal(), $single_placeholders ); + $single_placeholders = $single_placeholders[0]; + + preg_match_all( self::SPRINTF_PLACEHOLDER_REGEX, $translation->getPlural(), $plural_placeholders ); + $plural_placeholders = $plural_placeholders[0]; + + // see https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/#plurals + if ( count( $single_placeholders ) < count( $plural_placeholders ) ) { + // Check 5: Flag things like _n( 'One comment', '%s Comments' ) + $issues[] = [ + 'file' => $file, + 'line' => $line, + 'message' => 'Missing singular placeholder, needed for some languages. See https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/#plurals', + 'code' => 'missing-singular-placeholder', + ]; + } else { + // Reordering is fine, but mismatched placeholders is probably wrong. + sort( $single_placeholders ); + sort( $plural_placeholders ); + + // Check 6: Flag things like _n( '%s Comment (%d)', '%s Comments (%s)' ) + if ( $single_placeholders !== $plural_placeholders ) { + $issues[] = [ + 'file' => $file, + 'line' => $line, + 'message' => 'Mismatched placeholders for singular and plural string.', + 'code' => 'mismatched-placeholders', + ]; + } + } + } + } + + return $issues; + } + + /** + * Outputs audit results in the specified format. + * + * @param array $issues Array of issues found. + */ + protected function output_results( $issues ) { + if ( empty( $issues ) ) { + return; + } + + switch ( $this->format ) { + case 'json': + WP_CLI::line( json_encode( $issues, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); + break; + + case 'github-actions': + foreach ( $issues as $issue ) { + $file = $issue['file']; + $line = $issue['line'] ?? 1; + $message = $issue['message']; + + WP_CLI::line( sprintf( '::warning file=%s,line=%d::%s', $file, $line, $message ) ); + } + break; + + case 'plaintext': + default: + foreach ( $issues as $issue ) { + $file = $issue['file']; + $line = $issue['line'] ?? null; + $message = $issue['message']; + $location = $line ? "$file:$line" : $file; + + WP_CLI::warning( sprintf( '%s: %s', $location, $message ) ); + } + break; + } + } +} From 33ffb82df0348dd2c9da0e77b9d2d12442035e79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:32:44 +0000 Subject: [PATCH 03/17] Fix debug context from 'make-pot' to 'audit' in AuditCommand Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AuditCommand.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AuditCommand.php b/src/AuditCommand.php index 0580303c..32613275 100644 --- a/src/AuditCommand.php +++ b/src/AuditCommand.php @@ -242,7 +242,7 @@ public function __invoke( $args, $assoc_args ) { $this->slug, $this->domain ), - 'make-pot' + 'audit' ); $translations = $this->extract_strings(); @@ -273,9 +273,9 @@ protected function get_main_file_data() { $type = FileDataExtractor::get_file_type( $this->source ); if ( 'unknown' === $type ) { - WP_CLI::debug( 'Could not detect file type.', 'make-pot' ); + WP_CLI::debug( 'Could not detect file type.', 'audit' ); } else { - WP_CLI::debug( sprintf( '%s file detected.', ucfirst( $type ) ), 'make-pot' ); + WP_CLI::debug( sprintf( '%s file detected.', ucfirst( $type ) ), 'audit' ); } $this->project_type = $type; From 16e72a93994ec47718e72353ba18c95bb5753d47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:35:54 +0000 Subject: [PATCH 04/17] Improve code quality: add helper method and documentation Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AuditCommand.php | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/AuditCommand.php b/src/AuditCommand.php index 32613275..826f19ec 100644 --- a/src/AuditCommand.php +++ b/src/AuditCommand.php @@ -21,6 +21,16 @@ class AuditCommand extends WP_CLI_Command { protected $include = []; /** + * Default exclude patterns for string extraction. + * + * Excludes common directories and files that typically don't contain translatable strings: + * - node_modules: NPM dependencies + * - .*: Hidden files and directories like .git, .svn, .CVS, .hg + * - vendor: Composer dependencies + * - Gruntfile.js, webpack.config.js: Build configuration files + * - *.min.js: Minified JavaScript files + * - test, tests: Test directories + * * @var array */ protected $exclude = [ 'node_modules', '.*', 'vendor', 'Gruntfile.js', 'webpack.config.js', '*.min.js', 'test', 'tests' ]; @@ -158,7 +168,10 @@ class AuditCommand extends WP_CLI_Command { * * [--exclude=] * : Comma-separated list of files and paths that should be ignored for string extraction. - * Defaults to 'node_modules,.git,.svn,vendor,Gruntfile.js,webpack.config.js,*.min.js,test,tests'. + * For example, `--exclude=.github,myfile.php` would ignore any strings found within `myfile.php` or the `.github` + * folder. Simple glob patterns can be used, i.e. `--exclude=foo-*.php` excludes any PHP file with the `foo-` + * prefix. Leading and trailing slashes are ignored, i.e. `/my/directory/` is the same as `my/directory`. The + * following files and folders are always excluded: node_modules, .git, .svn, .CVS, .hg, vendor, *.min.js, test, tests. * * [--skip-js] * : Skips JavaScript string extraction. @@ -449,6 +462,16 @@ protected function extract_strings() { return $translations; } + /** + * Extracts comment text from a ParsedComment or string. + * + * @param ParsedComment|string $comment Comment object or string. + * @return string Comment text. + */ + protected function get_comment_text( $comment ) { + return $comment instanceof ParsedComment ? $comment->getComment() : $comment; + } + /** * Audits strings. * @@ -501,7 +524,7 @@ function ( $comment ) { /** @var ParsedComment|string $comment */ /** @var string $file_header */ foreach ( $this->get_file_headers( $this->project_type ) as $file_header ) { - if ( 0 === strpos( ( $comment instanceof ParsedComment ? $comment->getComment() : $comment ), $file_header ) ) { + if ( 0 === strpos( $this->get_comment_text( $comment ), $file_header ) ) { return null; } } @@ -517,11 +540,12 @@ function ( $comment ) { $comments, function ( $comment ) use ( &$unique_comments ) { /** @var ParsedComment|string $comment */ - if ( in_array( ( $comment instanceof ParsedComment ? $comment->getComment() : $comment ), $unique_comments, true ) ) { + $comment_text = $this->get_comment_text( $comment ); + if ( in_array( $comment_text, $unique_comments, true ) ) { return null; } - $unique_comments[] = ( $comment instanceof ParsedComment ? $comment->getComment() : $comment ); + $unique_comments[] = $comment_text; return $comment; } From 242d6aae4b128a6fdd64f98ca72ee406ae84c421 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 16:04:19 +0000 Subject: [PATCH 05/17] Refactor AuditCommand to extend MakePotCommand and eliminate code duplication Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AuditCommand.php | 228 ++++--------------------------------------- 1 file changed, 20 insertions(+), 208 deletions(-) diff --git a/src/AuditCommand.php b/src/AuditCommand.php index 826f19ec..b27c89e0 100644 --- a/src/AuditCommand.php +++ b/src/AuditCommand.php @@ -7,140 +7,19 @@ use Gettext\Utils\ParsedComment; use WP_CLI; use WP_CLI\Utils; -use WP_CLI_Command; - -class AuditCommand extends WP_CLI_Command { - /** - * @var string - */ - protected $source; - - /** - * @var array - */ - protected $include = []; - - /** - * Default exclude patterns for string extraction. - * - * Excludes common directories and files that typically don't contain translatable strings: - * - node_modules: NPM dependencies - * - .*: Hidden files and directories like .git, .svn, .CVS, .hg - * - vendor: Composer dependencies - * - Gruntfile.js, webpack.config.js: Build configuration files - * - *.min.js: Minified JavaScript files - * - test, tests: Test directories - * - * @var array - */ - protected $exclude = [ 'node_modules', '.*', 'vendor', 'Gruntfile.js', 'webpack.config.js', '*.min.js', 'test', 'tests' ]; - - /** - * @var string - */ - protected $slug; - - /** - * @var array - */ - protected $main_file_data = []; - - /** - * @var string - */ - protected $main_file_path; - - /** - * @var bool - */ - protected $skip_js = false; - - /** - * @var bool - */ - protected $skip_php = false; - - /** - * @var bool - */ - protected $skip_blade = false; - - /** - * @var bool - */ - protected $skip_block_json = false; - - /** - * @var bool - */ - protected $skip_theme_json = false; - - /** - * @var bool - */ - protected $location = true; - - /** - * @var string - */ - protected $domain; - - /** - * @var string - */ - protected $project_type = 'generic'; +/** + * Audit strings in a WordPress project. + * + * Extends MakePotCommand to reuse string extraction logic while providing + * specialized output formats for auditing purposes. + */ +class AuditCommand extends MakePotCommand { /** * @var string */ protected $format = 'plaintext'; - /** - * These Regexes copied from http://php.net/manual/en/function.sprintf.php#93552 - * and adjusted for better precision and updated specs. - */ - const SPRINTF_PLACEHOLDER_REGEX = '/(?: - (?exclude = array_map( 'trim', explode( ',', $exclude ) ); } - $this->get_main_file_data(); + $this->main_file_data = $this->get_main_file_data(); if ( null === $this->domain ) { if ( ! empty( $this->main_file_data['Text Domain'] ) && ! $ignore_domain ) { @@ -258,9 +137,9 @@ public function __invoke( $args, $assoc_args ) { 'audit' ); - $translations = $this->extract_strings(); + $translations = $this->extract_strings_for_audit(); - $issues = $this->audit_strings( $translations ); + $issues = $this->collect_audit_issues( $translations ); $this->output_results( $issues ); @@ -274,79 +153,13 @@ public function __invoke( $args, $assoc_args ) { } /** - * Returns the file data of the main plugin or theme file. - * - * @return array - */ - protected function get_main_file_data() { - if ( ! empty( $this->main_file_data ) ) { - return $this->main_file_data; - } - - $type = FileDataExtractor::get_file_type( $this->source ); - - if ( 'unknown' === $type ) { - WP_CLI::debug( 'Could not detect file type.', 'audit' ); - } else { - WP_CLI::debug( sprintf( '%s file detected.', ucfirst( $type ) ), 'audit' ); - } - - $this->project_type = $type; - - $location = FileDataExtractor::find_main_file( $this->source, $type ); - - if ( ! $location ) { - return []; - } - - $this->main_file_path = $location; - - return $this->main_file_data = FileDataExtractor::get_file_data( $location, $type ); - } - - /** - * Returns the file headers for a given project type. + * Extracts strings for auditing. * - * @param string $type Project type. - * @return array - */ - protected function get_file_headers( $type ) { - switch ( $type ) { - case 'plugin': - return [ - 'Plugin Name', - 'Plugin URI', - 'Description', - 'Author', - 'Author URI', - 'Version', - 'License', - 'Domain Path', - 'Text Domain', - ]; - case 'theme': - return [ - 'Theme Name', - 'Theme URI', - 'Description', - 'Author', - 'Author URI', - 'Version', - 'License', - 'Domain Path', - 'Text Domain', - ]; - default: - return []; - } - } - - /** - * Extracts strings from source code. + * Similar to extract_strings() in parent class but simplified for audit purposes. * * @return Translations A Translation set. */ - protected function extract_strings() { + protected function extract_strings_for_audit() { $translations = new Translations(); if ( $this->domain ) { @@ -473,14 +286,14 @@ protected function get_comment_text( $comment ) { } /** - * Audits strings. + * Collects audit issues from translations. * * Goes through all extracted strings to find possible mistakes. * * @param Translations $translations Translations object. * @return array Array of issues found. */ - protected function audit_strings( $translations ) { + protected function collect_audit_issues( $translations ) { $issues = []; foreach ( $translations as $translation ) { @@ -493,9 +306,8 @@ protected function audit_strings( $translations ) { continue; } - $file = $references[0][0]; - $line = $references[0][1] ?? null; - $location = $line ? "$file:$line" : $file; + $file = $references[0][0]; + $line = $references[0][1] ?? null; // Check 1: Flag strings with placeholders that should have translator comments. if ( @@ -659,9 +471,9 @@ protected function output_results( $issues ) { case 'plaintext': default: foreach ( $issues as $issue ) { - $file = $issue['file']; - $line = $issue['line'] ?? null; - $message = $issue['message']; + $file = $issue['file']; + $line = $issue['line'] ?? null; + $message = $issue['message']; $location = $line ? "$file:$line" : $file; WP_CLI::warning( sprintf( '%s: %s', $location, $message ) ); From 83d45cd9735c04bcf4ea939e44390c496686fa69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Nov 2025 09:00:34 +0000 Subject: [PATCH 06/17] Suppress log and summary messages in non-plaintext output formats Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AuditCommand.php | 103 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 5 deletions(-) diff --git a/src/AuditCommand.php b/src/AuditCommand.php index b27c89e0..c86fb392 100644 --- a/src/AuditCommand.php +++ b/src/AuditCommand.php @@ -143,13 +143,106 @@ public function __invoke( $args, $assoc_args ) { $this->output_results( $issues ); - $issue_count = count( $issues ); + // Only output summary messages in plaintext format. + if ( 'plaintext' === $this->format ) { + $issue_count = count( $issues ); - if ( $issue_count > 0 ) { - WP_CLI::warning( sprintf( 'Found %d %s.', $issue_count, Utils\pluralize( 'issue', $issue_count ) ) ); - } else { - WP_CLI::success( 'No issues found.' ); + if ( $issue_count > 0 ) { + WP_CLI::warning( sprintf( 'Found %d %s.', $issue_count, Utils\pluralize( 'issue', $issue_count ) ) ); + } else { + WP_CLI::success( 'No issues found.' ); + } + } + } + + /** + * Returns the file data of the main plugin or theme file. + * + * Overrides parent method to suppress log messages when using non-plaintext formats. + * + * @return array + */ + protected function get_main_file_data() { + $files = new \IteratorIterator( new \DirectoryIterator( $this->source ) ); + + /** @var \DirectoryIterator $file */ + foreach ( $files as $file ) { + // wp-content/themes/my-theme/style.css + if ( $file->isFile() && 'style' === $file->getBasename( '.css' ) && $file->isReadable() ) { + $theme_data = FileDataExtractor::get_file_data( + $file->getRealPath(), + array_combine( + $this->get_file_headers( 'theme' ), + $this->get_file_headers( 'theme' ) + ) + ); + + // Stop when it contains a valid Theme Name header. + if ( ! empty( $theme_data['Theme Name'] ) ) { + if ( 'plaintext' === $this->format ) { + WP_CLI::log( 'Theme stylesheet detected.' ); + } + WP_CLI::debug( sprintf( 'Theme stylesheet: %s', $file->getRealPath() ), 'audit' ); + + $this->project_type = 'theme'; + $this->main_file_path = $file->getRealPath(); + + return $theme_data; + } + } + + // wp-content/themes/my-themes/theme-a/style.css + if ( $file->isDir() && ! $file->isDot() && is_readable( $file->getRealPath() . '/style.css' ) ) { + $theme_data = FileDataExtractor::get_file_data( + $file->getRealPath() . '/style.css', + array_combine( + $this->get_file_headers( 'theme' ), + $this->get_file_headers( 'theme' ) + ) + ); + + // Stop when it contains a valid Theme Name header. + if ( ! empty( $theme_data['Theme Name'] ) ) { + if ( 'plaintext' === $this->format ) { + WP_CLI::log( 'Theme stylesheet detected.' ); + } + WP_CLI::debug( sprintf( 'Theme stylesheet: %s', $file->getRealPath() . '/style.css' ), 'audit' ); + + $this->project_type = 'theme'; + $this->main_file_path = $file->getRealPath(); + + return $theme_data; + } + } + + // wp-content/plugins/my-plugin/my-plugin.php + if ( $file->isFile() && $file->isReadable() && 'php' === $file->getExtension() ) { + $plugin_data = FileDataExtractor::get_file_data( + $file->getRealPath(), + array_combine( + $this->get_file_headers( 'plugin' ), + $this->get_file_headers( 'plugin' ) + ) + ); + + // Stop when we find a file with a valid Plugin Name header. + if ( ! empty( $plugin_data['Plugin Name'] ) ) { + if ( 'plaintext' === $this->format ) { + WP_CLI::log( 'Plugin file detected.' ); + } + WP_CLI::debug( sprintf( 'Plugin file: %s', $file->getRealPath() ), 'audit' ); + + $this->project_type = 'plugin'; + $this->main_file_path = $file->getRealPath(); + + return $plugin_data; + } + } } + + WP_CLI::debug( 'No valid theme stylesheet or plugin file found, treating as a regular project.', 'audit' ); + + return []; } /** From baa2210c622165b34d3e73e6bb7ed4a0a5f09fc7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Nov 2025 09:42:04 +0000 Subject: [PATCH 07/17] Fix --ignore-domain flag to properly extract all strings regardless of text domain Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AuditCommand.php | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/AuditCommand.php b/src/AuditCommand.php index c86fb392..9970b7e9 100644 --- a/src/AuditCommand.php +++ b/src/AuditCommand.php @@ -120,22 +120,28 @@ public function __invoke( $args, $assoc_args ) { $this->main_file_data = $this->get_main_file_data(); - if ( null === $this->domain ) { - if ( ! empty( $this->main_file_data['Text Domain'] ) && ! $ignore_domain ) { - $this->domain = $this->main_file_data['Text Domain']; - } else { + if ( $ignore_domain ) { + WP_CLI::debug( 'Extracting all strings regardless of text domain', 'audit' ); + } + + if ( ! $ignore_domain ) { + if ( null === $this->domain ) { $this->domain = $this->slug; + + if ( ! empty( $this->main_file_data['Text Domain'] ) ) { + $this->domain = $this->main_file_data['Text Domain']; + } } - } - WP_CLI::debug( - sprintf( - 'Auditing strings for %s, using "%s" as text domain.', - $this->slug, - $this->domain - ), - 'audit' - ); + WP_CLI::debug( + sprintf( + 'Auditing strings for %s, using "%s" as text domain.', + $this->slug, + $this->domain + ), + 'audit' + ); + } $translations = $this->extract_strings_for_audit(); From 56333d740022f7570043fb79d05267eab4e8a349 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 3 Nov 2025 13:30:40 +0100 Subject: [PATCH 08/17] Lint fixes --- src/AuditCommand.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/AuditCommand.php b/src/AuditCommand.php index 9970b7e9..5614f38b 100644 --- a/src/AuditCommand.php +++ b/src/AuditCommand.php @@ -98,15 +98,15 @@ public function __invoke( $args, $assoc_args ) { WP_CLI::error( 'Not a valid source directory.' ); } - $this->slug = Utils\get_flag_value( $assoc_args, 'slug', Utils\basename( $this->source ) ); - $this->domain = Utils\get_flag_value( $assoc_args, 'domain', null ); - $this->skip_js = Utils\get_flag_value( $assoc_args, 'skip-js', $this->skip_js ); - $this->skip_php = Utils\get_flag_value( $assoc_args, 'skip-php', $this->skip_php ); - $this->skip_blade = Utils\get_flag_value( $assoc_args, 'skip-blade', $this->skip_blade ); - $this->skip_block_json = Utils\get_flag_value( $assoc_args, 'skip-block-json', $this->skip_block_json ); - $this->skip_theme_json = Utils\get_flag_value( $assoc_args, 'skip-theme-json', $this->skip_theme_json ); - $this->format = Utils\get_flag_value( $assoc_args, 'format', $this->format ); - $ignore_domain = Utils\get_flag_value( $assoc_args, 'ignore-domain', false ); + $this->slug = Utils\get_flag_value( $assoc_args, 'slug', Utils\basename( $this->source ) ); + $this->domain = Utils\get_flag_value( $assoc_args, 'domain', null ); + $this->skip_js = Utils\get_flag_value( $assoc_args, 'skip-js', $this->skip_js ); + $this->skip_php = Utils\get_flag_value( $assoc_args, 'skip-php', $this->skip_php ); + $this->skip_blade = Utils\get_flag_value( $assoc_args, 'skip-blade', $this->skip_blade ); + $this->skip_block_json = Utils\get_flag_value( $assoc_args, 'skip-block-json', $this->skip_block_json ); + $this->skip_theme_json = Utils\get_flag_value( $assoc_args, 'skip-theme-json', $this->skip_theme_json ); + $this->format = Utils\get_flag_value( $assoc_args, 'format', $this->format ); + $ignore_domain = Utils\get_flag_value( $assoc_args, 'ignore-domain', false ); $include = Utils\get_flag_value( $assoc_args, 'include', [] ); if ( ! empty( $include ) ) { From ddba9bf8cdaa565e69331f0b620b63ff650e37ee Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 3 Nov 2025 14:19:34 +0100 Subject: [PATCH 09/17] Remove unused line Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/AuditCommand.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/AuditCommand.php b/src/AuditCommand.php index 5614f38b..1ecb9f1a 100644 --- a/src/AuditCommand.php +++ b/src/AuditCommand.php @@ -494,7 +494,6 @@ function ( $comment ) use ( &$unique_comments ) { // Check 4: Flag strings with multiple unordered placeholders (%s %s %s vs. %1$s %2$s %3$s). $unordered_matches_count = preg_match_all( self::UNORDERED_SPRINTF_PLACEHOLDER_REGEX, $translation->getOriginal(), $unordered_matches ); - $unordered_matches = $unordered_matches[0]; if ( $unordered_matches_count >= 2 ) { $issues[] = [ From 336153e0c0d3a8af42798071cdc68b7886be866f Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 3 Nov 2025 14:20:25 +0100 Subject: [PATCH 10/17] Return false --- src/AuditCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AuditCommand.php b/src/AuditCommand.php index 1ecb9f1a..7ba8f8c0 100644 --- a/src/AuditCommand.php +++ b/src/AuditCommand.php @@ -436,7 +436,7 @@ function ( $comment ) { /** @var string $file_header */ foreach ( $this->get_file_headers( $this->project_type ) as $file_header ) { if ( 0 === strpos( $this->get_comment_text( $comment ), $file_header ) ) { - return null; + return false; } } From 8e1d8a03b0406029c30673eefa251c484cac8c85 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 3 Nov 2025 14:20:34 +0100 Subject: [PATCH 11/17] Return false --- src/AuditCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AuditCommand.php b/src/AuditCommand.php index 7ba8f8c0..38851e51 100644 --- a/src/AuditCommand.php +++ b/src/AuditCommand.php @@ -453,7 +453,7 @@ function ( $comment ) use ( &$unique_comments ) { /** @var ParsedComment|string $comment */ $comment_text = $this->get_comment_text( $comment ); if ( in_array( $comment_text, $unique_comments, true ) ) { - return null; + return false; } $unique_comments[] = $comment_text; From ab7d23c4abc1e834a6cae76c04dcbadc560193ac Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 19 Dec 2025 14:39:26 +0100 Subject: [PATCH 12/17] Fix file data access --- src/AuditCommand.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AuditCommand.php b/src/AuditCommand.php index 38851e51..1583d43f 100644 --- a/src/AuditCommand.php +++ b/src/AuditCommand.php @@ -128,8 +128,8 @@ public function __invoke( $args, $assoc_args ) { if ( null === $this->domain ) { $this->domain = $this->slug; - if ( ! empty( $this->main_file_data['Text Domain'] ) ) { - $this->domain = $this->main_file_data['Text Domain']; + if ( ! empty( $this->main_file_data['Text Domain']['value'] ) ) { + $this->domain = $this->main_file_data['Text Domain']['value']; } } From 4c3a823380199ca5965a47097aed901e8f8edaa8 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 20 Jan 2026 11:19:15 +0100 Subject: [PATCH 13/17] Update src/AuditCommand.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/AuditCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AuditCommand.php b/src/AuditCommand.php index 1583d43f..e98111bc 100644 --- a/src/AuditCommand.php +++ b/src/AuditCommand.php @@ -411,7 +411,7 @@ protected function collect_audit_issues( $translations ) { // Check 1: Flag strings with placeholders that should have translator comments. if ( ! $translation->hasExtractedComments() && - preg_match( self::SPRINTF_PLACEHOLDER_REGEX, $translation->getOriginal(), $placeholders ) >= 1 + preg_match( self::SPRINTF_PLACEHOLDER_REGEX, $translation->getOriginal() ) >= 1 ) { $issues[] = [ 'file' => $file, From 4ea4b9b0c9163b56b46740817db5970bee4159d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:23:18 +0000 Subject: [PATCH 14/17] Remove unnecessary quote-stripping regex as gettext already provides unquoted strings Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AuditCommand.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/AuditCommand.php b/src/AuditCommand.php index e98111bc..4aa1e1e0 100644 --- a/src/AuditCommand.php +++ b/src/AuditCommand.php @@ -479,8 +479,7 @@ function ( $comment ) use ( &$unique_comments ) { } } - $non_placeholder_content = trim( preg_replace( '`^([\'"])(.*)\1$`Ds', '$2', $translation->getOriginal() ) ); - $non_placeholder_content = preg_replace( self::SPRINTF_PLACEHOLDER_REGEX, '', $non_placeholder_content ); + $non_placeholder_content = trim( preg_replace( self::SPRINTF_PLACEHOLDER_REGEX, '', $translation->getOriginal() ) ); // Check 3: Flag empty strings without any translatable content. if ( '' === $non_placeholder_content ) { From 9b1ee8e3f4230f10ef2323a78890cf9dec26e63d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:27:42 +0000 Subject: [PATCH 15/17] Refactor code for better readability: simplify unique comments and theme detection Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AuditCommand.php | 60 +++++++++++++------------------------------- 1 file changed, 17 insertions(+), 43 deletions(-) diff --git a/src/AuditCommand.php b/src/AuditCommand.php index 4aa1e1e0..c0de5b54 100644 --- a/src/AuditCommand.php +++ b/src/AuditCommand.php @@ -173,34 +173,22 @@ protected function get_main_file_data() { /** @var \DirectoryIterator $file */ foreach ( $files as $file ) { + $stylesheet_path = null; + $project_path = null; + // wp-content/themes/my-theme/style.css if ( $file->isFile() && 'style' === $file->getBasename( '.css' ) && $file->isReadable() ) { - $theme_data = FileDataExtractor::get_file_data( - $file->getRealPath(), - array_combine( - $this->get_file_headers( 'theme' ), - $this->get_file_headers( 'theme' ) - ) - ); - - // Stop when it contains a valid Theme Name header. - if ( ! empty( $theme_data['Theme Name'] ) ) { - if ( 'plaintext' === $this->format ) { - WP_CLI::log( 'Theme stylesheet detected.' ); - } - WP_CLI::debug( sprintf( 'Theme stylesheet: %s', $file->getRealPath() ), 'audit' ); - - $this->project_type = 'theme'; - $this->main_file_path = $file->getRealPath(); - - return $theme_data; - } + $stylesheet_path = $file->getRealPath(); + $project_path = $file->getRealPath(); + } elseif ( $file->isDir() && ! $file->isDot() && is_readable( $file->getRealPath() . '/style.css' ) ) { + // wp-content/themes/my-themes/theme-a/style.css + $stylesheet_path = $file->getRealPath() . '/style.css'; + $project_path = $file->getRealPath(); } - // wp-content/themes/my-themes/theme-a/style.css - if ( $file->isDir() && ! $file->isDot() && is_readable( $file->getRealPath() . '/style.css' ) ) { + if ( $stylesheet_path ) { $theme_data = FileDataExtractor::get_file_data( - $file->getRealPath() . '/style.css', + $stylesheet_path, array_combine( $this->get_file_headers( 'theme' ), $this->get_file_headers( 'theme' ) @@ -212,10 +200,10 @@ protected function get_main_file_data() { if ( 'plaintext' === $this->format ) { WP_CLI::log( 'Theme stylesheet detected.' ); } - WP_CLI::debug( sprintf( 'Theme stylesheet: %s', $file->getRealPath() . '/style.css' ), 'audit' ); + WP_CLI::debug( sprintf( 'Theme stylesheet: %s', $stylesheet_path ), 'audit' ); $this->project_type = 'theme'; - $this->main_file_path = $file->getRealPath(); + $this->main_file_path = $project_path; return $theme_data; } @@ -444,25 +432,11 @@ function ( $comment ) { } ); - $unique_comments = array(); - - // Remove duplicate comments. - $comments = array_filter( - $comments, - function ( $comment ) use ( &$unique_comments ) { - /** @var ParsedComment|string $comment */ - $comment_text = $this->get_comment_text( $comment ); - if ( in_array( $comment_text, $unique_comments, true ) ) { - return false; - } - - $unique_comments[] = $comment_text; - - return $comment; - } + $unique_comment_texts = array_unique( + array_map( [ $this, 'get_comment_text' ], $comments ) ); - $comments_count = count( $comments ); + $comments_count = count( $unique_comment_texts ); if ( $comments_count > 1 ) { $issues[] = [ @@ -472,7 +446,7 @@ function ( $comment ) use ( &$unique_comments ) { "The string \"%s\" has %d different translator comments.\n%s", $translation->getOriginal(), $comments_count, - implode( "\n", $unique_comments ) + implode( "\n", array_values( $unique_comment_texts ) ) ), 'code' => 'multiple-translator-comments', ]; From b5563faf67ead8ff465e15ad9ae773de6c1d69a1 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 20 Jan 2026 12:10:39 +0100 Subject: [PATCH 16/17] Update src/AuditCommand.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/AuditCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AuditCommand.php b/src/AuditCommand.php index c0de5b54..78c922f2 100644 --- a/src/AuditCommand.php +++ b/src/AuditCommand.php @@ -220,7 +220,7 @@ protected function get_main_file_data() { ); // Stop when we find a file with a valid Plugin Name header. - if ( ! empty( $plugin_data['Plugin Name'] ) ) { + if ( ! empty( $plugin_data['Plugin Name']['value'] ) ) { if ( 'plaintext' === $this->format ) { WP_CLI::log( 'Plugin file detected.' ); } From 1753a81b2b4a6dfebe96a8669444b0e9c2437ff7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:13:00 +0000 Subject: [PATCH 17/17] Fix FileDataExtractor data access to check ['value'] key consistently with parent class Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AuditCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AuditCommand.php b/src/AuditCommand.php index 78c922f2..88e63249 100644 --- a/src/AuditCommand.php +++ b/src/AuditCommand.php @@ -196,7 +196,7 @@ protected function get_main_file_data() { ); // Stop when it contains a valid Theme Name header. - if ( ! empty( $theme_data['Theme Name'] ) ) { + if ( ! empty( $theme_data['Theme Name']['value'] ) ) { if ( 'plaintext' === $this->format ) { WP_CLI::log( 'Theme stylesheet detected.' ); }