Skip to content
Back to skills

I18n Scan

ASecurity

i18n detector: copy, catalog parity, plurals, formats, RTL, lang/dir, bidi.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 20, 2026
developmentpythongophpswiftshellbashsqlreactvueangular

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill i18n-scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of I18n Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for I18n Scan
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-i18n-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-i18n-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: i18n-scan
description: "i18n detector: copy, catalog parity, plurals, formats, RTL, lang/dir, bidi."
kind: skill
pack: frontend
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: i18n-scan

## Premise

An i18n defect is copy the user cannot read in their own language, or a layout that is wrong in it. An English label sits in an Arabic screen. A key exists in `en` and nowhere else, so production renders `orders.card.invalid_status`. `count === 1 ? 'item' : 'items'` is right for English and wrong for the six CLDR plural categories of Arabic. A date formats in the server's locale. `margin-left` puts the gap on the wrong side of an RTL layout. A Latin username scrambles the punctuation of the Arabic sentence around it. None of this fails a type check or a test written in the developer's locale, and most of it is invisible until a second locale ships.

**Every finding cites the site `<file:line>` (or the catalog set, key and locale) + the class + the route or screen that renders it + the oracle that failed, or the test that would settle it + the fix in the project's i18n library.** A grep hit, a lint message or a script row is a candidate, never a finding by itself. "No i18n defects" is never printed without the class-coverage block (§ Output).

**Detect statically, prove by execution.** The static half is the pre-flight below plus a catalog script that diffs every locale's keys, placeholders and plural forms. The proof is a render or a unit test under a second locale: a pseudo-locale walk, a missing-key run, a plural matrix, an RTL mirror check ([`proof-oracles.md`](../../../../snippets/proof-oracles.md) § "Pseudo-locale and RTL mirror", variants V111 and V112). A candidate with no harness is **Unconfirmed**, carrying the test that would settle it. It is never dropped as "speculative" and never promoted on reading alone ([`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 2, gate 6).

## The locale-count gate

Decide the gate before any class runs, and print it in the header. It comes from the catalog script's `GATE:` line plus pre-flight greps 0a–0d.

| Gate | When | What runs |
|---|---|---|
| **ON** | more than one locale declared (catalog files, `supportedLngs`, `locales: [...]`), or a non-`en` `lang` or `dir="rtl"` shipped, or the brief names a second market | every class; RTL classes (§5, §7 `dir`, §11) only when an RTL locale (`ar`, `he`, `fa`, `ur`, …) is declared or shipped |
| **CATALOG-ONLY** | one locale, but the project has an i18n layer: a library from 0a, or an in-house `t()` from 0c | §1, §2 (keys used but undefined), §3, §4, §10 at one severity step lower: the code is committed to externalising copy, and a bypass is convention drift. §5, §6, §7, §8, §11 print `N/A (single locale, no RTL)` |
| **OFF** | one locale and no i18n layer | every row `N/A (single-locale product, no i18n library)`. Not findings ([`precedents/ui.md`](../../../../snippets/precedents/ui.md) § 47–48 cover the RTL and expansion cases) |

The gate never downgrades a class silently. A row it turns off prints its `N/A` reason.

## The rule

A candidate is a finding when all four hold:

1. **It renders for a user.** A route or screen shows the string, the format or the layout. Dead components, Storybook-only files, design-system documentation pages the product declares single-language, and admin or dev routes declared untranslated fail here.
2. **The locale that breaks it ships.** The gate is ON for the class, and the locale the defect hurts is declared: an RTL locale for §5, a locale with more plural categories than English for §3.
3. **No existing control handles it.** A `<Trans>` child, an Angular `i18n`-marked element, a project `formatDate()` wrapper that injects the active locale, a server that already localizes its messages by `Accept-Language`, a `[dir]`-scoped override.
4. **The oracle fires, or would.** A failing test or render is a finding. A static fact passes with its two-ended trace: a key missing from a shipped locale's catalog, a hard-coded `lang="en"` on a multi-locale shell. Everything else without a harness is Unconfirmed, with the test written out.

## Boundaries

- **Clock and time-zone arithmetic** (a date off by a day, DST, `new Date()` in a range filter) → code-quality `correctness-smell-scan` §6, when the code-quality pack is installed. §4 here owns presentation: which locale, calendar, digits and currency format the user sees. Without that pack, report the arithmetic here tagged `correctness-class (owner pack not installed)`.
- **Locale-dependent output during SSR** (a hydration mismatch from `toLocaleDateString()` on server and client) → `ssr-audit` §1 (this pack). Server-side `i18next.changeLanguage()` on a shared instance, which serves one user's language to another, → `ssr-audit` §4. §4 here still reports the missing locale argument once.
- **The leak in raw server text** (a stack, SQL or internal detail) → security `data-exposure-scan` §1, when the security pack is installed. §10 here owns only the language: English server text in a localized UI. Without it, report the leak here tagged `security-class (owner pack not installed)`.
- **A cache key that omits the active locale**, which serves the previous language after a switch → `client-data-scan` §6 (this pack).
- **`<html lang>` as accessibility** (SC 3.1.1, axe `html-has-lang`) → `a11y-code-scan` grep 0d and `a11y-scan` (this pack). §7 here owns whether `lang` and `dir` follow the active locale. A site both report is one finding, credited to both.
- **`hreflang` reciprocity** → `seo-audit` and `@technical-seo` (this pack).
- **How an RTL layout should look** (mirrored motion, icon semantics, the visual language) → the ui-ux pack's `rtl.md` and `visual-check`'s RTL matrix row, when ui-ux is installed. §5 here owns whether the code can mirror at all.
- **Native screens** (Android, iOS, Flutter, Compose) → the mobile pack owns their layout and lifecycle. This skill owns their **catalogs** (§9) and carries a native lane for hard-coded text (grep 1f) and physical layout (grep 5e) until a mobile i18n detector ships.

## Adapt to the codebase — the i18n lanes

Read the library, the lint plugins **and their versions** from the lockfile, and print the lanes in the header. The rule ids and options below were read from the npm registry and package sources on 2026-10-05.

| Lane | Catalogs | Lint lane (keep only these `ruleId`s) | Missing-key hook for the oracle |
|---|---|---|---|
| **React / Next / RN** — i18next, react-i18next, next-intl | JSON namespaces | eslint-plugin-i18next **6.1.5** `i18next/no-literal-string`. The default mode `jsx-text-only` ignores attributes: run `{ mode: 'jsx-only', 'jsx-attributes': { include: ['alt','title','placeholder','aria-label'] } }` | `saveMissing: true` + a `missingKeyHandler` that throws in the test run; `i18next.exists(key, { lng })` |
| **formatjs / react-intl** | ICU JSON | eslint-plugin-formatjs **8.1.0**: `no-literal-string-in-jsx`, `prefer-full-sentence`, `enforce-plural-rules`, `no-invalid-icu`, `no-missing-icu-plural-one-placeholders`; `@formatjs/cli` 6.16.32 `compile` fails on bad ICU | the compile step |
| **Lingui** | PO / JSON | eslint-plugin-lingui **0.16.0**: `no-unlocalized-strings` (noisy until `ignoreFunctions` lists loggers), `no-single-variables-to-translate`, `no-expression-in-message`, `consistent-plural-format` | `lingui extract` + the catalog script |
| **Vue 3 / Nuxt** — vue-i18n, @nuxtjs/i18n | JSON / YAML / TS | @intlify/eslint-plugin-vue-i18n **4.5.1**: `no-raw-text`, `no-missing-keys`, `no-missing-keys-in-other-locales`, `no-unused-keys`, `no-dynamic-keys`, `valid-message-syntax`, `valid-plural-forms` (needs `settings['vue-i18n'].localeDir`); eslint-plugin-vue **10.11.1** `vue/no-bare-strings-in-template` (its default allow-list is punctuation only) | `missingWarn` + `fallbackWarn` turned into test failures |
| **Angular** — @angular/localize | XLIFF from `ng extract-i18n --format=xlf2` | @angular-eslint/eslint-plugin-template **22.5.0** `@angular-eslint/template/i18n` with `{ checkId: false, checkText: true, checkAttributes: true }`. Extraction sees only marked text, so unmarked text is invisible to it | the catalog script reads the XLIFF |
| **Svelte / Solid / Astro** — Paraglide, svelte-i18n | inlang messages / JSON | none mapped: rg only | Paraglide's typed `m.key()` makes a missing key a type error |
| **Styles** | — | stylelint 17.16.0 with stylelint-use-logical **2.1.3** `csstools/use-logical`, or stylelint-plugin-logical-css **2.1.0** `logical-css/require-logical-properties`, `-keywords`, `-units` (the 1.x `plugin/use-logical-*` names are gone); eslint-plugin-better-tailwindcss **4.8.0** `better-tailwindcss/enforce-logical-properties` (not in its recommended set) | — |
| **Native catalogs** | `strings.xml`, `.xcstrings`, `.strings`, ARB, PO | Android Lint `MissingTranslation`, `ExtraTranslation`, `StringFormatMatches`, `HardcodedText`, `RtlHardcoded`, `MissingQuantity`; Flutter `flutter gen-l10n --untranslated-messages-file` (flag unverified here: check `--help`) | the catalog script |

