i18n Audit Developer Guide - edmozley/freeitsm GitHub Wiki

i18n Audit β€” Developer Guide

scripts/i18n_audit.php compares any locale against English and reports what is wrong with it.

Related: Internationalisation for how translations work, and Adding a Language for the process of creating one.


0. πŸ”΄ THE MOST IMPORTANT THING ON THIS PAGE

A locale cannot be more complete than its source. This script asks "how much of lang/en does this locale have?" β€” which is the right question, and is structurally incapable of finding a screen whose strings were never added to lang/en at all.

That is not hypothetical. On 2026-09-22 the maintainer opened the Spanish Checklists screen, found it in English, and this script reported Spanish at 100%. Both were true. lang/en/checklists.php held 70 help-page keys and 3 nav items; the module's own screens β€” 2,011 lines across four files β€” contained no t() call at all, so there was nothing for a translator to translate.

πŸ”‘ Coverage of lang/en and coverage of the PRODUCT are different claims. Run both:

php scripts/i18n_audit.php --all     # is each locale in step with the source?
php scripts/i18n_unwired.php         # does the source cover the product?

The whole toolkit

Script Asks Fails when
i18n_audit.php How far behind is this locale? never β€” it reports
i18n_unwired.php Does a screen render text without calling t()? visible text, zero t()
i18n_gate.php Did this change make things worse? a new English key missed a current locale
i18n_drift.php Is this locale quietly writing a related language? a wrong-language form appears
i18n_verify_chunk.php Is this translated chunk safe to merge? structure differs from English
i18n_gated_merge.php verify + merge, welded together anything above fails
i18n_chunk.php Β· i18n_merge.php Β· i18n_repair_chunk.php the pipeline β€” see Adding a Language

⚠️ Each of these was written because a real defect slipped past everything that existed at the time. None is redundant, and the newest ones exist because a green result from the older ones meant less than it appeared to.


1. πŸ”‘ Why this exists

A locale does not announce that it has fallen behind. A missing key falls back to English and renders perfectly, so a half-translated module looks finished until somebody who actually reads that language opens it. English keeps moving; twenty-three other locales do not move with it.

The only way to know is to compare, and comparing 9,290 strings across 23 locales by hand is not a thing anybody does twice.

This was written after a simple question β€” "the German files could be really out of date, can you check?" β€” turned out to need a real answer rather than a spot check. German was at 47.2%. So were fourteen other locales.


2. Running it

php scripts/i18n_audit.php de           # one locale, full detail
php scripts/i18n_audit.php de --keys    # …and list every missing key
php scripts/i18n_audit.php --all        # every locale, one line each

Read-only. It reads lang/ and writes nothing.

--all output, sorted worst first:

LOCALE    COVER   FILES     KEYS   PLACE   EXTRA SAME-AS-EN
--------------------------------------------------------------
gu        43.2%       8     5277       0       0 44
de        47.2%       7     4901       0       0 159
uk        78.8%       1     1967       0      30 104
pt-BR     97.3%       1      251       3      14 192
Column Meaning
COVER percentage of English strings present
FILES whole namespaces absent β€” the entire module renders in English
KEYS total English strings not present, including those inside missing files
PLACE placeholder mismatches
EXTRA keys English no longer has
SAME-AS-EN values byte-identical to English

3. What it checks, in order of how much the problem matters

Missing files

A whole namespace absent. The worst kind, and the easiest to miss, because the module works β€” it is simply in English.

Missing keys

Individual strings absent from a file that otherwise exists. These produce English words in the middle of a translated sentence.

πŸ”‘ Placeholder mismatches β€” the dangerous one

⚠️ A translation that drops {name}, or swaps %d and %s, is WORSE than a missing translation. A missing string falls back to English and looks obviously untranslated. A broken placeholder renders β€” so nobody reports it β€” and it either prints the wrong value or shows a raw token to the user.

Checked both ways: a placeholder the translator invented is as broken as one they lost.

Two styles are in use and a locale can be perfect in one and broken in the other:

  • {name} β€” the modern form. Compared as a sorted set, since order does not matter.
  • %s %d %1$s β€” the older form, still used across the tickets strings. Compared in sequence, because a swapped %d and %s passes any check that merely counts them.

