lthn.io/app/Core/Lang/Console/Commands/TranslationCoverageCommand.php
Claude 41a90cbff8
feat: lthn.io API serving live chain data
Fixed: basePath self→static binding, namespace detection, event wiring,
SQLite cache, file cache driver. All Mod Boot classes converted to
$listens pattern for lifecycle event discovery.

Working endpoints:
- /v1/explorer/info — live chain height, difficulty, aliases
- /v1/explorer/stats — formatted chain statistics
- /v1/names/directory — alias directory grouped by type
- /v1/names/available/{name} — name availability check
- /v1/names/lookup/{name} — name details

Co-Authored-By: Charon <charon@lethean.io>
2026-04-03 17:17:42 +01:00

324 lines
9.8 KiB
PHP

<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Lang\Console\Commands;
use Core\Lang\Coverage\TranslationCoverage;
use Core\Lang\Coverage\TranslationCoverageReport;
use Illuminate\Console\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
/**
* Report translation coverage across the codebase.
*
* Scans code for translation key usage and compares against translation files
* to identify missing and unused keys.
*
* Usage:
* php artisan lang:coverage
* php artisan lang:coverage --locale=en_GB
* php artisan lang:coverage --missing --verbose
* php artisan lang:coverage --json > report.json
*/
class TranslationCoverageCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'lang:coverage
{--locale= : Analyze specific locale only}
{--path= : Additional code path to scan}
{--lang-path= : Custom language directory path}
{--missing : Show only missing keys}
{--unused : Show only unused keys}
{--json : Output as JSON}
{--verbose : Show detailed usage information}';
/**
* The console command description.
*/
protected $description = 'Report translation coverage - find missing and unused translation keys';
/**
* Execute the console command.
*/
public function handle(TranslationCoverage $coverage): int
{
$this->newLine();
$this->components->info('Translation Coverage Analysis');
$this->newLine();
// Build options
$options = [];
if ($langPath = $this->option('lang-path')) {
$options['lang_path'] = $langPath;
}
if ($locale = $this->option('locale')) {
$options['locales'] = [$locale];
}
if ($additionalPath = $this->option('path')) {
$options['code_paths'] = array_filter([
app_path(),
resource_path('views'),
resource_path('js'),
base_path('packages'),
base_path('src'),
$additionalPath,
], fn ($p) => is_dir($p));
}
// Run analysis with progress indication
$report = null;
$this->components->task('Scanning code for translation keys', function () use ($coverage, $options, &$report) {
$report = $coverage->analyze($options);
return true;
});
$this->newLine();
// Output based on format
if ($this->option('json')) {
$this->line($report->toJson());
return $report->hasIssues() ? self::FAILURE : self::SUCCESS;
}
// Display summary
$summary = $report->getSummary();
$this->displaySummary($summary);
// Display per-locale stats
$this->displayLocaleStats($report);
// Display issues
$showMissing = ! $this->option('unused');
$showUnused = ! $this->option('missing');
$verbose = $this->option('verbose');
if ($showMissing && $report->hasMissing()) {
$this->displayMissingKeys($report, $verbose);
}
if ($showUnused && $report->hasUnused()) {
$this->displayUnusedKeys($report, $verbose);
}
// Final status
$this->newLine();
if ($report->hasIssues()) {
$this->components->warn('Translation coverage issues found. See details above.');
return self::FAILURE;
}
$this->components->info('No translation coverage issues found.');
return self::SUCCESS;
}
/**
* Display the summary section.
*
* @param array{locales: int, total_coverage: float, total_missing: int, total_unused: int, has_issues: bool} $summary
*/
protected function displaySummary(array $summary): void
{
$this->components->twoColumnDetail('<fg=gray;options=bold>Summary</>', '');
$this->components->twoColumnDetail(
'Locales analyzed',
"<fg=cyan>{$summary['locales']}</>"
);
$coverageColor = $summary['total_coverage'] >= 90 ? 'green' : ($summary['total_coverage'] >= 70 ? 'yellow' : 'red');
$this->components->twoColumnDetail(
'Overall coverage',
"<fg={$coverageColor}>{$summary['total_coverage']}%</>"
);
$missingColor = $summary['total_missing'] === 0 ? 'green' : 'yellow';
$this->components->twoColumnDetail(
'Missing keys',
"<fg={$missingColor}>{$summary['total_missing']}</>"
);
$unusedColor = $summary['total_unused'] === 0 ? 'green' : 'yellow';
$this->components->twoColumnDetail(
'Unused keys',
"<fg={$unusedColor}>{$summary['total_unused']}</>"
);
$this->newLine();
}
/**
* Display per-locale statistics.
*
* @param TranslationCoverageReport $report
*/
protected function displayLocaleStats($report): void
{
$locales = $report->getLocales();
if (empty($locales)) {
return;
}
$this->components->twoColumnDetail('<fg=gray;options=bold>Per-Locale Statistics</>', '');
foreach ($locales as $locale) {
$stats = $report->getStats($locale);
$coverageColor = $stats['coverage'] >= 90 ? 'green' : ($stats['coverage'] >= 70 ? 'yellow' : 'red');
$this->components->twoColumnDetail(
$locale,
sprintf(
'<fg=%s>%.1f%%</> (defined: %d, used: %d, missing: %d, unused: %d)',
$coverageColor,
$stats['coverage'],
$stats['total_defined'],
$stats['total_used'],
$stats['total_missing'],
$stats['total_unused']
)
);
}
$this->newLine();
}
/**
* Display missing keys.
*
* @param TranslationCoverageReport $report
*/
protected function displayMissingKeys($report, bool $verbose): void
{
$this->components->twoColumnDetail('<fg=yellow;options=bold>Missing Keys</>', '');
$this->line(' <fg=gray>Keys used in code but not defined in translation files:</>');
$this->newLine();
foreach ($report->getLocales() as $locale) {
$missing = $report->getMissing($locale);
if (empty($missing)) {
continue;
}
$this->line(" <fg=cyan;options=bold>{$locale}:</>");
foreach ($missing as $key => $usages) {
$this->line(" - <fg=yellow>{$key}</>");
if ($verbose && ! empty($usages)) {
foreach (array_slice($usages, 0, 3) as $usage) {
$shortPath = $this->shortenPath($usage['file']);
$this->line(" <fg=gray>Used in: {$shortPath}:{$usage['line']}</>");
}
if (count($usages) > 3) {
$remaining = count($usages) - 3;
$this->line(" <fg=gray>... and {$remaining} more usages</>");
}
}
}
$this->newLine();
}
}
/**
* Display unused keys.
*
* @param TranslationCoverageReport $report
*/
protected function displayUnusedKeys($report, bool $verbose): void
{
$this->components->twoColumnDetail('<fg=blue;options=bold>Unused Keys</>', '');
$this->line(' <fg=gray>Keys defined in translation files but not used in code:</>');
$this->newLine();
foreach ($report->getLocales() as $locale) {
$unused = $report->getUnused($locale);
if (empty($unused)) {
continue;
}
$this->line(" <fg=cyan;options=bold>{$locale}:</>");
foreach ($unused as $key => $files) {
$this->line(" - <fg=blue>{$key}</>");
if ($verbose && ! empty($files)) {
foreach ($files as $file) {
$shortPath = $this->shortenPath($file);
$this->line(" <fg=gray>Defined in: {$shortPath}</>");
}
}
}
$this->newLine();
}
}
/**
* Shorten a file path for display.
*/
protected function shortenPath(string $path): string
{
$basePath = base_path();
if (str_starts_with($path, $basePath)) {
return substr($path, strlen($basePath) + 1);
}
return $path;
}
/**
* Get shell completion suggestions.
*/
public function complete(
CompletionInput $input,
CompletionSuggestions $suggestions
): void {
if ($input->mustSuggestOptionValuesFor('locale')) {
// Suggest available locales
$langPath = lang_path();
if (is_dir($langPath)) {
$locales = [];
foreach (scandir($langPath) as $item) {
if ($item !== '.' && $item !== '..' && $item !== 'vendor' && is_dir($langPath.'/'.$item)) {
$locales[] = $item;
}
}
$suggestions->suggestValues($locales);
}
}
if ($input->mustSuggestOptionValuesFor('path')) {
// Suggest common paths
$suggestions->suggestValues([
app_path(),
resource_path('views'),
resource_path('js'),
base_path('packages'),
]);
}
}
}