**`better-tailwindcss/enforce-logical-properties` rewrites more than direction.** Version 4.8.0 also maps vertical and size utilities (`mt-` → `mbs-`, `w-` → `inline-`, `h-` → `block-`, `top-` → `inset-bs-`). Those are writing-mode changes, irrelevant to RTL. Run it with the ignore list, or it buries the RTL rows:

```bash
# ROOTS as computed by the first line of § Pre-flight greps. Run only when the project's ESLint config loads the plugin;
# otherwise the lane is `blocked (plugin not installed)` — never install into the target repo, use a throwaway worktree.
npx --no eslint -f json --rule '{"i18next/no-literal-string":["error",{"mode":"jsx-only","jsx-attributes":{"include":["alt","title","placeholder","aria-label"]}}],"better-tailwindcss/enforce-logical-properties":["warn",{"ignore":["^(?:h|w|min-h|min-w|max-h|max-w|size|mt|mb|pt|pb|top|bottom|scroll-[mp][tb]|border-[tb])(?:-|$)"]}]}' "${ROOTS[@]}" > "${TMPDIR:-/tmp}/i18n-lint.json"
jq -r '.[] | .filePath as $f | .messages[] | select((.ruleId // "") | test("^(i18next/|formatjs/|lingui/|@intlify/vue-i18n/|vue/no-bare-strings|@angular-eslint/template/i18n|better-tailwindcss/enforce-logical)")) | "\($f):\(.line) \(.ruleId)"' "${TMPDIR:-/tmp}/i18n-lint.json" | sort | uniq -c | sort -rn | head -40
```

A lint lane that did not run is not a clean lane: §1 reads `checked (rg only; lint blocked: <reason>)`.

## The catalog script (§2, §9)

`scripts/catalog-parity.py`, beside this file (installed as `.claude/skills/i18n-scan/scripts/catalog-parity.py`). Python 3 standard library, read-only. Run it from the repo root:

```bash
python3 .claude/skills/i18n-scan/scripts/catalog-parity.py . --used="$(IFS=,; echo "${ROOTS[*]}")" --max=60
python3 .claude/skills/i18n-scan/scripts/catalog-parity.py --self-test
```

It finds every catalog whose path carries a locale token (JSON, YAML, TS/JS object catalogs, ARB, Android `values-*/strings.xml`, iOS `.lproj/*.strings` and `.xcstrings`, gettext PO, Angular XLIFF), groups the files that differ only by that token into **sets**, and prints the locales, the RTL locales, the base and the **gate**. Per set it reports `missing-file`, `missing-key`, `placeholder` (`{{n}}` vs `{n}`, `%1$s` vs `%s`, ICU arguments parsed so branch text is not mistaken for an argument), `plural-forms` (the categories `Intl.PluralRules` says each locale needs, via `node`), and `untranslated` (an empty value where another locale has one, a fuzzy or empty `msgstr`, an XLIFF target that is missing or `new`, an `.xcstrings` state other than `translated`). With `--used` it also reports `used-undefined` (a literal key that no catalog defines under any plausible namespace), and three candidate lists that each need a read: `dynamic-prefix` (including keys built on a string constant, `` `${KEY}.jt.${code}` ``, and `<fully dynamic>` heads), `unused-key` and `same-as-base`. Two finding shapes print as candidates instead: `placeholder-token` (a value that is one placeholder token in every locale, or a key read with `skipInterpolation`) and `untranslated-slot` (an empty value whose siblings share its stem — a word-order slot). Exit 1 on any finding row; candidates never set it.

**Cross-tree mode (`--v1=<laravel-root>`).** During a port from Laravel, `--v1` also reads V1's `lang/` and `resources/lang/` (`<locale>/**/*.php` arrays, `<locale>.json`; never written) and matches each V1 key to the V2 key of the same name, or the same name under the V2 file's stem, per locale. Findings: `v1-plural-forms` (a V1 pipe plural whose V2 key lacks a CLDR category the locale needs), `v1-plural-lost`, `v1-placeholder` (`:name` vs `{{name}}`); candidates: `v1-plural-range`, `v1-pipe-text`, `v1-unmapped`. The migration pack routes MG-I18N-1 (catalog and plural drift in a port) here, when the frontend pack is installed; its rows are graded under §2 (keys, placeholders) and §3 (plurals). A `--v1` with no `lang/` prints `v1 cross-tree: blocked`, never 0 rows.

i18next plural keys (`key_one` … `key_other`) fold into their base only when a locale has `<base>_other` **and** a second form or a `{{count}}`, so `errors.too_many` stays a key. A token that never shares a set with another locale (`team-hr.json` read as Croatian) is dropped with a note. Pass `--locales=` to keep one on purpose.

**The prefix × union sweep** (§2, dynamic keys). For each `dynamic-prefix` row: the prefix is the *supplied* set (catalog keys under it, which the script counts). Find what bounds the variable at its declaration — a TS union or enum, a `const` array, a DTO field, the API schema — and cite it as the *demanded* set. Supplied − demanded is **dead (dynamic)**, safe to propose for deletion now that the bound is cited. Demanded − supplied is **missing (dynamic)**: a key that exists in *no* locale, so parity is silent about it, and production renders the raw key for one value nobody clicked in QA. An unbounded variable (raw API text, tenant-supplied values) is `unresolvable: <prefix>` and needs a `defaultValue` or fallback at the call site. An unresolvable prefix with no fallback is a high finding. Report dynamic rows in their own block, never folded into the grep-complete ones.

**Measured** (2026-10-05) on a real bilingual SPA (React 19, Vite, i18next 26, Arabic default with English, about 4,700 web source files, plus a NestJS API with its own catalogs; read-only): 518 catalog files in 259 sets, locales `ar` and `en`, 0.4 s. The first version printed 262 `missing-file` rows from one false token (`team-hr.json` → `hr`) and 18 false `plural-forms` rows from key names ending `_many`, `_one` or `_other`. The directory-before-suffix order, the never-paired drop and the second-form rule removed all of them. The run then reports four `plural-forms` candidates, the shared catalog's `ar` count keys missing `zero` (read whether a count of 0 renders), one `untranslated` (an `en` value empty where `ar` has text), and nine `same-as-base` (provider and API terms). With `--used` over the web sources (13,332 literal and 1,351 dynamic calls, 2.5 s): `used-undefined` 4 → 0 once comment lines were skipped (all four were comments), 336 dynamic prefixes, and `unused-key` 1,707 → 276 once namespace mounts (`docs/<id>.json` mounted at `docs.<id>`), template prefixes and `` `${ns}:key` `` keys counted as references. A sample of 11 of the 276 had no reference outside comments. Re-measured 2026-10-15 on a real NestJS + React monorepo (React 19.2, i18next 26, Arabic default): the same command printed 509 `unused-key`, 41 % of them keys built on a string constant — `const KEY = 'ns:carrierReason'` then `` `${KEY}.jt.${code}` ``, and `` `ns:carrierCode.${family}.${code}` `` whose literal head carries a namespace — so `unused-key` was not "mostly real". The script now resolves both: 509 → 314 (195 credited, 0 newly reported, no change on the 20 benchmark fixtures), and a template whose head is a variable (`` `${group}.${key}.label` ``, 22 keys under a `'rules' | 'workflow'` union) prints as a `<fully dynamic>` `dynamic-prefix` row for the sweep instead of hiding. Its two finding rows became candidates: a `{{variable}}` / `{{متغير}}` display token read with `skipInterpolation` (`placeholder-token`) and an empty English middle slot between two hero-title halves (`untranslated-slot`); the exit code now comes only from four Arabic `zero` plural gaps. On a synthetic tree with one catalog per format, every planted row was reported and `values-night/`, `translatable="false"` and `shouldTranslate: false` were skipped.