Extra keys

Keys English no longer has. Dead weight, and a reliable sign the locale was written against an older version.

Identical to English

Reported as a number to look at, never as an error β€” plenty of strings are legitimately identical. A large number usually means a file was copied and never translated.


4. ⚠️ The false-positive that shaped the tool

The first version reported German as having two broken placeholders. Both were wrong:

'percent_uploaded'  => '{pct}% uploaded'      // en
'percent_uploaded'  => '{pct}% hochgeladen'   // de

The printf pattern included the space flag β€” % d is legal printf β€” so "% u" in "% uploaded" matched as a specifier, and "90% of" versus "90 % der" matched as another. Both were ordinary prose containing a percent sign.

The space flag is now excluded:

preg_match_all('/%(?:\d+\$)?[-+0#]*[\d.]*[bcdeEfFgGosuxX]|%%/', $s, $printf);
//                          ↑ no space in the flag set

πŸ”‘ The lesson is not about regex. A checker that cries wolf gets ignored, and then the real mismatch it eventually finds gets ignored too. Prefer missing an exotic case to reporting a common one wrongly. After the change, false positives went from several per locale to zero, and the findings that survived were all genuine.

A finding class that needs a human β€” and one that turned out to be real

{{doubled braces}} are documentation examples of the workflow-variable syntax, not substitution tokens. The audit flags them when a translator translates the word inside. Eyeball, do not auto-fix.

⚠️ This page used to say "that is usually fine". On 2026-09-22 it was not. Brazilian Portuguese rendered {{variables}} as {{variΓ‘veis}} in three workflow help strings. Those strings teach the reader the syntax they type into a template, and the engine resolves those tokens only in English β€” so a user copying the example got a token that resolves to nothing. They were the last three placeholder faults in the product.

πŸ”‘ The distinction: a doubled brace in ordinary prose about variables can be translated; a doubled brace presented as something to copy cannot. Read the surrounding sentence. Every translator prompt now carries this rule.


5. How it works

Step What happens
Flatten Each lang file is included and flattened to dot paths β€” ['a' => ['b' => 'x']] becomes a.b. Comparing nested arrays directly would report a whole branch as one difference
Compare Every English key is looked for in the locale; every locale key is looked for in English
Placeholders Extracted from both values and compared as described above
Score COVER counts strings inside missing FILES as missing too, or a locale that is missing eight whole modules would score well on the files it does have

looksDeliberatelyIdentical() suppresses the identical-to-English count for empty strings, one- and two-character values, pure punctuation, and a list of brand and protocol names nobody translates (FreeITSM, URL, API, SLA, Slack…). Extend that list rather than living with the noise.


6. Files

Colour key: πŸ› οΈ tool Β· 🌍 data Β· πŸ“„ docs

🎨 File What it does
πŸ› οΈ scripts/i18n_audit.php The whole thing. ~250 lines, no dependencies, read-only
🌍 lang/en/*.php The reference. 24 namespaces, 9,290 strings
🌍 lang/<locale>/*.php Compared against it
πŸ“„ This page

7. When to run it

  • Before adding a language β€” see what "complete" actually means today
  • Before a release β€” catch a locale that a new module left behind
  • After adding any new English strings β€” every locale just got slightly worse, and this says by how much
  • When somebody says a translation looks wrong β€” placeholder mismatches are the usual cause, and they are invisible by inspection

⚠️ It cannot tell you whether a translation is good, only whether it is there and structurally sound. A locale can score 100% and read like a machine wrote it.


8. The script

scripts/i18n_audit.php in full
<?php
/**
 * i18n audit β€” compare a locale against English and report what is wrong with it.
 *
 *   php scripts/i18n_audit.php de            one locale, full detail
 *   php scripts/i18n_audit.php --all         every locale, one summary line each
 *   php scripts/i18n_audit.php de --keys     also list every missing key
 */

$root    = dirname(__DIR__);
$langDir = $root . '/lang';

$args    = array_slice($argv, 1);
$flags   = array_values(array_filter($args, fn($a) => str_starts_with($a, '--')));
$locales = array_values(array_filter($args, fn($a) => !str_starts_with($a, '--')));
$showKeys = in_array('--keys', $flags, true);
$all      = in_array('--all', $flags, true);

