ESLint Plugin

The Rules

Two rules, both about the same contract: every i18n call must leave the orphan scanner enough to bound which keys it can produce.

Both rules recognize an i18n call the way the scanner does: $t, $te and $tc anywhere; a bare t, te or tc only where it resolves to a vue-i18n binding — destructured from useI18n(), imported from vue-i18n, @nuxtjs/i18n or petite-vue-i18n, called on a useI18n() composer, or called on $i18n. A t that resolves to a function parameter or an unrelated const is not an i18n call, however many useI18n() calls the file contains. Lint and scan never disagree about what a call is — a rule firing on client.t(...) would teach you to ignore it. <template> expressions in .vue files are checked the same as script code.

literal-key-prefix

Error in both presets. A dynamic key — a template literal with expressions, or a + concatenation — must begin with a literal segment containing a dot.

// Fires: the key starts with a variable, so the scanner cannot
// bound what it produces.
t(`${section}.title`)
t(prefix + '.title')

// Passes: every key this call can produce lives under bookings.status.,
// and the scanner protects exactly that subtree.
t(`bookings.status.${status}`)
t('bookings.status.' + status)

The mechanism this defends: the scanner turns a literal dotted prefix into a bounded pattern and protects only the keys under it. A key that starts with a variable gives it nothing to anchor on — its options are over-protecting every *.title key in the catalog or deleting live ones. This exact idiom once put 34 live keys in the safe-to-delete bucket; the rule makes it unwritable.

Plain string literals, templates without expressions, and fully-literal keys never fire. When a literal prefix is impossible, look full keys up from a local map of literals instead — the next rule explains why that shape is safe.

runtime-key-needs-declared-namespace

Warn in recommended, error in strict — see Triage, Then Promote for why. It fires when the key is entirely runtime data:

// Fires: the key exists only at runtime. The scanner sees nothing here,
// so whatever keys the wire sends look orphaned.
t(view.name_key)

This is the shape behind a real deletion incident: a backend seeded key names like views.defaults.displayApp into a name_key column, the frontend called t(view.name_key), and no frontend scan could ever see which keys that call keeps alive. Four live keys landed in the deletion bucket and were caught only by a manual cross-repo audit.

The rule demands two things, both checkable:

  1. An i18n-namespace: comment on the call's line or the line above, naming the pattern the keys draw from.
  2. That exact pattern declared under orphanScanignorePatterns in the kit config — the place the scanner actually reads.

An annotation naming a pattern the config does not declare is reported as drift, with the config path in the message: the comment only protects keys if the scanner reads the same declaration. A runtime key with no kit config anywhere above the file is also reported — there is nowhere to declare the namespace, so the scanner will report its keys as deletable.

The Annotation Workflow

Annotate the call:

components/ViewCard.vue
// i18n-namespace: views.defaults.**
const label = t(view.name_key)

Declare the same pattern in the config, under the layer that owns the keys:

i18n-kit.config.ts
import { defineI18nKitConfig } from '@the-i18n-kit/cli/config'

export default defineI18nKitConfig({
  orphanScan: {
    root: { ignorePatterns: ['views.defaults.**'] },
  },
})

The rule accepts the pattern from any layer's ignorePatterns — which layer it belongs under is the scanner's concern, covered in the consumer graph. The config is found by walking up from the linted file (i18n-kit.config.ts, .mjs or .js), loaded without a build step, and re-read when its mtime changes — a declaration you just added stops the report without an editor restart.

What Never Fires

The leniencies are the scanner's own, restated — what the candidate net already protects, the rule does not question:

ShapeWhy it is safe
t('bookings.title'), templates, concatenationReadable; the built ones are literal-key-prefix's turf
const KEY = 'bookings.title't(KEY)A same-file const holding a string literal
const KEY = `bookings.${id}`t(KEY)A same-file const holding a dotted-prefix template
const LABELS = { a: 'x.a', b: 'x.b' }t(LABELS[state])A lookup into a same-file const map of string literals
te(x), $te(x)An existence check commits to nothing; only the translating call keeps keys alive

Only const bindings count. A let or var can hold anything by the time the call runs, so a runtime key behind one is reported. A conditional expression is runtime data if either branch is.

Refactoring a protected shape into an unprotected one — a dotted template into a lookup on data from a prop, say — is exactly when this rule fires and should. Derived protection evaporates silently otherwise; the report is the only signal that it did.

Next

Copyright © 2026