Four checks in ci/ printed a reassuring line having examined nothing. Found independently by the woo-ai-manager and woo-cache-manager sessions in their own copies of this kit; all four reproduce here. A check that examined nothing prints the same sentence as one that examined everything, and only the passing side can tell them apart. That is why these survived being verified: a pattern that is too broad fails loudly the first time it meets real code, while one that finds nothing passes forever. So every success line now carries a count — the old text domain line could not tell 142 from 0. - static.sh, direct-access guard: counts what the loop examined and fails below MIN_SHIPPED_PHP_FILES. shipped_files() swallows a dist-files.sh failure with `|| true`, so an empty list is one broken .distignore away, and this is the check standing between a PHP file and being requested straight from a browser outside every capability check the code assumes has happened. The floor is above 1 because a pattern that wrongly excludes includes/ collapses the list without emptying it. - static.sh, credential scan: counts what it read, fatal on zero. A pushed key is compromised even after the commit is deleted, so a silent no-op here is not a weak check but the absence of one wearing a green tick. The floor is 1 and is not configurable: this list has no filter that could go partially wrong, so the only way it misreports is by coming out empty. - check-textdomain.php: three zeros, three verdicts. No PHP files at all is always fatal and deliberately not caller-configurable — it says the scan root is wrong, not that the plugin is small. Below --min is fatal. Zero calls with no --min is a warning, since a small plugin may legitimately have no translatable strings, but it is never silent. - check-textdomain.php: fix the annotated path. str_replace( $root, '', $path ) with the default root of "." removed every dot anywhere in the path, so a real error in includes/class-db.php was reported on "includes/class-dbphp" — not a file, not clickable, and not greppable. Every error this script printed with the default root was mangled that way. The floors live in ci/plugin.conf, the only file under ci/ that knows anything about this plugin, rather than hardcoded in static.sh: static.sh is copied verbatim between plugins too, so a number in it would make this plugin's shape everybody's constraint. ci_load_conf now requires them — an unset floor turns its comparison into a no-op and silently restores the bug. Each floor was broken once and watched to fail before being believed: empty shipped list, wrong scan root, --min above the real count, zero calls without --min, empty credential list, and a plugin.conf missing both floors. Static checks green, and now report 13 shipped PHP files, 142 translation calls across 17 files, 44 tracked files read. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
217 lines
6.2 KiB
PHP
217 lines
6.2 KiB
PHP
<?php
|
|
/**
|
|
* Verify that every translation call carries the plugin's text domain.
|
|
*
|
|
* A mistyped or missing domain does not break anything visibly — the string
|
|
* simply never gets translated, and nobody notices until the shop is asked to
|
|
* run in another language.
|
|
*
|
|
* Tokenised rather than grepped, so a domain-looking string inside a sentence
|
|
* cannot pass for the real thing.
|
|
*
|
|
* 🔴 This script's whole job is to look at every string, which makes it the one
|
|
* check in the kit that can report success having found NOTHING. "Every
|
|
* translation call carries the text domain" is also exactly what a wrong scan
|
|
* root prints, or a refactor that moved the strings somewhere this does not
|
|
* walk. So it counts what it read and every success line carries the numbers:
|
|
* the old line could not tell 142 from 0.
|
|
*/
|
|
|
|
// The floor is the caller's decision, because only the caller knows what zero
|
|
// would mean for its plugin. This script is copied between plugins and cannot
|
|
// know any of their sizes; a number hardcoded here would make one plugin's
|
|
// shape everybody's constraint. A plugin that has strings passes --min; one
|
|
// that legitimately has none leaves it off and gets a warning instead.
|
|
$min = null;
|
|
$args = array();
|
|
foreach ( array_slice( $argv, 1 ) as $arg ) {
|
|
if ( 0 === strpos( $arg, '--min=' ) ) {
|
|
$min = (int) substr( $arg, 6 );
|
|
continue;
|
|
}
|
|
$args[] = $arg;
|
|
}
|
|
|
|
// Copied between plugins, so there is no sensible default domain: a wrong one
|
|
// would report "all good" while checking for somebody else's text domain.
|
|
if ( empty( $args[0] ) ) {
|
|
fwrite( STDERR, "Usage: php ci/check-textdomain.php [--min=N] <text-domain> [root]\n" );
|
|
exit( 1 );
|
|
}
|
|
|
|
$domain = $args[0];
|
|
$root = $args[1] ?? '.';
|
|
|
|
/**
|
|
* Path relative to the scan root, for the annotation.
|
|
*
|
|
* NOT str_replace( $root, '', $path ). With the usual root of "." that removes
|
|
* every dot ANYWHERE in the path, so a real error on "includes/class-db.php"
|
|
* was annotated on "includes/class-dbphp" — a filename that does not exist, is
|
|
* not clickable in a CI log, and does not match if you grep for it. Every
|
|
* error this script printed with the default root was mangled that way.
|
|
*
|
|
* @param string $path Path as the iterator produced it.
|
|
* @param string $root Scan root.
|
|
* @return string
|
|
*/
|
|
function ci_relative_path( $path, $root ) {
|
|
$root = rtrim( $root, '/' );
|
|
|
|
if ( '' !== $root && '.' !== $root && 0 === strpos( $path, $root . '/' ) ) {
|
|
return substr( $path, strlen( $root ) + 1 );
|
|
}
|
|
|
|
return ltrim( preg_replace( '#^\./#', '', $path ), '/' );
|
|
}
|
|
|
|
$functions = array(
|
|
'__',
|
|
'_e',
|
|
'_x',
|
|
'_ex',
|
|
'_n',
|
|
'_nx',
|
|
'_n_noop',
|
|
'_nx_noop',
|
|
'esc_html__',
|
|
'esc_html_e',
|
|
'esc_html_x',
|
|
'esc_attr__',
|
|
'esc_attr_e',
|
|
'esc_attr_x',
|
|
);
|
|
|
|
$problems = 0;
|
|
$calls = 0;
|
|
$files = 0;
|
|
|
|
$iterator = new RecursiveIteratorIterator(
|
|
new RecursiveCallbackFilterIterator(
|
|
new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ),
|
|
static function ( $file ) {
|
|
$name = $file->getFilename();
|
|
return ! in_array( $name, array( '.git', 'node_modules', 'vendor', 'build' ), true );
|
|
}
|
|
)
|
|
);
|
|
|
|
foreach ( $iterator as $file ) {
|
|
if ( 'php' !== strtolower( $file->getExtension() ) ) {
|
|
continue;
|
|
}
|
|
|
|
$path = $file->getPathname();
|
|
++$files;
|
|
$tokens = token_get_all( file_get_contents( $path ) );
|
|
$count = count( $tokens );
|
|
|
|
for ( $i = 0; $i < $count; $i++ ) {
|
|
$token = $tokens[ $i ];
|
|
|
|
if ( ! is_array( $token ) || T_STRING !== $token[0] || ! in_array( $token[1], $functions, true ) ) {
|
|
continue;
|
|
}
|
|
|
|
// Skip method calls and definitions: ->__(), function __().
|
|
$prev = $i - 1;
|
|
while ( $prev >= 0 && is_array( $tokens[ $prev ] ) && T_WHITESPACE === $tokens[ $prev ][0] ) {
|
|
--$prev;
|
|
}
|
|
if ( $prev >= 0 && is_array( $tokens[ $prev ] ) && in_array( $tokens[ $prev ][0], array( T_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION ), true ) ) {
|
|
continue;
|
|
}
|
|
|
|
// Find the opening parenthesis.
|
|
$j = $i + 1;
|
|
while ( $j < $count && is_array( $tokens[ $j ] ) && T_WHITESPACE === $tokens[ $j ][0] ) {
|
|
++$j;
|
|
}
|
|
if ( $j >= $count || '(' !== $tokens[ $j ] ) {
|
|
continue;
|
|
}
|
|
|
|
++$calls;
|
|
|
|
// Collect every literal string passed at the top level of this call.
|
|
$depth = 0;
|
|
$strings = array();
|
|
|
|
for ( $k = $j; $k < $count; $k++ ) {
|
|
$t = $tokens[ $k ];
|
|
|
|
if ( '(' === $t ) {
|
|
++$depth;
|
|
continue;
|
|
}
|
|
|
|
if ( ')' === $t ) {
|
|
--$depth;
|
|
if ( 0 === $depth ) {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ( is_array( $t ) && T_CONSTANT_ENCAPSED_STRING === $t[0] ) {
|
|
$strings[] = trim( $t[1], "'\"" );
|
|
}
|
|
}
|
|
|
|
if ( ! in_array( $domain, $strings, true ) ) {
|
|
printf(
|
|
"::error file=%s,line=%d::The %s() call is missing the text domain '%s'.\n",
|
|
ci_relative_path( $path, $root ),
|
|
$token[2],
|
|
$token[1],
|
|
$domain
|
|
);
|
|
++$problems;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( $problems > 0 ) {
|
|
printf( "\n%d of %d translation calls are missing the text domain.\n", $problems, $calls );
|
|
exit( 1 );
|
|
}
|
|
|
|
// Three different zeros, three different verdicts. This one is never
|
|
// legitimate and deliberately not caller-configurable: the smallest possible
|
|
// plugin still has its main file, so no PHP at all does not say "a small
|
|
// plugin", it says the ROOT IS WRONG and this check proved nothing.
|
|
if ( 0 === $files ) {
|
|
fwrite( STDERR, "No PHP files were found under '" . $root . "'. The scan root is wrong, so this check proved nothing.\n" );
|
|
exit( 1 );
|
|
}
|
|
|
|
// The caller expected strings and there are none: the scan no longer finds
|
|
// this plugin's strings, whatever the reason.
|
|
if ( null !== $min && $calls < $min ) {
|
|
fwrite(
|
|
STDERR,
|
|
sprintf(
|
|
"Only %d translation call(s) across %d file(s), but at least %d were expected. The strings have moved somewhere this scan does not look, so this check proved nothing.\n",
|
|
$calls,
|
|
$files,
|
|
$min
|
|
)
|
|
);
|
|
exit( 1 );
|
|
}
|
|
|
|
// No floor was given, so zero may be honest — a genuinely small plugin can have
|
|
// no translatable strings. It must not be silent, though.
|
|
if ( 0 === $calls ) {
|
|
fwrite( STDERR, "Warning: no translation calls were found across " . $files . " file(s). Either this plugin has no translatable strings, or the scan missed them — pass --min=N to make that a failure.\n" );
|
|
exit( 0 );
|
|
}
|
|
|
|
printf(
|
|
"All %d translation calls across %d files carry the text domain '%s'.\n",
|
|
$calls,
|
|
$files,
|
|
$domain
|
|
);
|
|
exit( 0 );
|