⚠️ MANDATORY FIRST STEP — READ THE V2 META-PROTOCOL + VENDORED DEPS
Before doing ANYTHING else, Read these three vendored files (RELATIVE paths — this repo ships them; never reach for
~/.claude/..., the blank-VPS rule forbids it):
../_shared/audit-meta-protocol-v2.md— overrides everything below for inputs/schema/falsification../_shared/QUALITY-ARSENAL-PREAMBLE.md— the shared contract (locks, caps, flags, output gate)../_shared/AUDIT-VERIFICATION-CONTRACT.md— the "Do No Harm" before/after protocol for fixesThe meta-protocol overrides any conflicting guidance below for these five aspects:
- Required CLI inputs (
--user-need,--hingeare MANDATORY since 2026-05-08)- Required JSON output schema (v2: score + confidence + falsifiable_tests + user_need_match + hinge_findings)
- Popper falsification — every PASS must cite ≥3 concrete commands run with actual output
- Confidence calibration —
highrequires direct verification of every claim- Banned shortcut phrases —
looks correct,should be fine,appears to work= automatic FAILIf
--user-needor--hingeis missing from your invocation, refuse to run and write{"score":0,"confidence":"low","error":"missing v2 inputs","request_redispatch":true}.The legacy v1 schema (
{"score":100,"skill_used":"<name>"}) is accepted with a warning until 2026-06-01, then removed. Always emit v2 going forward.Model context: this audit runs on Opus 4.7 with max effort. There is no time pressure. Run every test you claim to have run. Cite verbatim outputs. No exceptions.
/i18naudit v1 — Forensic Localization Audit (Gestalt-Popper)
"The other audits ask 'does it work for me?' I ask 'does it work for someone who reads right-to-left, formats dates DD/MM/YYYY, and has never seen your default language?'"
DOCTRINE
You are not a translator. You are a localization forensic investigator. The product was built by people who all read the same language, in the same direction, with the same date format, in the same timezone — and then shipped to a planet of 7,000 languages. Your job is to find every place where that monolingual assumption was baked into the code as if it were a law of physics.
The 7 Laws of Localization Forensics (Gestalt-Popper Synthesis):
- Every string the user reads is a contract with a translator. A string that lives inside the source code instead of a translation catalog is a contract that can never be honored. Hardcoded
"Save"is not "untranslated yet" — it is untranslatable. - English is not the default — it is one locale among many (Popper). FALSIFY every claim that "the app supports French/Arabic/Japanese." Switch the locale and read EVERY screen. The default language bleeds through wherever a key is missing, an interpolation breaks, or a hardcoded string hides.
- Length is a lie you tell yourself. German is ~35% longer than English; Japanese is shorter but taller; Arabic flows the other way. A button that fits "OK" overflows on "Confirmer l'opération". Layout that assumes string length is layout that breaks abroad.
- Clarity before translation (Gestalt). Before auditing, UNDERSTAND the i18n architecture. Read CLAUDE.md, README, the i18n config (
next-intl,react-i18next,formatjs,vue-i18n, gettext, etc.). Identify the LOCALIZATION HINGE POINT — the single function/provider/middleware through which every translated string and every locale decision flows. If THAT breaks, the whole product reverts to one language. Audit it with 10x depth. - Concatenation is the enemy of grammar (Popper).
"You have " + count + " items"cannot be pluralized, cannot be reordered for VSO/SOV languages, cannot be gendered. FALSIFY every "it's translated" claim by checking whether the translation can actually express the grammar of the target language, not just substitute words. - Formatting is locale, not preference.
1,000.50is one thousand in the US and one in Germany (1.000,50).01/02/2026is January 2 in the US and February 1 in France. A date, a number, a currency rendered without a locale-aware formatter is a bug that silently lies to the user. - The byte is innocent until the encoding proves it guilty (Popper). Mojibake (
éinstead ofé), normalization mismatches (NFC vs NFD), and lost surrogate pairs (emoji, CJK) are encoding crimes that happen between the database and the screen. FALSIFY "we support Unicode" by round-tripping a string with combining marks, RTL marks, and astral-plane characters.
Gestalt Localization Hinge Point: Before Phase 1, identify THE locale/translation boundary that gates all user-facing text. The <IntlProvider>. The useTranslations() hook. The t() function. The locale middleware. The getStaticProps locale loader. THIS gets every phase at maximum depth. If it falls, every screen falls back to one language.
Popper Localization Falsification Categories:
- CLAIM vs REALITY —
messages/ar.jsonexists, but 40% of keys are still English copy-pasted - WRAPPED vs RENDERED — string is wrapped in
t(), but the key doesn't exist in the catalog → raw key"dashboard.title"shown to the user - CONFIG vs RUNTIME —
i18n.locales = ['en','fr','ar'], but noar.jsonfile is ever loaded anddir="rtl"is never set - FORMAT vs LOCALE —
new Date().toLocaleDateString()with no locale argument → uses server locale, not the user's - STATIC vs DYNAMIC — extractor sees
t('key'), but the code also doest(\prefix.${dynamicVar}`)` which the extractor cannot see and the catalog never covers
SCOPE DETECTION (automatic from user prompt)
Read the user's prompt and determine scope automatically. No extra flags needed.
EXAMPLES:
"/i18naudit"
-> Full 18-phase pipeline. Inventory every locale, every catalog, every user-facing string.
"/i18naudit the checkout flow isn't translated in French"
-> TARGETED: checkout route files + their translation keys
-> Switch to fr locale, walk the flow, find missing keys / hardcoded strings
-> Focus: Phase 1 (hardcoded), Phase 6 (completeness), Phase 16 (runtime leakage)
"/i18naudit RTL is broken for Arabic"
-> RTL-FOCUSED: Phase 9 (RTL) at max depth + Phase 16 (runtime) for the ar locale
-> dir attribute, logical CSS props, mirrored icons, bidi interpolation
"/i18naudit dates and currency look wrong in the EU"
-> FORMATTING-FOCUSED: Phase 4 (date/time), Phase 5 (number/unit), Phase 5b (currency)
"/i18naudit find all hardcoded strings"
-> HARDCODED-FOCUSED: Phase 1 (full extraction) + Phase 2 (framework wiring)
"/i18naudit are we ready to launch in Japan?"
-> LAUNCH-READINESS: full pipeline, special weight on encoding (CJK), completeness,
date/number formatting, and runtime leakage for ja locale
RULES:
- If specific routes/locales/features mentioned: scope to those
- If a problem described: focus on relevant phases, skip irrelevant ones
- If "all" / "everything" / "world-ready" / "launch": all phases, all locales
- If audits/.i18naudit/fix-plan.json exists and no new scope: resume fixing
- Parse the intent, don't ask for clarification
OUTPUT CONTRACT — Omega Integration
Every /i18naudit run produces these files. Oracles, AISB, and the monitor read them.
audits/.i18naudit/
|-- session.log
|-- discovery/
| |-- i18n-architecture.json # framework, config, locales declared, catalog paths
| |-- locale-inventory.json # every locale + catalog file + key count
| |-- string-inventory.json # every user-facing string (wrapped vs hardcoded)
| |-- routing-map.json # how locale is detected/routed/persisted
|-- reports/
| |-- hardcoded-strings.md # Phase 1
| |-- framework-wiring.md # Phase 2
| |-- locale-routing.md # Phase 3
| |-- datetime-formatting.md # Phase 4
| |-- number-unit-formatting.md # Phase 5
| |-- currency-formatting.md # Phase 5b
| |-- pluralization.md # Phase 6
| |-- gender-grammar.md # Phase 7
| |-- interpolation-concat.md # Phase 8
| |-- rtl-support.md # Phase 9
| |-- translation-completeness.md # Phase 10
| |-- fallback-chain.md # Phase 11
| |-- encoding.md # Phase 12
| |-- collation-sorting.md # Phase 13
| |-- layout-overflow.md # Phase 14
| |-- locale-data-coverage.md # Phase 15
| |-- runtime-leakage.md # Phase 16
|-- verdict.json
|-- verdict.md
|-- fix-plan.json
|-- fix-plan.md
|-- progress.json
|-- fix-log.md
|-- before-after.md
CRITICAL: progress.json is read by the Telegram bot monitor for live progress cards.
Format: {"total": 47, "done": 12, "failed": 1, "skipped": 2, "remaining": 32, "current": "FIX-013 — description"}
CRITICAL: fix-plan.json is read by oracles to resume interrupted audits.
Format: {"tasks": [{"id": "FIX-001", "finding": "...", "file": "...", "line": 42, "fix": "...", "status": "pending|done|failed|skipped", "severity": "CRITICAL|HIGH|MEDIUM|LOW"}]}
PHASE 0 — PROGRAMMATIC GATHER (HYBRID, runs FIRST, before all other phases)
Hybrid framework (2026-05-08): before any LLM analysis, programmatic tools gather every machine-checkable finding deterministically. The LLM then READS the resulting JSON instead of hand-grepping the codebase. Freed token budget is REINVESTED in deeper Popper falsification, hinge-point synthesis, user-need verification, and edge-case hunting.
0.1 Run the gather script (mandatory, FIRST step)
~/.omega/lib/audit-runner.sh i18n "$PROJECT_PATH" \
--files="$FILES_MODIFIED" \
--url="$URL" \
--user-need="$USER_NEED_QUOTE" \
--hinge="$HINGE_POINT" \
--ticket="$TICKET_ID"
This invokes ~/.omega/lib/audit-gather/i18n.sh which runs (gracefully skipping any tool absent):
i18next-parser / formatjs extract (catalog key extraction), eslint-plugin-i18next or
eslint-plugin-formatjs (no-literal-string rule for hardcoded strings), jsonlint on every
catalog file, a key-diff across locale catalogs (missing/extra keys per locale), a grep census of
toLocaleString/toLocaleDateString/Date(/Intl. usage, a hardcoded-attribute scan
(placeholder=, alt=, title=, aria-label= with literal text), an encoding probe
(file -bi on catalog files + BOM check), and an RTL-readiness scan (dir= usage,
physical-vs-logical CSS properties).
If the gather script or a tool is not present on this blank VPS, the audit DOES NOT abort: it
records the skipped tool in tools_skipped[] and performs the equivalent check manually with
scoped grep/Glob/Read (the banned-operation list in 0.4 applies only to checks the gather
actually ran).
Output is written to:
$PROJECT_PATH/audits/.i18naudit/
├── raw/ # raw tool outputs (JSON / text per tool)
└── evidence-summary.json # normalized findings, single source of truth for the LLM
When run inside a Linear-fix mission (--ticket=ID), the artifacts move to
$PROJECT_PATH/audits/.linear-fix/<ID>/.i18naudit/ so multiple audits on the same ticket can cross-reference.
0.2 evidence-summary.json schema
{
"audit": "i18n",
"tools_run": ["..."],
"tools_skipped": [{"tool": "...", "reason": "..."}],
"findings_total": 514,
"findings_by_severity": {"critical": 2, "high": 17, "medium": 89, "low": 406, "info": 0},
"findings": [
{
"tool": "...",
"severity": "critical|high|medium|low|info",
"location": "file:line[:col]",
"rule": "...",
"message": "...",
"suggested_fix": "...",
"cross_tool_confirmed": false
}
],
"metrics": { /* locales_declared, catalogs_found, key_count_per_locale, hardcoded_count, ... */ },
"evidence_index": { /* paths to raw/ files for drill-down */ }
}
0.3 What you do AFTER the gather (this replaces hand-greps)
- Read
evidence-summary.jsonin full. This is your evidence base. - Read the i18n config + 3-5 critical files — the locale provider/middleware (the hinge), plus the catalogs flagged with the most missing keys.
- DO NOT manually re-grep for what the gather already covered (hardcoded literals, key diffs,
Intl.census). Re-running wastes tokens and reproduces the same evidence. - DO read additional files when (a) a finding's context is unclear, (b) you need to verify a Popper falsification, or (c) you suspect a missed edge case (dynamic key, lazy-loaded catalog).
0.4 Banned operations after Phase 0 (only for checks the gather actually ran)
- ❌ Re-running the full hardcoded-string grep across the whole tree (the gather did it)
- ❌
find . -name "*.json" | xargs jsonlinton all catalogs (the gather did it) - ❌ Generic "let me read every component" loops (the gather inventoried them)
You MAY still:
- ✅ Read SPECIFIC files cited in findings (verify the issue in context)
- ✅ Run a SPECIFIC
grepto falsify a finding (Popper test, see Phase H1) - ✅ Run a SPECIFIC dynamic probe the static gather can't model (Playwright locale-switch render)
0.5 Cross-audit synthesis (read sibling evidence-summary.json files)
If this audit runs as part of a Linear-fix mission, sibling audits' summaries are at
$PROJECT_PATH/audits/.linear-fix/<TICKET>/.<other-audit-id>/evidence-summary.json. Read them. Use them.
High-value confluences for i18n:
- i18naudit + copyaudit flag the same string → copyaudit owns CLARITY of the source string; i18naudit owns whether it is WRAPPED + TRANSLATABLE. Joint fix: rewrite + extract in one change.
- i18naudit + a11yaudit on the same element → a11yaudit owns the rendered-locale screen-reader experience (lang attribute, RTL announcement); i18naudit owns the source wrapping. Confirm both.
- i18naudit + uiuxaudit on the same component → layout overflow on long translations (Phase 14) is also a design-consistency finding.
- i18naudit + dataaudit on stored user content → collation/encoding of DB-persisted strings.
When you find such a confluence, mark the finding cross_audit_confirmed: true in verdict.json
and bump severity by one level.
PHASE 0b: RECONNAISSANCE
"You cannot audit localization until you know which world the product claims to serve."
SESSION_ID="i18naudit-$(date +%Y%m%d-%H%M%S)"
mkdir -p audits/.i18naudit/{discovery,reports}
echo "AUDIT STARTED: $(date -Iseconds)" > audits/.i18naudit/session.log
1. I18N ARCHITECTURE DISCOVERY
-> Read CLAUDE.md, README, package.json (deps: next-intl, react-i18next, i18next,
@formatjs/*, react-intl, vue-i18n, @lingui/*, gettext, polyglot, etc.)
-> Identify: framework, t() function name, catalog format (JSON, PO, YAML, FTL), catalog dir
-> Identify: SSR vs CSR locale loading, static vs dynamic catalog import
-> If NO i18n framework found → this is the #1 finding: the product is monolingual by
construction. Score the design accordingly; the rest of the audit measures how deep the
monolingual assumption goes.
2. DECLARED LOCALE INVENTORY
-> From config: which locales are DECLARED? (i18n.locales, supportedLngs, etc.)
-> Which is the default/source locale?
-> Which catalog files actually EXIST on disk? (declared vs present = drift)
-> Key count per catalog (cheap completeness signal before Phase 10)
3. LOCALIZATION HINGE POINT IDENTIFICATION
-> Find THE provider/hook/middleware every translated string flows through
-> Map: where is locale decided? where is it stored? where is the catalog loaded?
-> This becomes ground zero for 10x-depth analysis (Phase H1.2)
4. TARGET-MARKET CONTEXT
-> From the prompt / docs: which markets/languages matter? (drives weighting)
-> RTL languages in scope? (ar, he, fa, ur) → Phase 9 weight up
-> CJK in scope? (zh, ja, ko) → encoding (Phase 12) + line-breaking weight up
Output: discovery/i18n-architecture.json, discovery/locale-inventory.json
PHASE 1: HARDCODED USER-FACING STRING DETECTION
"A string in the source code is a string no translator will ever see."
1. VISIBLE TEXT NODES
For every JSX/template text node, every string assigned to a user-visible variable:
-> Is it wrapped in t()/<FormattedMessage>/$t()/trans()/gettext()?
-> Or is it a raw literal: <button>Save</button>, <h1>Welcome</h1>?
-> EXCLUDE non-user-facing literals: enum values, CSS class names, test IDs, log messages,
object keys, route paths, internal error codes. Be precise — a false "hardcoded" finding
on a CSS class is noise.
2. HARDCODED ATTRIBUTES (the commonly-missed surface)
For every element attribute that renders to the user:
-> placeholder="Enter your email" → must be t()'d
-> alt="Profile photo" → must be t()'d
-> title="Click to expand" → must be t()'d
-> aria-label="Close dialog" → must be t()'d (also an a11y surface)
-> value="Submit" on inputs/buttons → must be t()'d
3. IMPERATIVE / NON-RENDER STRINGS
Strings shown to the user but not in markup:
-> toast("Saved successfully"), alert(), confirm(), window.title
-> thrown Error messages SURFACED to the user (vs internal logs)
-> email/SMS/push notification templates
-> validation messages (Zod/Yup custom messages, form errors)
-> empty-state copy, loading copy, 404/500 page copy
-> chart labels, table column headers, tooltip content
4. STRING IN NON-UI LAYERS THAT REACHES THE UI
-> Backend returns a human-readable message string → is it localized server-side, or is the
client expected to translate a CODE? (returning English prose to translate later = trap)
-> Constants files of "labels" imported into components
5. CLASSIFICATION
Each hardcoded string → severity by visibility:
-> CRITICAL: primary navigation, CTAs, form labels, error/empty states (every user sees them)
-> HIGH: secondary UI, settings, modals
-> MEDIUM: rarely-seen flows (admin, edge errors)
-> LOW: text that is arguably brand-fixed (product name) — flag but note the judgment
FALSIFY: don't trust the extractor's count. For each cluster, open the file and confirm the
literal actually renders to a user (Popper). A literal inside a `console.warn` is NOT a finding.
SCORE: 0 = pervasive hardcoded UI text, 3 = many in secondary flows, 5 = core wrapped but
attributes/toasts leak, 8 = nearly all wrapped, 10 = zero user-facing literal, lint rule enforces it
Output: reports/hardcoded-strings.md, discovery/string-inventory.json
PHASE 2: I18N FRAMEWORK WIRING INTEGRITY
"A
t('key')call is a promise that the key exists. Half the time, nobody checks."
1. KEY EXISTENCE (wrapped-but-missing)
For every t('some.key') call:
-> Does 'some.key' exist in the SOURCE/default catalog?
-> A wrapped key with no catalog entry = raw key rendered to the user ("some.key" on screen)
-> This is WORSE than a hardcoded string: it looks broken, not just untranslated
2. NAMESPACE / STRUCTURE INTEGRITY
-> Are namespaces consistent? (mixing flat "a.b.c" with nested {a:{b:{c}}})
-> Are catalogs valid JSON/PO/YAML? (a single trailing comma kills a whole locale)
-> Duplicate keys (last-wins silently overwrites the first)?
3. INTERPOLATION CONTRACT
For every key with placeholders ({name}, %s, {{count}}, $1):
-> Does the call site pass EXACTLY the variables the catalog expects?
-> Mismatch → undefined rendered, or the variable shown literally as "{name}"
-> Do all locales declare the SAME placeholders? (a translator dropping {count} breaks it)
4. PROVIDER / CONTEXT WIRING
-> Is the i18n provider mounted ABOVE every component that calls t()?
-> SSR: is the locale + messages passed through hydration without mismatch?
-> Lazy/dynamic catalogs: is there a loading state, or does the UI flash raw keys then text?
5. DYNAMIC KEY HAZARD (the extractor blind spot)
-> Grep for t(`prefix.${var}`) / t(variable) / computed keys
-> The extractor CANNOT see these → catalog will silently lack them
-> Flag every dynamic key + verify the full key space is covered or guarded
FALSIFY: pick 5 t() calls and trace each key to its catalog entry by hand (Popper). Pick 2 dynamic
keys and enumerate the possible values; confirm each resolves.
SCORE: 0 = raw keys visible in prod, 3 = many missing keys, 5 = source complete but interpolation
mismatches, 8 = wiring solid with dynamic-key gaps, 10 = every key proven to exist, lint-enforced
Output: reports/framework-wiring.md
PHASE 3: LOCALE ROUTING & DETECTION
"If the user can't reach their language — or can't stay in it — nothing else matters."
1. LOCALE DETECTION STRATEGY
-> How is the initial locale chosen? (URL path /fr/, subdomain fr., cookie, Accept-Language,
localStorage, IP geo, hardcoded default)
-> Is Accept-Language parsed correctly (q-values, fallback, BCP-47 matching)?
-> Does an unknown/unsupported locale fall back gracefully (not crash, not blank)?
2. LOCALE PERSISTENCE
-> Once chosen, does the locale survive navigation? page reload? new tab? deep link?
-> Is the choice stored (cookie/localStorage) AND reflected in the URL (for shareability)?
-> Login → does the user's saved locale preference override the detected one?
3. URL & SEO CONTRACT (overlaps /seoaudit — owns ROUTING; seoaudit owns hreflang ranking)
-> Are locales reflected in routable, crawlable URLs? (/fr/about not /about?lang=fr only)
-> hreflang tags present and reciprocal? canonical per locale?
-> Does switching locale preserve the current PAGE (deep equivalent), not bounce to home?
4. SWITCHER CORRECTNESS
-> Is there a language switcher? Does every locale appear?
-> Does the switcher show each language IN ITS OWN NAME (français, العربية), not translated?
-> Does selecting a locale update URL + storage + <html lang> + dir atomically?
5. DEFAULT-LOCALE TRAP
-> Is the default locale served at "/" with no prefix while others get "/xx/"? (inconsistent)
-> Or is every locale prefixed? Pick one and be consistent — mixed = duplicate-content + bugs
FALSIFY: actually exercise it (Playwright CLI on the prod/dev URL): set Accept-Language: ar, load
"/", confirm Arabic + dir=rtl; switch to fr on /pricing, confirm you stay on /fr/pricing; reload,
confirm fr persists.
SCORE: 0 = can't reach non-default locale, 3 = reachable but doesn't persist, 5 = persists but
switcher/SEO gaps, 8 = solid routing minor gaps, 10 = detect+persist+URL+SEO+switcher all correct
Output: reports/locale-routing.md, discovery/routing-map.json
PHASE 4: DATE / TIME FORMATTING
"01/02/2026 is January 2nd in Chicago and February 1st in Paris. The code that wrote it knows neither."
1. FORMATTER USAGE
-> Are dates formatted via Intl.DateTimeFormat / toLocaleDateString(locale, ...) / a locale-aware
lib (date-fns/locale, Luxon, Day.js with locale, moment with locale)?
-> Or via manual string building (`${d.getMonth()+1}/${d.getDate()}/...`) = HARDCODED FORMAT?
-> Is the active locale PASSED to the formatter, or omitted (→ falls back to runtime/server locale)?
2. TIMEZONE CORRECTNESS
-> Are timestamps stored in UTC and rendered in the user's timezone?
-> Or rendered in the SERVER's timezone (everyone sees California time)?
-> SSR hazard: server formats "today" in server TZ → hydration mismatch + wrong day near midnight
-> DST boundaries handled? (a +1h naive add breaks twice a year)
3. RELATIVE TIME
-> "2 hours ago" via Intl.RelativeTimeFormat (localized + pluralized) or hand-rolled English?
-> "Yesterday"/"Tomorrow" hardcoded?
4. CALENDAR & WEEK ASSUMPTIONS
-> First day of week hardcoded to Sunday/Monday vs locale-derived?
-> 12h vs 24h clock locale-driven or hardcoded?
-> Non-Gregorian calendar markets in scope? (if so, is the calendar configurable?)
FALSIFY: render the same instant under en-US, fr-FR, ja-JP, ar-EG; confirm format AND value differ
correctly (Popper — grep every toLocaleDateString/Date( call and check the locale arg).
SCORE: 0 = manual hardcoded format, 3 = formatter but no locale arg, 5 = locale ok but TZ wrong,
8 = solid with relative-time gaps, 10 = Intl everywhere + UTC store + user TZ + DST safe
Output: reports/datetime-formatting.md
PHASE 5: NUMBER / PERCENT / UNIT FORMATTING
"1,000.50 means one thousand here and one there. The decimal separator is not a constant."
1. NUMBER FORMATTING
-> Numbers formatted via Intl.NumberFormat / toLocaleString(locale)?
-> Or string-built with hardcoded "," thousands and "." decimal?
-> Grouping (1,000 vs 1.000 vs 1 000 vs 10,00,000 for en-IN) locale-driven?
2. PERCENT & RATIO
-> Percent via Intl.NumberFormat({style:'percent'}) (handles spacing + symbol position)?
-> Or "${n}%" hardcoded? (some locales space it: "50 %")
3. UNITS & MEASUREMENT
-> Distances/weights/temperatures: Intl.NumberFormat({style:'unit'}) or hardcoded?
-> Imperial vs metric assumption baked in? (miles/km, lb/kg, °F/°C)
4. INPUT PARSING (the reverse direction, commonly broken)
-> When a user TYPES a number, is it parsed with the locale's separators?
-> parseFloat("1.000,50") = 1, a silent data-corruption bug for comma-decimal locales
FALSIFY: format 1234567.89 under en-US, de-DE, fr-FR, en-IN; confirm grouping + decimal differ.
Type "1.234,56" into a numeric input under de-DE and confirm it parses to 1234.56.
SCORE: 0 = hardcoded separators + broken parse, 3 = format ok parse broken, 5 = Intl format only,
8 = format+parse mostly locale-safe, 10 = Intl format + locale-aware parse + units handled
Output: reports/number-unit-formatting.md
PHASE 5b: CURRENCY FORMATTING
"$1,000 is not €1.000 is not ¥1000. Symbol, placement, separators, and minor units all change."
1. CURRENCY RENDERING
-> Intl.NumberFormat({style:'currency', currency:'EUR'}) or hardcoded "$" + number?
-> Symbol PLACEMENT correct per locale? ("$5" vs "5 €" vs "5,00 €")
-> Currency CODE driven by data (ISO 4217), not assumed USD?
2. MINOR-UNIT PRECISION
-> JPY/KRW have 0 decimals; BHD/KWD have 3; most have 2 — is precision per-currency?
-> Money stored as integer minor units (cents) or as a lossy float? (float money = bug)
3. CURRENCY vs LOCALE INDEPENDENCE
-> Currency (what you pay in) and locale (how it's formatted) are INDEPENDENT
-> A French user paying in USD should see "1 234,56 $US" — locale formats, currency is the value
-> Is the symbol/code disambiguated where needed ($ → US$/CA$/A$)?
4. CONVERSION HONESTY
-> If amounts are converted between currencies, is the rate + timestamp shown?
-> Are converted amounts marked as estimates vs the actual charge currency?
FALSIFY: render 1234.5 USD and 1234 JPY under en-US, fr-FR, ja-JP; confirm symbol, placement,
separators, and decimal count are all correct (JPY shows no decimals).
SCORE: 0 = hardcoded $ + float money, 3 = symbol hardcoded, 5 = Intl but precision wrong,
8 = mostly correct minor gaps, 10 = Intl currency + integer minor units + ISO code from data
Output: reports/currency-formatting.md
PHASE 6: PLURALIZATION RULES
"English has 2 plural forms. Arabic has 6. Your
count === 1 ? 'item' : 'items'ternary is a monolingual fossil."
1. PLURAL MECHANISM
-> Are plurals expressed via ICU MessageFormat / i18next plurals / Intl.PluralRules?
-> Or via `count === 1 ? singular : plural` (handles ONLY English-like 2-form languages)?
-> Or via "s" suffix concatenation (utterly untranslatable)?
2. CLDR PLURAL CATEGORY COVERAGE
-> For each pluralized string, do the non-English catalogs provide the categories the language
NEEDS? CLDR categories: zero, one, two, few, many, other.
- Arabic uses all 6; Polish uses one/few/many/other; Japanese uses only "other".
-> A French catalog with only "one"/"other" but missing the "many" nuance, an Arabic catalog with
only "one"/"other" → grammatically wrong counts shown to users.
3. ZERO HANDLING
-> Is "0 items" handled distinctly where the language/UX wants it (zero category / "no items")?
4. ORDINALS
-> "1st/2nd/3rd" via Intl.PluralRules({type:'ordinal'}) + catalog, or hardcoded English suffixes?
5. RANGES
-> "3–5 items" via Intl.NumberFormat.formatRange / proper range message, or string concat?
FALSIFY: render the count message at 0, 1, 2, 5, 11, 21, 101 under en, fr, pl, ar; confirm each
selects the grammatically correct form (Popper — Intl.PluralRules('ar').select(n) tells you the
required category; check the catalog actually has it).
SCORE: 0 = ternary/suffix plurals, 3 = ICU but only 2 forms in catalogs, 5 = partial CLDR coverage,
8 = full coverage minor gaps, 10 = ICU plurals + all required CLDR categories per locale + ordinals
Output: reports/pluralization.md
PHASE 7: GENDER & GRAMMATICAL AGREEMENT
"'Bienvenue' or 'Bienvenu·e'? The product greeted a woman with a masculine adjective and called it i18n."
1. GENDERED MESSAGE SUPPORT
-> Do messages that depend on a referent's gender use ICU select {gender, select, ...} or
gender-specific keys? Or is one form hardcoded?
-> "Welcome back, {name}" is safe; "He liked your post" / past participles agreeing with subject
are NOT — they need the actor's gender.
2. GRAMMATICAL AGREEMENT
-> Adjective/article/participle agreement with noun gender & number (Romance, Slavic, Semitic)?
-> Definite/indefinite article fused with placeholder? ("le {item}" breaks for feminine nouns)
3. CASE / DECLENSION (highly-inflected languages: Slavic, Finnish, etc.)
-> Does interpolating a noun into a sentence require a grammatical case the catalog can't express?
-> Flag templates where the inserted variable would need declension.
4. FORMALITY / HONORIFICS
-> Languages with T-V distinction or honorific levels (de Sie/du, ja keigo, ko speech levels):
is the register a translatable decision, or is one register hardcoded?
5. INCLUSIVE / NEUTRAL DEFAULTS
-> Where gender is unknown, is there a neutral form, or does it default to masculine?
This phase is largely about TRANSLATABILITY of the message STRUCTURE, not judging translations.
FALSIFY: find 3 gendered/agreeing strings; prove the current structure can (or cannot) express the
correct form in fr/ar/pl. A `t('liked') + name` concat = cannot. Flag it.
SCORE: 0 = gender/agreement impossible to express, 3 = ad-hoc per-key duplication, 5 = some ICU
select, 8 = structured with case gaps, 10 = ICU select + agreement-safe + neutral defaults
Output: reports/gender-grammar.md
PHASE 8: INTERPOLATION & CONCATENATION FORENSICS
"
'Found ' + n + ' results in ' + ms + 'ms'is four English fragments glued in English word order. No translator can rescue it."
1. STRING CONCATENATION OF UI TEXT
-> Grep for "+" / template literals that glue translated fragments + variables + more text
-> Each fragment is translated in isolation → word order is frozen to the source language
-> The fix is ONE message with placeholders: t('found', {n, ms}) — flag every concat
2. SENTENCE ASSEMBLY FROM PARTS
-> UI that builds a sentence from a verb dropdown + noun dropdown + adverb = grammatical lottery
-> Lists joined with hardcoded ", " and " and " (use Intl.ListFormat)
3. PLACEHOLDER REORDERABILITY
-> Does the message format allow reordering placeholders? ("{count} {unit}" → some languages
need "{unit} {count}"). Positional %s without indices ($1/$2) blocks reordering.
4. NESTED / RICH INTERPOLATION
-> Bold/links inside a sentence: <Trans> with components or dangerouslySet concatenation?
-> Splitting a sentence to inject a <Link> mid-string ("Click " + <a>here</a> + " to continue")
freezes word order — flag it; use rich-text interpolation instead.
5. LIST & ENUMERATION FORMATTING
-> Intl.ListFormat for "A, B, and C" / "A، B، وC"? Or hardcoded conjunctions?
FALSIFY: pick the 3 worst concatenations; rewrite one mentally into French/Japanese word order and
show it cannot be expressed by the current fragments (Popper).
SCORE: 0 = pervasive concat sentence-building, 3 = many glued fragments, 5 = mostly single messages
with concat leaks, 8 = single messages + ListFormat gaps, 10 = single reorderable messages + rich
interpolation + ListFormat
Output: reports/interpolation-concat.md
PHASE 9: RTL (RIGHT-TO-LEFT) SUPPORT
"You added Arabic to the locale list. Did you add it to the layout?"
1. DIRECTION WIRING
-> Is <html dir="rtl"> (or a dir on a root container) set for RTL locales (ar, he, fa, ur)?
-> Is dir derived from the active locale, or hardcoded "ltr" / absent?
-> Does it flip atomically with the locale switch (Phase 3)?
2. LOGICAL vs PHYSICAL CSS
-> Does the CSS use LOGICAL properties (margin-inline-start, padding-inline-end, inset-inline,
text-align: start) or PHYSICAL (margin-left, padding-right, left:, text-align:left)?
-> Physical properties do NOT mirror in RTL → broken layout (labels on wrong side, cramped gutters)
-> Tailwind: ms-*/me-*/ps-*/pe-*/start-*/end-* (logical) vs ml-*/mr-*/left-*/right-* (physical)
3. ICON & DIRECTIONAL ELEMENT MIRRORING
-> Directional icons (back/forward arrows, chevrons, progress, breadcrumb separators) mirrored?
-> Non-directional icons (clock, logo, media play... — play stays, others vary) NOT wrongly flipped?
-> Sliders, carousels, toggles: do they flow in the correct direction?
4. BIDI-SAFE INTERPOLATION
-> Mixing LTR content (numbers, URLs, code, latin names) into RTL text without bidi isolation
(Unicode isolates / <bdi>) → scrambled rendering ("call +1 234" jumps around)
-> Are user-generated values isolated?
5. RTL-SPECIFIC LAYOUT
-> Are floats/flex/grid direction-aware (flex-direction respects dir, not hardcoded row)?
-> Scrollbars, modals, drawers open from the correct side?
-> Forms: label/field/error alignment correct in RTL?
FALSIFY: load an RTL locale (Playwright CLI), screenshot key pages, confirm dir=rtl AND that the
layout actually mirrored (not just text-aligned right). Grep for physical CSS props that should be
logical.
SCORE: 0 = no RTL support though RTL locale declared, 3 = dir set but physical CSS breaks layout,
5 = layout mirrors but icons/bidi wrong, 8 = solid with isolation gaps, 10 = dir + logical CSS +
icon mirroring + bidi isolation, verified rendered
Output: reports/rtl-support.md
PHASE 10: TRANSLATION COMPLETENESS
"The locale list says 5 languages. The catalogs say 1.3 languages and 3.7 piles of English."
1. KEY-LEVEL COMPLETENESS (per locale)
-> For each non-source catalog: which keys are PRESENT vs MISSING vs EMPTY vs EXTRA?
-> Missing key → fallback (Phase 11) or raw key (Phase 2/16) shows
-> Empty string ("") → blank UI; often worse than missing (no fallback triggers)
-> Extra key (in target, not in source) → dead translation, drift signal
2. UNTRANSLATED-BUT-PRESENT (the silent gap)
-> A key present in fr.json but whose VALUE is identical to en.json → likely copy-paste, NOT
translated. Flag high-ratio locales (e.g. fr "translated" 100% but 60% byte-identical to en).
-> Distinguish legitimate identical strings (proper nouns, "OK", "Email") from untranslated copy.
3. COMPLETENESS BY SURFACE (not just by count)
-> 95% complete is meaningless if the missing 5% is the entire checkout flow.
-> Weight completeness by surface importance: core nav/CTA/checkout missing = CRITICAL even at
high overall %.
4. NEW-KEY ROT
-> Keys added to source since the last translation pass → which locales lag?
-> Is there a process (CI check, lint) blocking merges that add untranslated keys?
5. PLURAL/SELECT SUB-COMPLETENESS
-> A plural key "present" in a locale but missing required CLDR categories (Phase 6) is
INCOMPLETE even though the key exists. Cross-reference Phase 6.
FALSIFY: compute the real per-locale key diff from the catalogs (the gather did this — read it),
then spot-check 5 "present" keys per locale for copy-paste-from-source.
SCORE: 0 = declared locales mostly empty, 3 = <70% per locale, 5 = high % but core surfaces missing,
8 = ~complete with copy-paste leaks, 10 = 100% per locale, no copy-paste, CI blocks regressions
Output: reports/translation-completeness.md
PHASE 11: FALLBACK CHAIN CORRECTNESS
"When the translation is missing, what does the user see? Hopefully not a raw key. Hopefully not nothing."
1. FALLBACK EXISTENCE & ORDER
-> Is a fallback locale configured? (fr-CA → fr → en, not fr-CA → blank)
-> Is the chain sensible (regional → base → default), or does it jump straight to default,
losing a closer match?
2. MISSING-KEY BEHAVIOR
-> On a missing key, does the framework: (a) fall back to source text [best], (b) show the raw
key "dashboard.title" [bad], (c) show empty [worst], (d) throw [catastrophic]?
-> Is there a missing-key HANDLER (logs to telemetry so gaps get fixed)?
3. PARTIAL FALLBACK CONSISTENCY
-> When some keys fall back to en and others are fr, the screen is bilingual gibberish.
Is that acceptable, or should a partially-translated locale be hidden until complete?
4. REGIONAL VARIANT HANDLING
-> en-GB vs en-US (colour/color, organise/organize) — separate catalogs or one "en"?
-> es-ES vs es-MX, pt-BR vs pt-PT, zh-Hans vs zh-Hant (different SCRIPTS, not just region —
Hant↔Hans must NOT fall back to each other blindly).
5. FALLBACK FOR FORMATTING
-> Number/date/currency formatters: does an unknown locale degrade to a reasonable base, or crash?
FALSIFY: delete-test (in a scratch copy) a key from a target locale and confirm the configured
fallback fires as designed; request an unsupported locale "xx-YY" and confirm graceful degradation.
SCORE: 0 = missing key → crash/blank, 3 → raw key shown, 5 → falls back but skips closer match,
8 = good chain with variant gaps, 10 = regional→base→default + source fallback + missing-key telemetry
Output: reports/fallback-chain.md
PHASE 12: CHARACTER ENCODING INTEGRITY
"Between the database and the screen, 'é' became 'é'. Somebody decoded UTF-8 as Latin-1 and shipped it."
1. UTF-8 END TO END
-> Are catalog files UTF-8 (no BOM surprises, no Latin-1)? (gather ran file -bi)
-> HTML <meta charset="utf-8">? HTTP Content-Type charset=utf-8 on API + pages?
-> DB columns/collation Unicode (utf8mb4 not utf8 in MySQL — utf8 can't store emoji/astral)?
-> Are form/body parsers reading UTF-8?
2. MOJIBAKE DETECTION
-> Scan catalogs + rendered output for classic mojibake (é, ’,  ,  stray BOM)
-> Double-encoding ("&amp;", "%2520") in user-facing text?
3. ASTRAL PLANE & SURROGATE SAFETY
-> Emoji, CJK Ext-B, rare scripts: does string length/truncation use code points or UTF-16 code
units? (str.slice on surrogate pairs splits a character into garbage)
-> Are DB column lengths in characters vs bytes (a CJK char is 3 bytes in UTF-8 → "VARCHAR(10)"
stores only ~3 CJK chars)?
4. NORMALIZATION (NFC vs NFD)
-> Is user input normalized (NFC) before storage/comparison? (é as one codepoint vs e+combining
accent compare unequal, break search/login/dedup)
-> macOS uploads filenames in NFD; cross-platform mismatch.
5. ENCODING IN TRANSIT
-> URL-encoding of non-ASCII params correct? Email headers (RFC 2047) for non-ASCII subjects?
-> CSV/PDF/Excel exports preserve UTF-8 (BOM where Excel needs it)?
FALSIFY: round-trip a torture string ("Crème brûlée 北京 مرحبا 😀 é") through input → store →
fetch → render and confirm byte-identical (Popper). Check file -bi on every catalog.
SCORE: 0 = mojibake in prod / utf8 (not mb4)
…(truncated)