## Pre-flight greps

Run as **one** script from the repo root (shell state does not survive between tool calls). Grep **N** feeds detector **§N**; grep 0 decides the gate. Every `rg` names its path: given no path and a piped stdin, `rg` reads stdin and reports zero hits. Files-without-match is `--files-without-match`, **never `-L`** (that is `--follow`). Any `rg` error on stderr (exit 2) is a failed grep, not zero hits. Run them only for the classes the gate turned on.

```bash
ROOTS=( $(rg -l --pcre2 '"(?:react|react-dom|react-native|vue|svelte|@angular/core|solid-js|preact|astro|next|nuxt|@builder\.io/qwik)"\s*:' -g '**/package.json' -g '!**/node_modules/**' . | xargs -n1 dirname | sort -u) ); [ ${#ROOTS[@]} -eq 0 ] && ROOTS=(.)
X=(-g '!**/node_modules/**' -g '!**/{dist,build,out,coverage,.next,.nuxt,.output,.svelte-kit,.angular,storybook-static,DerivedData,Pods,.dart_tool}/**' -g '!**/*.min.*'
   -g '!**/{test,tests,__tests__,spec,e2e,__mocks__,mocks,__fixtures__,fixtures,stories,playwright,cypress,scripts,tools,generated,__generated__}/**'
   -g '!**/*.{test,spec,e2e,stories,story,mock}.*' -g '!**/*.{test-data,fixture,fixtures}.*' -g '!ai/**' -g '!docs/**')
C=(-g '!**/{locales,locale,i18n,l10n,lang,translations}/**')      # catalogs and i18n plumbing: never a hard-coded-string site
UI=(-g '*.{js,jsx,ts,tsx,mjs,vue,svelte,html,astro}'); CSS=(-g '*.{css,scss,sass,less,vue,svelte,astro}')
NC=(rg -v --pcre2 '^[^:]+:\d+:\s*(?://|/?\*|<!--|#)')            # drop comment-only lines
# 0. The gate: 0a i18n libraries and lint plugins, 0b declared locales, 0c an in-house t() layer, 0d RTL shipped anywhere.
rg -n --no-heading '"(i18next|react-i18next|next-intl|next-i18next|vue-i18n|@nuxtjs/i18n|@angular/localize|@ngx-translate/core|@jsverse/transloco|@lingui/core|react-intl|@formatjs/intl|svelte-i18n|@inlang/paraglide-js|typesafe-i18n|i18n-js|expo-localization|react-native-localize|eslint-plugin-i18next|@intlify/eslint-plugin-vue-i18n|eslint-plugin-lingui|eslint-plugin-formatjs|@angular-eslint/eslint-plugin-template|eslint-plugin-vue|eslint-plugin-better-tailwindcss|stylelint-use-logical|stylelint-plugin-logical-css|pseudo-localization|tailwindcss)"\s*:' -g '**/package.json' -g '!**/node_modules/**' "${ROOTS[@]}"
rg -n --pcre2 '\b(?:supportedLngs|supportedLocales|SUPPORTED_(?:LANGS|LOCALES)|availableLocales|locales)\s*[:=]\s*\[|\b(?:defaultLocale|fallbackLng|sourceLocale)\s*:|createI18n\(|\bi18n\s*:\s*\{' "${UI[@]}" -g '*.json' -g '!**/package*.json' "${X[@]}" "${ROOTS[@]}" | head -20
rg -l --pcre2 '^\s*(?:export\s+)?(?:function\s+(?:t|translate|__)\s*\(|const\s+(?:t|translate)\s*=\s*(?:\(|function))' "${UI[@]}" "${X[@]}" "${ROOTS[@]}"
rg -n --pcre2 '\bdir\s*=\s*["\x27]rtl|\bdir\s*[:=]\s*["\x27]rtl|\bisRTL\b|forceRTL|supportsRtl|\brtl:' "${UI[@]}" -g '*.xml' "${X[@]}" "${ROOTS[@]}" | head -5
# 1. Hard-coded copy: 1a a text node on one line, text beside interpolations included (skips <Trans>-family children,
#    Angular i18n, and the code-like tags in the skip list — append the project's own code/LTR wrappers to it after the
#    first read), 1b a text node on its own line, 1c a user-facing attribute (skips alt="", Angular i18n-<attr> and
#    example formats: emails, URLs, values with digits), 1d a toast / alert / confirm / document.title literal with words
#    outside its ${…}, 1e a validation-schema message, 1f native text (Android XML, Compose, Flutter; SwiftUI Text("…") is a key),
#    1g an English label in a TS/JS label map (`{ value: 'manual', label: 'Manual' }`; bilingual rows with labelAr/nameAr skipped).
rg -n --pcre2 '^(?!.*<(?:Trans|T|FormattedMessage|i18n-t|Plural|Select)\b).*?<(?!(?:code|pre|kbd|samp|script|style|Code|CodeBlock|Kbd)\b)([A-Za-z][\w.-]*)(?![^>]*\bi18n\b)(?:\s[^<>]*)?>(?=(?:[^<>{}]|\{(?:[^{}]|\{[^{}]*\})*\})*?\p{L}{2})(?:[^<>{}]|\{(?:[^{}]|\{[^{}]*\})*\})+</\1>' "${UI[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n -U --pcre2 '(?<![=>-])>[ \t]*\n[ \t]*\K(?=[^<>{}\n]*\p{L}{2})(?![ \t]*(?://|/?\*|\}|\)|return\b|import\b|export\b|const\b|let\b|if\b|else\b))[^<>{}\n;=()]+(?=\n[ \t]*</?[A-Za-z{])' -g '*.{jsx,tsx,vue,svelte,html,astro}' "${X[@]}" "${C[@]}" "${ROOTS[@]}"
rg -n --pcre2 '(?<![:\[\w.-])(alt|title|placeholder|aria-label|aria-description|aria-placeholder|label)=(["\x27])(?![\w.+-]*@|https?:|[^"\x27\s]*\d[^"\x27\s]*\2)(?=[^"\x27{}]*\p{L}{2})[^"\x27{}]+\2(?![^>]*\bi18n-\1\b)' "${UI[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --pcre2 '\b(?:toast|notify|notification|enqueueSnackbar|snackbar|Swal\.fire|alert|confirm|prompt)(?:\s*\.\s*(?:success|error|info|warn(?:ing)?|show|open|loading|promise|message))?\s*\(\s*(["\x27\x60])(?=(?:[^"\x27\x60$]|\$(?!\{)|\$\{[^}]*\})*?\p{L}{2})|\bdocument\.title\s*=\s*(["\x27\x60])(?=[^"\x27\x60]*\p{L}{2})' "${UI[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --pcre2 '\b(?:message|required_error|invalid_type_error)\s*:\s*(["\x27\x60])(?=[^"\x27\x60]*\p{L}{2}[^"\x27\x60]*\s)|\.(?:min|max|length|email|url|uuid|regex|nonempty|required|matches|oneOf|typeError|refine)\((?:[^()"\x27\x60]*,\s*)?(["\x27\x60])(?=[^"\x27\x60]*\p{L}{2}[^"\x27\x60]*\s)' "${UI[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --pcre2 'android:(?:text|hint|contentDescription)="(?!@)[^"]*\p{L}{2}|\bText\(\s*"(?=[^"]*\p{L}{2})[^"]*"\s*[,)]|\bText\(\s*\x27(?=[^\x27]*\p{L}{2})' -g '*.{xml,kt,dart}' "${X[@]}" .
rg -n --pcre2 '\b(?:label|title|header|heading|placeholder|description|hint|tooltip|emptyText)\s*:\s*(["\x27\x60])[A-Z][a-z]+(?:[ -][A-Za-z]+)*[.:!?]?\1' -g '*.{ts,tsx,js,jsx,mjs}' "${X[@]}" "${C[@]}" "${ROOTS[@]}" | rg -v --pcre2 '\b(?:labelAr|nameAr|titleAr|_ar)\b|@Api\w*\(' | "${NC[@]}"
# 3. Plurals and concatenation: 3a a plural comparison picking words or t() calls, 3b a plural `s` suffix, 3c translated
#    fragments joined with + or two t() calls in one template literal.
rg -n --pcre2 '\b(?:count|length|total|n|num\w*|qty|quantity|size|\w+Count)\s*(?:[!=]==?\s*1|>\s*1|<=\s*1|>=\s*2)\s*\?\s*(?:(["\x27\x60])(?=[^"\x27\x60]*\p{L}{2})|(?:t|\$t)\()' "${UI[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --pcre2 '\?\s*(["\x27\x60])s\1\s*:\s*(["\x27\x60])\2|\?\s*(["\x27\x60])\3\s*:\s*(["\x27\x60])s\4|\}\s*\+\s*["\x27\x60]s["\x27\x60]' "${UI[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --pcre2 '\b(?:t|\$t|i18n\.t|translate|intl\.formatMessage)\([^()]*\)\s*\+\s*(?:["\x27\x60]|\w)|(?:["\x27\x60]|\w)\s*\+\s*(?:t|\$t|i18n\.t)\(\s*["\x27\x60]|\$\{\s*(?:t|\$t)\([^}]*\}[^\x60]*\$\{\s*(?:t|\$t)\(' "${UI[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 4. Presentation formatting: 4a locale-less or hard-locale date and number formatting (skips machine dates: padStart,
#    toISOString, YYYY-MM- templates, date arithmetic), 4b manual money and currency symbols.
rg -n --pcre2 '^(?!.*(?:padStart|toISOString|getFullYear\(\)\}-|getMonth\(\)\s*\+\s*1\s*,))(?:.*?)(?:\.toLocale(?:Date|Time)?String\(\s*\)|\.toLocale(?:Date|Time)?String\(\s*(?:undefined|["\x27][a-z]{2}(?:-[A-Z]{2})?["\x27])|new\s+Intl\.(?:DateTimeFormat|NumberFormat|RelativeTimeFormat|PluralRules|ListFormat|Collator|DisplayNames)\(\s*(?:\)|["\x27][a-z]{2}(?:-[A-Z]{2})?["\x27])|\.get(?:UTC)?Month\(\)\s*\+\s*1|\bformat\([^,()]+,\s*["\x27][dDMyYHhms/.: -]+["\x27]\s*\)|\bdayjs\([^)]*\)\.format\()' "${UI[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --pcre2 '(?<![\w$])[$€£¥₹]\$\{|(["\x27\x60])[$€£¥₹]\1\s*\+|\.toFixed\(2\)|\$\{[^}]+\}\s*(?:USD|EUR|SAR|AED|EGP|GBP|KWD)\b' "${UI[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 5. RTL: 5a physical CSS (skips [dir]/:dir() lines, rules whose selector — the nearest `{` line above — is under
#    [dir]/:dir(), and left/right pairs), 5a' a 4-value margin/padding/inset whose right
#    and left differ, 5b physical inline-style keys (skips four-sided box objects such as chart margins), 5c Tailwind
#    physical utilities in class contexts (skips rtl:/ltr:), 5d directional icons with no flip (skips two-way swap icons),
#    5e native layout (Android XML, Compose, Flutter, UIKit).
rg -n --pcre2 '^(?!.*(?:\[dir|:dir\(|\bleft\s*:[^;]*;\s*right\s*:|\bright\s*:[^;]*;\s*left\s*:))[^\n]*?(?:(?<![\w-])(?:margin|padding|border)-(?:left|right)(?:-\w+)?\s*:|(?<![\w-])(?:left|right)\s*:\s*(?!auto\b)|(?<![\w-])(?:text-align|float|clear)\s*:\s*(?:left|right)\b|(?<![\w-])border-(?:top|bottom)-(?:left|right)-radius\s*:|background-position\s*:\s*(?:left|right)\b)' "${CSS[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}" | while IFS=: read -r f n rest; do awk -v n="$n" 'NR<=n && /[{]/ {s=$0} NR>=n {exit (s ~ /\[dir|:dir\(/)}' "$f" && printf '%s:%s:%s\n' "$f" "$n" "$rest"; done
rg -n --pcre2 '^(?!.*(?:\[dir|:dir\())[^\n]*\b(?:margin|padding|inset)\s*:\s*(\S+)\s+(\S+)\s+(\S+)\s+(?!\2\s*[;!}])\S+\s*[;!}]' "${CSS[@]}" "${X[@]}" "${ROOTS[@]}"
rg -n --pcre2 '^(?!.*\btop\s*:.*\bbottom\s*:|.*\bleft\s*:[^;,]*[;,]\s*right\s*:)(?:.*?)(?:\b(?:margin|padding|border)(?:Left|Right)\w*\s*:|\b(?:textAlign|float)\s*:\s*["\x27](?:left|right)|\b(?:left|right)\s*:\s*-?\d)' -g '*.{jsx,tsx,js,ts}' "${X[@]}" "${C[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --pcre2 '(?:\bclass(?:Name)?\s*=\s*\{?\s*["\x27\x60]|\b(?:clsx|cn|cva|twMerge|twJoin|classnames|tw)\s*\((?:[^()"\x27\x60]|["\x27\x60][^"\x27\x60]*["\x27\x60])*?["\x27\x60]|\btw\x60)[^"\x27\x60]*?(?<![\w-])(?<!rtl:)(?<!ltr:)-?(?:m[lr]|p[lr]|left|right|scroll-[mp][lr]|border-[lr]|rounded-(?:[lr]|[tb][lr])|text-(?:left|right)|float-(?:left|right)|clear-(?:left|right))(?:-[\w./\[\]#%()-]+)?(?![\w-])' "${UI[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}"
rg -n --pcre2 '<(?!\w*(?:RightLeft|LeftRight))(?:\w*(?:Chevron|Arrow|Caret)(?:Right|Left|Forward|Back)\w*|\w*(?:Right|Left)Arrow\w*)\b(?![^>]*(?:rtl:|scale-x-|rotate-180|mirror|flip))' "${UI[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}"
rg -n --pcre2 'android:(?:layout_)?(?:margin|padding)(?:Left|Right)\b|android:(?:layout_)?gravity="(?:left|right)|absolute(?:Padding|Offset)\(|AbsoluteAlignment|Arrangement\.Absolute|TextAlign\.(?:Left|Right)\b|EdgeInsets\.(?:only|fromLTRB)\(|Alignment\.(?:centerLeft|centerRight|topLeft|topRight|bottomLeft|bottomRight)\b|Positioned\(\s*(?:left|right)\s*:|NSTextAlignment\.(?:left|right)|\.textAlignment\s*=\s*\.(?:left|right)|\.(?:left|right)Anchor\b' -g '*.{xml,kt,dart,swift}' "${X[@]}" .
# 6. Expansion: a fixed pixel width, and truncation or no-wrap on a line that renders a translated label with no title.
rg -n --pcre2 '\b(?:w|min-w|max-w)-\[\d{2,}px\]|(?<![\w-])(?:width|max-width)\s*:\s*\d{2,}px|^(?=.*(?:\{\s*t\(|\$t\(|\|\s*translate\b))(?!.*\btitle=)(?=.*\b(?:truncate|line-clamp-\d|whitespace-nowrap|text-ellipsis)\b)' "${UI[@]}" "${CSS[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 7. <html lang>/dir: 7a a hard-coded lang, 7b the sites that sync lang/dir (no output with an RTL locale declared is the finding).
rg -n --pcre2 '<html\b(?![^>]*\blang=\{)[^>]*\blang=["\x27][a-z]{2}|htmlAttrs\s*:\s*\{[^}]*lang\s*:\s*["\x27]' -g '*.{html,tsx,jsx,vue,svelte,astro,ts,js}' "${X[@]}" "${ROOTS[@]}"
rg -n --pcre2 'documentElement\.(?:dir|lang)\b|documentElement\.setAttribute\(\s*["\x27](?:dir|lang)|<html[^>]*\bdir=\{|:dir=|\[dir\]=|htmlAttrs[^}]*\bdir\b|useHead\([^)]*\bdir\b|<Html[^>]*\bdir=' "${UI[@]}" "${X[@]}" "${ROOTS[@]}"
# 8. Collation and casing on display text: a string sort with < or >, localeCompare with no locale (in a sort callback or
#    on a name-like field), locale-less case mapping, case-folded equality on a name-like field.
rg -n --pcre2 '\.sort\(\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s*=>\s*\1(?:\.\w+)+\s*[<>]|\.sort\(\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s*=>\s*\3(?:\??\.\w+)*\.localeCompare\(\s*\4(?:\??\.\w+)*\s*\)|\.(?:\w*[Nn]ame|title|label|city|country|description)\??\.localeCompare\(\s*[^,()]+\)|\.toLocale(?:Lower|Upper)Case\(\s*\)|\.(?:\w*[Nn]ame|title|label|city)(?:\.trim\(\))?\.toLowerCase\(\)\s*===' "${UI[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 10. Raw server or exception text rendered: 10a JSX, Vue/Angular interpolation, a toast fed the message; 10b the project's
#     error→text helpers (`notifyError(e, fallback)` → `apiErrorMessage` → `error.message`): read each once — one helper is
#     the app-wide surface the inline grep never sees.
rg -n --pcre2 '(?:\{\{?\s*|\(\s*)(?:e|err|error|ex|exception)\??\.(?:message|response\??\.data\??\.(?:message|error|detail))\s*\}|toast\.error\(\s*(?:e|err|error)\??\.message' "${UI[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --pcre2 '(?:export\s+)?(?:async\s+)?(?:function\s+\w*(?:[Ee]rror[Mm]essage|[Nn]otify[Ee]rror|[Tt]oast[Ee]rror|[Ss]how[Ee]rror|[Ee]rror[Tt]ext)\w*\s*[(<]|const\s+\w*(?:[Ee]rror[Mm]essage|[Nn]otify[Ee]rror|[Tt]oast[Ee]rror|[Ss]how[Ee]rror|[Ee]rror[Tt]ext)\w*\s*=\s*(?:async\s*)?\()' "${UI[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 11. Bidi isolation: BW = the project's isolation wrappers (an exported component that renders {children} inside <bdi> or
#     dir="auto", e.g. `<Ltr>`), counted as isolation in 11a and 11c; 11a a user field beside other text on one line
#     (alone in its element it is isolated already), 11b a user field passed into a translated sentence, 11c the
#     isolation sites per file (no output = 0).
BW=$(rg -o -U --no-filename --pcre2 'export\s+(?:default\s+)?(?:function\s+([A-Z]\w*)|const\s+([A-Z]\w*)\s*=)[^\n]*\n(?:(?![ \t]*export\s)[^\n]*\n){0,30}?[^\n]*<(?:bdi\b|\w+\b[^>]*\bdir=["\x27{]+auto)[^>]*>\s*\{\s*(?:props\.)?children\s*\}' -r '$1$2' "${UI[@]}" "${X[@]}" "${ROOTS[@]}" | sort -u | paste -sd'|' -); echo "11 isolation wrappers: ${BW:-none}"
rg -n --pcre2 '(?:\p{L}[^<>{}\n=]*|\}\s*)\{\{?\s*\w+\??\.(?:name|fullName|firstName|lastName|title|comment|description|username|email|displayName|phone)\s*\}\}?|\{\{?\s*\w+\??\.(?:name|fullName|firstName|lastName|title|comment|description|username|email|displayName|phone)\s*\}\}?(?:\s*\{|[^<>{}\n]*\p{L})' -g '*.{jsx,tsx,vue,svelte,html,astro}' "${X[@]}" "${C[@]}" "${ROOTS[@]}" | rg -v --pcre2 '<bdi|dir="auto"|unicode-bidi|\b(?:key|alt|src|href|value|id)=\{'"${BW:+|<(?:$BW)\\b}" | "${NC[@]}"
rg -n --pcre2 '\b(?:t|\$t|i18n\.t)\(\s*["\x27\x60][^"\x27\x60]+["\x27\x60]\s*,\s*\{[^}]*\b\w+\s*:\s*\w+\??\.(?:name|fullName|firstName|lastName|title|username|displayName)\b' "${UI[@]}" "${X[@]}" "${C[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -c --pcre2 '<bdi\b|dir="auto"|dir=\{["\x27]auto|unicode-bidi\s*:\s*isolate'"${BW:+|<(?:$BW)\\b}" "${UI[@]}" "${CSS[@]}" "${X[@]}" "${ROOTS[@]}"
```

Grep 2 and grep 9 are the catalog script. A finding is a hit **plus** a read of the component **plus** the route that renders it **plus** the oracle. Four blind spots are structural:

- **A label map hides the copy.** `const LABELS = { open: 'Open' }` rendered three files away is what 1g lists (one capitalised English word or phrase as a `label` / `title` / `hint` value); a lower-case value, a default prop `label = 'Submit'` and an `<option>` list built from an array stay invisible. The pseudo-locale walk sees all of them, and so does the i18next lint in `mode: 'all'`, at the price of noise.
- **A wrapper decides the direction.** A design-system `Stack` with a physical `gap`/`margin` prop, or a chart library's internal layout, is invisible to 5a–5c. Only the RTL mirror render sees it.
- **A dynamic key is invisible to parity.** Every `dynamic-prefix` row needs the prefix × union sweep, or §2 reads `checked (static keys only)`.
- **Text built outside the component** (an email or push template, a PDF or print template string, `confirm()` built in a hook) reaches the user with no JSX around it. Read the print and notification modules by name.

**Measured** (2026-10-05). On this batch's research fixtures (positives and decoys in React, Vue, Svelte and Angular): 1a 5/5 and 0/9 decoys (`<Trans>` children, `<code>`, Angular `i18n`, `{t('x')}` alone), 1c 5/5 (`alt=""`, `:placeholder`, `alt={m.x()}`, `i18n-title` skipped), 1d 2/2 (`console.error`, `logger.warn` skipped), 3a 2/2, 3c 2/2, 4a 4/4 (`toLocaleDateString(locale)`, `Intl.NumberFormat(locale, …)` skipped), 4b 1/1, 5a 6/6 (`[dir="rtl"]`, `margin-inline-start`, `text-align: start`, `margin: 0 auto`, `top: 0` skipped), 5b 1/1, 5c 6/6 (`rtl:mr-4 ltr:ml-4`, `ms-4 pe-2` skipped), 5d 1/1 (`rtl:rotate-180` skipped), 7a 1/1, 8 2/2, 10 4/4; on a second fixture 1b 2/2 (an Arabic line included), 1e 2/2 (a `t()` message skipped), 3b 1/1, 5a′ 2/2 (`4px 8px 4px 8px` skipped), 11a 1/1 (`<bdi>` skipped), 1f 3/3 and 5e 5/5 across Android XML, Compose, Flutter and UIKit (`@string/`, `EdgeInsets.symmetric`, `TextAlign.Start` skipped). `benchmarks/fixtures/web-dashboard`: 0c finds the in-house `t()` (gate CATALOG-ONLY), 1a returns WEB-I18N-01, 4b returns WEB-I18N-02, 5b returns the six inline `textAlign` sites. On the real bilingual SPA above the script runs in about 1 s. 1a returned 103 lines: 52 inside the project's own LTR wrapper on design-system documentation pages (append it to the skip list), 16 in print templates (read whether the print is locale-bound), and the rest mostly English labels in an Arabic-default UI (`Client ID`, `POS Serial`, `Type:`, `SKU:`) plus six brand or standard names. 1c 45 (example-format placeholders were the noise until the digit/email/URL exclusion), 1d 7 (three English toasts), 3a 4 (2 true: a binary plural in code), 3c 28 (labels assembled from two `t()` calls), 4a 2, 4b 9, 5a 1 (a centering `left: 50%`), 5b 2, 5c 2, 5d 0 after the swap-icon exclusion (it was 18, every one an `ArrowRightLeft` or `ArrowLeftRight`), 7a 1 and 7b 2 (`lang`/`dir` synced on switch), 8 23 (names sorted with no locale, beside ISO-date and code sorts), 10 9 (`error.message || t(…)`: read whether the API localizes by `Accept-Language`), 11a 13, 11b 27, 11c 27 files. Re-measured 2026-10-15 on a real NestJS + React monorepo (the same Arabic-default i18next 26 web app family): 1b 9 → 8 (a `*.test-data.tsx` builder, now excluded), new 1g 28 lines → three real groups (an English label map on a POS order card, `'Manual'` / `'Other'` in a geography source list, the WhatsApp template-variable labels) beside brand and theme names, 5a 2 → 1 (a `text-align: right` whose `[dir='rtl']` selector sits on the line above), new 10b 5 helpers — `notifyError(error, fallback)` → `apiErrorMessage` → `error.message` is the app-wide surface that 10a's single inline hit never showed, 11c 24 → 268 files once the project's `<Ltr>` isolation wrapper (a `<bdi dir="ltr">`) counts; 11a 17 → 17.

## The 3,000-hit path

1. **Intersect with the route table.** A component no route imports is dismissed with one read. Design-system documentation, Storybook-only and admin/dev routes the product declares single-language form their own group.
2. **Group.** One group = (component or shared wrapper, class) for code hits, and (catalog set, class) for script rows. A wrapper that hard-codes its label, or a namespace missing one locale, is one group however many call sites or keys it has. Fixing it there closes them all.
3. **Rank** by route traffic: RUM or analytics when present, else landing, auth, checkout-class, list and form routes. Rank error and empty states up: they surface when the user is already stuck.
4. **Read the top 30 groups.**
5. **Prefer the runtime bulk oracle at scale.** One pseudo-locale walk across the route matrix confirms or refutes every §1 group at once: a hit that never renders is dead code (Refuted), and a rendered unaccented string the greps missed is a new finding. One missing-key run over the e2e suite does the same for §2. One RTL mirror pass over the top routes does it for §5.
6. **Ledger the rest** `Live, unreviewed (k groups / n hits)` with the command that continues them. Never `checked`.

## Scans for

Severity assumes the gate is ON and the route ships to the affected locale. Under CATALOG-ONLY, drop one step.

| § | Class | Research id | Default severity |
|---|---|---|---|
| 1 | Hard-coded copy: text nodes, user-facing attributes, toasts, confirms, titles, validation messages, native text | I-01, I-02, I-03 | high in new code on a translated route, and for an accessible name; medium in existing code |
| 2 | Catalog defects: missing file or key, key used but undefined, dynamic key with no bound, placeholder mismatch, invalid ICU, unused key | I-07, I-16 | high for a key missing in a shipped locale or a placeholder mismatch (a runtime error or a raw key); medium for an unbounded dynamic key; low for unused |
| 3 | Plural logic in code, concatenated fragments, a plural message missing a CLDR category | I-05, I-06 | high when Arabic or a Slavic locale ships; medium otherwise |
| 4 | Presentation formatting with no locale or a hard-coded one; manual money and currency symbols | I-08, I-09 | medium; high when it also renders during SSR (cross-link `ssr-audit` §1) |
| 5 | Physical CSS, inline styles, Tailwind utilities and native layout; directional icons that do not mirror | I-11, I-12, I-13 | medium; high on an RTL-primary product; low for icons |
| 6 | Text expansion clipped: fixed widths, truncation with no full value | I-17 | medium |
| 7 | `<html lang>` / `dir` hard-coded or not switched with the locale | I-14 | high when multi-locale |
| 8 | Locale-insensitive collation and case mapping on display text | I-10 | low; medium on a list users scan by name |
| 9 | Native catalogs: Android `strings.xml`, iOS String Catalog and `.strings`, Flutter ARB | — | as §2 |
| 10 | Raw server or exception text rendered to the user | I-04 | high |
| 11 | User content with no bidi isolation | I-15 | low; medium when names, phone numbers or amounts sit inside RTL sentences |

### 1. Hard-coded copy

```
BAD:   <Label>Client ID</Label>   ·   <input placeholder="Search orders" />   ·   toast.success('Marked as paid')
       z.string().email('Enter a valid email')   ·   <h2>Order summary</h2> (Vue)   ·   Text("Order summary") (Compose)
GOOD:  <Label>{t('integrations.clientId')}</Label>   ·   placeholder={t('orders.search')}   ·   toast.success(t('referrals.paid'))
       z.string().email({ message: t('validation.email') }), or the library's i18n error map   ·   {{ $t('order.summary') }}
       stringResource(R.string.summary)   ·   SwiftUI Text("order.summary") is already a localized key
```
**Read the code:** Is the string copy, or data (a SKU, a code, a user value, an API enum shown as a badge)? Is it a brand, product or standard name the project lists as untranslated? Does a `<Trans>` or `i18n` attribute wrap it a level up? Is the route translated, or declared single-language? For an attribute: is it the accessible name (`aria-label`, `alt`, an icon button's `title`)? Then a screen-reader user in the other locale hears the wrong language. **Finding when** the copy renders on a translated route. **Proof:** the pseudo-locale walk: the string renders with no pseudo marks ([`proof-oracles.md`](../../../../snippets/proof-oracles.md) § "Pseudo-locale and RTL mirror", V111). A toast or validation message needs its failure injected first (`page.route` → 500, an invalid submit).

### 2. Catalog defects

```
BAD:   locales/ar.json lacks products.form.name_placeholder   ·   en "{{count}} items" vs ar "{count} عناصر"
       t(`status.${s}`) with no bound on s   ·   t('orders.card.invalid_status') defined in no catalog
GOOD:  every shipped locale has every key (fallback to the base only where the product declares it)
       the placeholder spelled the same in every locale   ·   a typed key map: satisfies Record<Status, Key>
```
**Read the code:** For `missing-key`: does the library fall back (`fallbackLng`), and does the product declare that fallback acceptable for this namespace? A fallback to another language is still the wrong language. For `placeholder`: does the library throw on a missing variable or print the raw token? For `used-undefined`: is the key built at runtime under a namespace the script could not see (`addResourceBundle`, a glob loader)? Read the loader once. For each `dynamic-prefix`, run the prefix × union sweep (§ The catalog script). **Finding when** a shipped locale renders a raw key, a raw placeholder or the base language. **Proof:** a missing-key run: the e2e or component suite under each locale with a missing-key handler that throws ([`proof-oracles.md`](../../../../snippets/proof-oracles.md) § "Pseudo-locale and RTL mirror"); for a single key, `i18next.exists(key, { lng })` or the library's equivalent in a unit test, red before the catalog fix.

### 3. Plurals and concatenation

```
BAD:   {count === 1 ? t('order') : t('orders')}   ·   `${n} item${n === 1 ? '' : 's'}`   ·   t('hello') + ' ' + user.name
       `${t('store')} (${t('optional')})`        ·   ar plural with only one/other
GOOD:  t('orders', { count })  with  orders_zero … orders_other  (i18next)  or  {count, plural, …}  (ICU)
       t('greeting', { name })  — one message, the translator orders the words   ·   Intl.ListFormat for joined lists
```
**Read the code:** Does the ternary pick words or a visual state (`count === 0 ? <Empty/> : <List/>` is an empty state, not a plural)? Is the concatenation joining sentence fragments, or two separate UI elements laid out by CSS? Does the target locale's catalog carry every category `Intl.PluralRules(locale).resolvedOptions().pluralCategories` names (Arabic: zero, one, two, few, many, other)? Can the count be 0 on this screen? **Finding when** a shipped locale renders a wrong form or a reordered sentence. **Proof:** the plural matrix: render with n ∈ {0, 1, 2, 3, 11, 100, 101} under `ar`, one assertion per CLDR category; for concatenation, the pseudo-locale `bidi` strategy plus an `ar` screenshot.

### 4. Presentation formatting

```
BAD:   new Date(d).toLocaleDateString()  ·  amount.toLocaleString('en-US')  ·  `$${price.toFixed(2)}`  ·  `${x} USD`
       format(d, 'MM/dd/yyyy') (date-fns, no locale)  ·  new Intl.NumberFormat()
GOOD:  new Intl.DateTimeFormat(locale, opts)  ·  new Intl.NumberFormat(locale, { style: 'currency', currency })
       a project formatDate()/formatMoney() that injects the active locale — read it once; its call sites are then clean
```
**Read the code:** Is the output shown to a user, or sent to an API, a log, a file name or an `<input type="date">` (machine format: not a finding)? Is the hard-coded locale a product decision (a WhatsApp message in one market's format)? Is the currency fixed by the product (single-currency, single-locale)? Does the code run during SSR? Then the server's locale and zone differ from the browser's: cross-link `ssr-audit` §1. **Finding when** a user in a shipped locale sees another locale's format, digits or currency placement. **Proof:** the locale format matrix: the same fixture value under `test.use({ locale: 'ar-SA', timezoneId })` and `en-US`, asserted against `Intl` output for that locale; money across three locales × three currencies including JPY (0 decimals) and KWD (3).

### 5. RTL: physical layout and directional icons

```
BAD:   .title { margin-left: 12px }  ·  style={{ textAlign: 'left' }}  ·  className="ml-4 text-left rounded-l-lg"
       margin: 0 8px 0 0  ·  <ChevronRight />  ·  android:layout_marginLeft  ·  EdgeInsets.only(left: 8)
GOOD:  margin-inline-start  ·  textAlign: 'start'  ·  ms-4 text-start rounded-s-lg  ·  margin-inline-end: 8px
       <ChevronRight className="rtl:rotate-180" />  ·  layout_marginStart  ·  EdgeInsetsDirectional.only(start: 8)
```
**Read the code:** Is the pair symmetric (`left: 0; right: 0`, `mx-`, `left-1/2 -translate-x-1/2` centering)? Is it inside a `[dir]`, `:dir()`, `rtl:` or `ltr:` override — that is the mirroring? Must the content stay LTR (code, phone numbers, charts, maps, media timelines)? Is the property vertical (`top`, `mt-`, `h-`)? On React Native, `marginLeft` auto-swaps under RTL by default, so it is low at most; transforms, `textAlign: 'left'` and icons do not swap. On Tailwind v3, `space-x-*` emits physical margins; check the major before reporting it. Is the icon directional (back, forward, next), or one that must not mirror (play, clock, check, a two-way swap)? **Finding when** the RTL render puts the element on the wrong side. **Proof:** the RTL mirror: switch to the RTL locale through the app's own switcher, assert `html[dir="rtl"]`, and compare bounding boxes with the LTR render: the x-order of the pair inverts (V112).

### 6. Text expansion

```
BAD:   <Button className="w-[110px]">{t('save')}</Button>   ·   <span className="truncate">{t('roles.auditor')}</span>
GOOD:  min-w-* with padding  ·  truncation with the full value in a title or tooltip  ·  wrapping allowed
```
**Read the code:** Does the element hold translated text, or a number or a code? Is the truncation intentional, with the full value reachable? **Finding when** a translated label clips at the product's smallest viewport. **Proof:** the pseudo-locale `accented` strategy (about +35–40 % length) at 320 px: no labelled element has `scrollWidth > clientWidth + 1` under hidden overflow (V111). A brand mark or a code-like value that clips only there is not a finding (`precedents/ui.md` § 47).

### 7. `<html lang>` and `dir`

```
BAD:   <html lang="en"> in index.html of a bilingual app, never updated   ·   an RTL locale declared and no dir binding anywhere
GOOD:  document.documentElement.lang = lang; document.documentElement.dir = dir on every switch   ·   <html lang={locale} dir={dir}>
```
**Read the code:** Does the static `index.html` value match the default locale, and does a switch update both attributes (7b)? Is the SPA switch covered as well as the first load? A per-subtree `lang` or `dir` on a quote or a code block is correct. **Finding when** the active locale and the document attributes disagree after load or after a switch. **Proof:** the locale switch: switch through the app's own control; `expect(page.locator('html')).toHaveAttribute('lang', /^ar/)` and `dir="rtl"`, on first load and after a client-side switch.

### 8. Collation and casing

```
BAD:   cities.sort((a, b) => a.localeCompare(b))   ·   rows.sort((a, b) => a.name > b.name ? 1 : -1)   ·   name.toLowerCase() === q
GOOD:  new Intl.Collator(locale).compare  ·  a.localeCompare(b, locale)  ·  toLocaleLowerCase(locale) on user text
```
**Read the code:** Is the sorted value display text (names, cities, product titles), or an id, an enum code, an ISO date (sorting those by code unit is correct)? Is the case-folded value user text or an ASCII protocol token (an HTTP header, a file extension, a status code)? **Finding when** a list users scan by name orders wrongly in a shipped locale, or a Turkish dotted/dotless `i` breaks a match. **Proof:** a unit test: `['Ä', 'Z', 'A']` sorted under `de` and `sv` compared with `new Intl.Collator(locale).compare`; for Arabic, a list with and without diacritics.

### 9. Native catalogs

```
BAD:   res/values-ar/strings.xml lacks a key of res/values/strings.xml   ·   a plurals entry with only one/other for ar
       an .xcstrings entry `needs_review` or `new` for a shipped language   ·   app_ar.arb missing a message of app_en.arb
GOOD:  every shipped locale complete; translatable="false" or shouldTranslate: false on what must not be translated
```
**Read the code:** Is the language listed as shipped (Android `localeConfig` / `resConfigs`, Xcode `knownRegions`, Flutter `supportedLocales`)? Is the entry marked non-translatable? **Finding when** a shipped language falls back to the source for a string a user sees. **Proof:** the catalog script row is the static fact; Android Lint `MissingTranslation` / `MissingQuantity` at error level, or a Flutter build whose untranslated-messages report is empty, is the gate.

### 10. Raw server or exception text

```
BAD:   <p>{err.message}</p>   ·   toast.error(error.message || t('saveFailed'))   ·   {{ error.message }}
GOOD:  t(`errors.${code}`, { defaultValue: t('errors.generic') }) with the code list enumerated from the API contract
```
**Read the code:** Start with the helpers 10b lists: a `notifyError(error, fallback)` that shows `apiErrorMessage(error)` — the server's `message` when there is one — is one root cause with every call site behind it. Does the API localize its messages by `Accept-Language`, and does the client send it? Then the text is already in the user's language: read one handler to confirm, and the row is Hardening. Is the message a code the client maps? **Finding when** a localized UI shows server or runtime text in another language. **Proof:** the server-text sentinel: `page.route` answers 4xx/5xx with the message `ZZ_SERVER_TEXT`; under `ar` the sentinel must not be visible.

### 11. Bidi isolation

```
BAD:   <span>{t('by')} {user.name} (2)</span>   ·   t('revokeDesc', { name: user.username }) shown in an Arabic sentence
GOOD:  <bdi>{user.name}</bdi>  ·  <span dir="auto">{comment}</span>  ·  unicode-bidi: isolate on the interpolation's element
```
**Read the code:** Does user-supplied or API-supplied text sit inside a sentence or beside numbers and punctuation in another direction? Does the library isolate interpolations already? ICU MessageFormat and `Intl` do not by default. **Finding when** the mixed-direction render reorders the punctuation or digits. **Proof:** the bidi isolation fixture: render the user name `"Ali (CEO) 2"` inside the Arabic sentence; assert the visual order of the parenthesis and digit with `Range.getClientRects()` or a screenshot.

## Proof

The oracle per class, failing first and green after the fix ([`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Accessibility and UI states):

1. **Pseudo-locale walk** (§1, §3 concatenation, §6) — § "Pseudo-locale and RTL mirror", V111 and V132. Wrap every catalog **value** with `pseudoLocalizeString(v, { strategy: 'accented' | 'bidi' })` (pseudo-localization 3.1.3), skipping ICU placeholders and tags. Never `PseudoLocalizeDom.start()`: it rewrites hard-coded text too, so it cannot tell the two apart. Walk the visible text nodes and `[alt], [title], [placeholder], [aria-label]` values: a node with two or more Latin letters and no pseudo marks is hard-coded unless it is seeded data.
2. **RTL mirror** (§5, §7) — same section, V112 and V136.
3. **Missing-key run** (§2, §9) — the suite under every shipped locale with `missingKeyHandler` (i18next), `missingWarn`/`fallbackWarn` (vue-i18n) or the compile step (formatjs, Angular) turned into a failure — *Missing-key run* under `### i18n oracles`, V133.
4. **Plural matrix** (§3, V134), **locale format matrix** (§4, V135), **locale switch** (§7, V137), **collation order** (§8, V138), **server-text sentinel** (§10, V139), **bidi isolation** (§11, V140) — `### i18n oracles` in the same section; the per-§ shape above names the fixture.

A static fact needs no runtime oracle only when the source is the whole claim: a key absent from a shipped locale's catalog, a placeholder spelled differently, `<html lang="en">` never updated on a bilingual shell. Every other class needs its oracle to fire. Without a harness the candidate is Unconfirmed with the test written out.

## Output

```
i18n-scan — gate ON: ar, en (ar RTL, default ar); i18next 26.4 + react-i18next 17.0 (apps/web/package.json);
lint lane: blocked (eslint-plugin-i18next not installed) — §1 is rg + pseudo-locale only; catalogs: 518 JSON in 259 sets

Findings: 2 · Unconfirmed: 3

1. src/features/integrations/components/CredentialsDialog.tsx:100                  [report-with-fix]   high
   §1 hard-coded label — <Label>Client ID</Label> and <Label>Client Secret</Label> on /settings/integrations,
   an Arabic-default route. Proof: pseudo-locale walk — both labels render with no pseudo marks (fails).
   Fix: t('integrations.clientId') / t('integrations.clientSecret'); keys added to ar and en.
2. src/shared/i18n/locales/{L}.json :: common.bulkActionBar.selected               [report-with-fix]   high
   §3 plural category missing — ar has one, two, few, many, other; Intl.PluralRules('ar') also needs zero, and the
   bar renders at count 0 after a deselect-all. Proof: plural matrix n=0 under ar renders the raw key (fails).
   Fix: add selected_zero to ar.
3. src/features/delivery/model/use-delivery-settings.ts:121                          [report-flagged]    high
   §10 server text — toast.error(error.message || t('settings.saveFailed')). Unproven: whether the API localizes by
   Accept-Language. Settling probe: 500 with ZZ_SERVER_TEXT under ar; the sentinel must not show.

Verification: 41 candidates → 2 findings · 3 unconfirmed · 9 hardening · 27 refuted

Class coverage:
  §1  hard-coded copy          checked (rg only; lint blocked) (162 hits / 38 groups → 1 finding; 52 in an LTR docs wrapper refuted by one read) · Live, unreviewed (24 groups / 61 hits — continue with grep 1a on src/features/finance)
  §2  catalog defects          checked (script: 0 missing, 0 placeholder; 336 dynamic prefixes → top 30 swept, 0 missing (dynamic)) · Live, unreviewed (306 prefixes — continue with the prefix × union sweep)
  §3  plurals / concatenation  checked (32 hits + 4 script rows → 1 finding, 1 unconfirmed)
  §4  formatting               checked (11 hits → 0 findings; 9 machine or value-only toFixed)
  §5  RTL layout               checked (5 hits → 0 findings; 1 centering, 2 print-only)
  §6  expansion                blocked (no harness for the pseudo-locale render) (40 hits)
  §7  lang / dir               checked (static lang="ar" matches the default; dir and lang synced on switch)
  §8  collation                checked (23 hits → 1 unconfirmed: city names sorted with no locale)
  §9  native catalogs          N/A (no Android, iOS or Flutter catalogs)
  §10 server text              checked (9 hits / 1 group → 1 unconfirmed)
  §11 bidi isolation           checked (40 hits → 0 findings, 4 hardening)
```

`[report-with-fix]`: the site renders on a cited route in a shipped locale, the oracle failed, and the fix is a local change in the project's i18n library. `[report-flagged]`: one link is unproven (no harness, a loader not read, a server contract not checked), and the row names that link and the settling test. The coverage block lists **all eleven** classes on every run: `checked (k hits → m findings)` only when the greps or the script ran; `N/A (<reason>)` when the gate or the stack has no such surface; `blocked (<reason>)` when a grep, the lint lane or the render failed. A partial review reads `Live, unreviewed (k groups / n hits)`, never `checked`.

## False positives / precedents

The verifier reads [`precedents/ui.md`](../../../../snippets/precedents/ui.md) (§ 47–48 and i18n § 76–83 for this skill) and [`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 4 and § 10. The ones this skill meets most:

- **A single-locale product with no i18n library**: every row `N/A`, unless the brief names a second market (§ 76).
- **Not copy:** text in `<code>`, `<pre>`, `<kbd>` and the project's code or LTR wrappers; `<Trans>`-family children; Angular `i18n`-marked elements; `alt=""` on a decorative image; brand, product and standard names on a documented allow-list; units and symbols; data (user content, API values, SKUs); example-format placeholders (`john@example.com`, `https://…`, `01xxxxxxxxx`); developer text (`console.*`, logger calls, `throw new Error` that never renders); routes the product declares single-language (§ 77).
- **Machine formats:** `toISOString()`, `YYYY-MM-DD` built for an input or an API, `toFixed` on a non-money value, sorting ids, enum codes or ISO dates, `toLowerCase()` on ASCII protocol tokens (§ 78).
- **A project wrapper owns the locale:** a `formatDate()`, `formatMoney()` or `Trans` wrapper that injects the active locale. Read it once; its call sites are clean (§ 79).
- **RTL:** symmetric pairs; `[dir]`, `:dir()`, `rtl:` and `ltr:` overrides (`precedents/ui.md` § 48); content that must stay LTR (code, phone numbers, charts, maps, media timelines); vertical properties; React Native `marginLeft` under the default auto-swap; icons that must not mirror (play, clock, check, two-way swap) (§ 80, 83).
- **A server that localizes** its messages by `Accept-Language`, with the client sending the header: §10 is Hardening (§ 81).
- **SwiftUI `Text("literal")`** is a `LocalizedStringKey`; only `Text(verbatim:)` and `Text(stringVariable)` are hard-coded (§ 82).
- **Pseudo-locale-only clipping** of a brand mark or a code-like value (`precedents/ui.md` § 47).
- **A word-order slot:** an empty value in one locale whose siblings form one sentence (`heroTitleA` / `heroTitleMid` / `heroTitleB`) — `untranslated-slot` (`precedents/ui.md` § 106).
- **A display token:** a value that is one placeholder token in every locale, or a key read with `skipInterpolation` — `placeholder-token` (`precedents/ui.md` § 107).

## When to run

- On a diff that adds or changes user-visible text, a catalog, a format call, a sort over display text, a style with a horizontal property, an icon, or the document shell: the `/frontend-audit` dispatch signal when that command is present.
- On every whole-repo `/frontend-audit` and `/i18n-audit` (its preset), reporting all eleven classes.
- When a translator delivers a locale, before a new locale is promoted, and when a second market is first named in a brief (the gate turns ON).

## Halt conditions

- Do not report a high row as confirmed until a verifier other than the run that found it has passed it through [`refute-before-report.md`](../../../../snippets/refute-before-report.md). Under `/frontend-audit` or `/i18n-audit` that happens in their verification phase; run standalone, apply the protocol as a separate second pass, tag those rows `self-verified`, and list the rest as Unconfirmed, Hardening, Pre-existing or Refuted. Never drop them.
- Halt on any finding without the site `<file:line>` (or set, key and locale), the route or screen that renders it, the oracle, and the fix in the project's library.
- Do not mark a finding `[report-with-fix]` without an oracle that failed first; a static-fact row needs its two-ended trace.
- Do not print the gate as OFF while 0a or 0c returned an i18n layer, and do not print "no i18n defects" without the class-coverage block. A class whose grep, script or render did not run is `blocked`, never `checked`.
- Do not propose a fix that hides the symptom: a machine translation shipped as copy (copy the base value with a `TODO: translate` marker instead), a plural collapsed to `one`/`other` for a locale that needs more, an `eslint-disable` line, `dir="ltr"` forced on a whole screen, a key deleted because a grep found no literal use while a dynamic prefix covers it.
- Do not use `rg -L` for files-without-match; it is `--follow`.
- Do not re-derive a class another skill owns (§ Boundaries). Cross-link it.

## Related

- `@i18n-auditor` (this pack) — the lens over this skill: judges its rows, ranks them and runs the catalog script on a diff. `/i18n-audit` — the preset of `/frontend-audit --classes=i18n` that dispatches this skill.
- `ssr-audit` (this pack) — locale-dependent hydration and per-request language on the server. `a11y-code-scan` and `a11y-scan` (this pack) — `lang` as accessibility. `client-data-scan` §6 (this pack) — the locale in a cache key. `seo-audit` and `@technical-seo` (this pack) — `hreflang`.
- code-quality `correctness-smell-scan` §6 (when installed) — clock and zone arithmetic. Security `data-exposure-scan` §1 (when installed) — the leak in raw server text.
- `visual-check` (this pack) — the RTL row of the visual matrix. ui-ux `rtl.md` (when installed) — the RTL vocabulary.
- `rules/i18n.md` — how many locales the code assumes; `ai/patterns/i18n.md` — key architecture, ICU plurals, `Intl` formatting and namespace loading, the shapes the fixes take.

Files in this skill

  • SKILL.md59.2 KB
  • scripts/catalog-parity.py61.3 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.