# I18naudit

> Forensic internationalization & localization audit v1 (Gestalt-Popper). 18-phase deep analysis of everything that decides whether the product is WORLD-READY: hardcoded user-facing string detection (JSX text, alt/title/aria/placeholder attributes, toast/error/email/notification copy), i18n framework wiring integrity (key existence, namespace structure, interpolation contract), locale routing & detection (path/subdomain/cookie/Accept-Language, persistence across navigation), pluralization rules (CLDR plural categories: zero/one/two/few/many/other), gender/grammatical agreement, date/time formatting (Intl.DateTimeFormat, timezone), number/percent/unit formatting, currency formatting (ISO 4217, symbol placement, minor-unit precision), RTL support (dir attribute, logical CSS properties, bidi-safe interpolation, mirrored icons), translation completeness + fallback chains, character encoding (UTF-8 end to end, normalization, mojibake), locale-aware sorting & collation (Intl.Collator), and untranslated-UI leakage at

- Skill: `agentik-os/i18naudit` (Agent Skill)
- Install (CLI): `npx skillmds@latest add agentik-os/i18naudit`
- Raw SKILL.md: https://api.skillmd.com/api/skills/agentik-os/i18naudit/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: agentik-os (https://skillmd.com/u/agentik-os)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/agentik-os/i18naudit

---



<!-- AUDIT-META-V2-INJECTED -->

> ## ⚠️ 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):
> 1. `../_shared/audit-meta-protocol-v2.md` — overrides everything below for inputs/schema/falsification
> 2. `../_shared/QUALITY-ARSENAL-PREAMBLE.md` — the shared contract (locks, caps, flags, output gate)
> 3. `../_shared/AUDIT-VERIFICATION-CONTRACT.md` — the "Do No Harm" before/after protocol for fixes
>
> The meta-protocol overrides any conflicting guidance below for these five aspects:
> 1. Required CLI inputs (`--user-need`, `--hinge` are MANDATORY since 2026-05-08)
> 2. Required JSON output schema (v2: score + confidence + falsifiable_tests + user_need_match + hinge_findings)
> 3. Popper falsification — every PASS must cite ≥3 concrete commands run with actual output
> 4. Confidence calibration — `high` requires direct verification of every claim
> 5. Banned shortcut phrases — `looks correct`, `should be fine`, `appears to work` = automatic FAIL
>
> If `--user-need` or `--hinge` is 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):**
1. **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**.
2. **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.
3. **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.
4. **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.
5. **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.
6. **Formatting is locale, not preference.** `1,000.50` is one thousand in the US and one in Germany (`1.000,50`). `01/02/2026` is 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.
7. **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.json` exists, 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 no `ar.json` file is ever loaded and `dir="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 does `t(\`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)

```bash
~/.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

```jsonc
{
  "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)

1. **Read `evidence-summary.json` in full.** This is your evidence base.
2. **Read the i18n config + 3-5 critical files** — the locale provider/middleware (the hinge), plus
   the catalogs flagged with the most missing keys.
3. **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.
4. **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 jsonlint` on 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 `grep` to 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."*

```bash
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;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 北京 مرحبا 😀 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)
