Skip to main content
Urbicon UI

Auditing & Quality

Three complementary layers catch i18n problems before they ship: a data-level parity audit you assert in a test, a runtime sink that flags raw-key renders, and a source scan for unused keys and hardcoded strings. The first two are dependency-free; the scanner and CLI front them for whole-project checks.

Three layers

A key-based i18n system has three distinct failure modes, and one tool rarely covers all three. A locale can fall behind the base (a key missing, empty, or with the wrong interpolation params). A defined key can become dead after a rename. And UI copy can bypass i18n entirely as a literal string. Each needs a different lens.

Where each layer lives

  • Data-level audit (auditTranslations) and the runtime sink (onMissingKey) ship from @urbicon-ui/i18n — dependency-free, run them in a Vitest test.
  • The source scanner is the dev-only @urbicon-ui/i18n/audit subpath (typescript + svelte as optional, lazily-imported peers).
  • urbicon i18n (in @urbicon-ui/design) is the filesystem front end over all three — the one you run in CI.

1. Parity & quality (data-level)

auditTranslations diffs a package's locale bundles. Beyond missing/extra keys it sees what a structural diff can't: empty values, interpolation-param drift ({{name}} present in one locale but not another), value-equals-key placeholders, and malformed or CLDR-incomplete _plural objects. It is pure and deterministic — no false positives — so it belongs in a test that fails CI on drift.

// translations.test.ts — pure, deterministic, zero false positives.
import { auditTranslations } from '@urbicon-ui/i18n';
import { appTranslations } from '$lib/i18n'; // { en: { … }, de: { … } }

it('translations stay in parity', () => {
  const report = auditTranslations('app', appTranslations);
  expect(report.ok).toBe(true); // true ⇢ no error-severity findings
});

// Each report.findings entry carries a stable `code` + severity:
//   missing-key · empty-value · param-mismatch · plural-shape-invalid
//   plural-category-incomplete · value-equals-key · wrong-type   (errors)
//   extra-key · same-as-base (opt-in)                            (warnings)

The richer successor to validatePackageTranslations (kept for back-compat). Toggle individual checks or set a base locale via the third options argument; same-as-base (a target string still identical to the base — probably untranslated) is opt-in because proper nouns make it FP-prone.

2. Runtime misses

A static audit can only see keys that exist as literals. Keys assembled at runtime (t(`errors.${code}`)) are invisible to it — but onMissingKey catches them where it counts: the moment translate resolves a key nowhere and would render its raw string. createMissingKeyCollector packages that sink for a test or E2E run.

// test setup — catch keys that resolve nowhere at RUNTIME, including the
// dynamically-built keys a static scan can't see.
import { configureI18n, createMissingKeyCollector } from '@urbicon-ui/i18n';

const misses = createMissingKeyCollector();
configureI18n({ onMissingKey: misses.onMissingKey });

// … render pages / run the E2E flow …

expect(misses.isClean()).toBe(true); // nothing rendered as its raw key
// misses.report() → [{ key, locale, packageName?, count }]

Off by default (a provider-less render legitimately misses keys), so it only fires once you opt in. Fed back into the scanner as runtimeUsedKeys, the observed keys also prove a dynamic key is reachable — shrinking false "unused" reports.

3. Unused keys & hardcoded strings

urbicon i18n scans your sources (binding-aware, so it follows arbitrarily-named useFooI18n() aliases, member calls, and <T key>). It reports unused keys (defined but referenced nowhere — confirmed when no opaque dynamic call could be hiding them, else suspect), used-but-undefined keys (a typo that renders raw), and hardcoded UI copy in markup. Run it under Bun (it loads .ts locale bundles).

# parity (locale bundles) + unused keys + hardcoded strings — all three
urbicon i18n audit src/ --translations src/lib/translations

# scope to one check; --json for CI; allowlist dynamically-built key families
urbicon i18n unused --dynamic-keys 'errors.*,filter.operators.*' --json
urbicon i18n hardcoded src/ --strict   # gate the advisory lint too

It gates (exit 1) on parity errors and used-but-undefined keys — the correctness failures. Unused keys and hardcoded strings are advisory (add --strict to gate them too), mirroring the design gate's correctness-vs-craft split. The same scanning core is importable for programmatic use:

// The CLI is the filesystem front end over this pure core. typescript +
// svelte are optional peers, lazily imported only when a scan runs.
import { scanSources, findUnusedKeys, collectDeepKeys } from '@urbicon-ui/i18n/audit';

const { scan } = await scanSources(files); // files: { file, code }[]
const report = findUnusedKeys(collectDeepKeys(enBundle), scan, {
  dynamicKeys: ['errors.*'], // never flagged
  runtimeUsedKeys // optional: keys observed via createMissingKeyCollector
});
// report.unused          → { key, tier: 'confirmed' | 'suspect' }[]
// report.usedButUndefined → keys referenced in code but defined nowhere

The unused check is biased hard toward "used" — a key is flagged only when no static call, no template prefix, no string literal anywhere, no allowlist, and no runtime observation reaches it — so a live key is never wrongly proposed for deletion. Declare dynamic families with --dynamic-keys to clear the suspect tier.

CI integration

One step gates translation correctness alongside your design gate. urbicon init --ci writes it for you; the standalone form:

# .github/workflows/i18n.yml — gate translation correctness in CI
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bunx urbicon i18n audit src/ --translations src/lib/translations

Pair it with the auditTranslations test for the data-level checks and a createMissingKeyCollector assertion in your E2E run for the dynamic keys — three layers, each catching what the others can't.