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:
- An
i18n-namespace:comment on the call's line or the line above, naming the pattern the keys draw from. - That exact pattern declared under
orphanScan→ignorePatternsin 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:
// i18n-namespace: views.defaults.**
const label = t(view.name_key)
Declare the same pattern in the config, under the layer that owns the keys:
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:
| Shape | Why it is safe |
|---|---|
t('bookings.title'), templates, concatenation | Readable; 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.
Next
- Layer-Aware Missing Keys — the other half of the plugin: does this key exist for this app?