$available = array_values(array_filter(scandir($langDir), function ($d) use ($langDir) {
    return $d !== '.' && $d !== '..' && $d !== 'en' && is_dir($langDir . '/' . $d);
}));
sort($available);

if ($all) {
    $locales = $available;
} elseif (!$locales) {
    fwrite(STDERR, "Usage: php scripts/i18n_audit.php <locale>|--all [--keys]\n");
    exit(1);
}

/** Flatten a nested lang array into dot paths. */
function flatten(array $a, string $prefix = ''): array
{
    $out = [];
    foreach ($a as $k => $v) {
        $key = $prefix === '' ? (string) $k : $prefix . '.' . $k;
        if (is_array($v)) { $out += flatten($v, $key); } else { $out[$key] = (string) $v; }
    }
    return $out;
}

/** Every placeholder in a string. Named tokens sorted; printf specs in sequence. */
function placeholders(string $s): array
{
    preg_match_all('/\{[a-zA-Z0-9_]+\}/', $s, $named);
    $named = $named[0];
    sort($named);
    // ⚠️ No space in the flag set β€” see section 4.
    preg_match_all('/%(?:\d+\$)?[-+0#]*[\d.]*[bcdeEfFgGosuxX]|%%/', $s, $printf);
    return ['named' => $named, 'printf' => $printf[0]];
}

/** Strings that are legitimately the same in most languages. */
function looksDeliberatelyIdentical(string $v): bool
{
    $t = trim($v);
    if ($t === '') return true;
    if (mb_strlen($t) <= 2) return true;
    if (preg_match('/^[\d\s\p{P}\p{S}]+$/u', $t)) return true;
    $brands = ['FreeITSM', 'Email', 'URL', 'API', 'SSO', 'LDAP', 'SLA', 'CMDB', 'PDF', 'CSV',
               'JSON', 'XML', 'Slack', 'Teams', 'Jira', 'Intune', 'Microsoft', 'Google'];
    return in_array($t, $brands, true);
}

$enNs = array_map(fn($f) => basename($f, '.php'), glob($langDir . '/en/*.php'));
sort($enNs);

$en = [];
foreach ($enNs as $ns) {
    $data = include $langDir . '/en/' . $ns . '.php';
    $en[$ns] = is_array($data) ? flatten($data) : [];
}
$enTotal = array_sum(array_map('count', $en));

foreach ($locales as $loc) {
    $dir = $langDir . '/' . $loc;
    $missingFiles = $missingKeys = $extraKeys = $phProblems = [];
    $untranslated = 0;

    foreach ($enNs as $ns) {
        $path = $dir . '/' . $ns . '.php';
        if (!is_file($path)) { $missingFiles[] = $ns; continue; }
        $data = @include $path;
        if (!is_array($data)) continue;
        $loc_ = flatten($data);

        foreach ($en[$ns] as $key => $enVal) {
            $full = $ns . '.' . $key;
            if (!array_key_exists($key, $loc_)) { $missingKeys[] = $full; continue; }
            $lv = $loc_[$key];
            if ($lv === $enVal && !looksDeliberatelyIdentical($enVal)) $untranslated++;

            $a = placeholders($enVal);
            $b = placeholders($lv);
            if ($a['named'] !== $b['named'] || $a['printf'] !== $b['printf']) {
                $phProblems[] = $full;
            }
        }
        foreach ($loc_ as $key => $v) {
            if (!array_key_exists($key, $en[$ns])) $extraKeys[] = $ns . '.' . $key;
        }
    }

    $missingFromFiles = 0;
    foreach ($missingFiles as $ns) $missingFromFiles += count($en[$ns]);
    $totalMissing = count($missingKeys) + $missingFromFiles;
    $pct = round(100 * ($enTotal - $totalMissing) / $enTotal, 1);

    // …reporting omitted here for brevity; see the file itself for the
    //   section-by-section output and the --all summary table.
}

The copy above is trimmed to the logic. The file in the repository is the authority β€” it also carries the per-section reporting, the --all summary table, and the comments explaining each decision.

⚠️ **GitHub.com Fallback** ⚠️