Orphan Detection
The orphans and check commands share one scanner. It parses your source, and
a key counts as used only where the call it sits in resolves to a translation
function: emit('save') is not a usage, and a t you renamed to translate
still is. A file the parser cannot read falls back to pattern matching, which
matches call shapes by name instead; summary.filesDeclined counts those files.
What the scanner sees decides which keys are safe to delete.
the-i18n-cli orphans
Evidence It Recognizes
| Class | Example | Effect |
|---|---|---|
| Static keys | t('a.b.c'), $te('a.b'), __('a.b') | Exact match |
| Template patterns | t(`a.b.${x}`) | Keys matching a.b.<one segment> count as used |
| Same-file const prefixes | const base = 'a.b' then t(`${base}.title`) | Resolved to the exact key |
| Unresolved variable segments | t(`${somePath}.title`) | Conservatively matches any *.title key |
| Concatenated prefixes | 'a.b.' + x, x + '.label' | Pattern-matched like templates |
| Multiline calls | The prefix on a different line from t( | Caught by a bare-string fallback |
Usage is then scoped: a key in a shared layer counts as used only from apps that consume that layer. See layers and the consumer graph.
The Buckets
Only orphanKeys is ever removed. Everything else is protective:
| Field | Means |
|---|---|
orphanKeys | No evidence of use in any consuming app — safe to remove |
uncertainKeys | Evidence is ambiguous, such as an existence-check-only probe — never removed |
candidateOnlyKeys | Kept alive only by a dotted string that is not a call — not orphans, but where dead references hide |
misplacedUsages | Used only from non-consuming apps — never removed |
dynamicKeys | The dynamic expressions the scan found, with file and line |
A key that could be produced by a template pattern, a concatenated prefix or an
ambiguous probe counts as used and is left alone; summary.dynamicMatchedCount,
summary.ignoredCount and summary.declaredCount say how many were protected
that way, by orphanScan.ignorePatterns and by a declared namespace. The run
reports; --remove deletes.
Extracting Undefined Keys
the-i18n-cli check --write adds every undefined key to a locale file as an
empty string, in the default locale only, and never overwrites a value already
there. The keys land in the layer the using code resolves against; where that is
more than one layer, the run refuses and asks for --layer <name>. A written
key has a definition and is no longer undefined, so a run that wrote all of them
exits 0 and one with findings left over still exits 2.
Declared Namespaces
Some keys exist by contract rather than by a call site: a backend sends the key name, a call keys them by runtime data, a registry defines the set. The scanner finds no reference and reports them as orphans. Declare the pattern and what keeps it alive:
import { defineI18nKitConfig } from '@the-i18n-kit/cli/config'
export default defineI18nKitConfig({
declaredNamespaces: [
{ pattern: 'views.defaults.**', reason: 'sent by bookings-api as name_key' },
],
})
Declared keys are never orphans, never removed, and never written by
check --write, in every layer. orphans lists each declaration with the keys
it covers under declaredNamespaces; one with an empty matchedKeys covers
nothing in the catalog. The ESLint plugin
requires an // i18n-namespace: views.defaults.** comment on a runtime-keyed
call and checks the pattern against this config. orphanScan.ignorePatterns,
keyed by layer, suppresses the rest — prefixes held in cross-file constants or
object properties, which widen to suffix patterns rather than resolve.
Anchor Dynamic Keys, Do Not Avoid Them
Dynamic keys are tracked and are often the right design. What matters is that the namespace stays literal at the call site:
t(`${prefix}.title`) // widens to any key ending .title
t(`components.integrations.${type}.title`) // one segment under a known namespace
Both count as used, but the first suppresses every .title key in the project —
keys the scan can then no longer audit. A literal leading segment costs nothing
and keeps the report specific. Where the set of keys is closed, a map of literals
is the fully static alternative:
const KEYS = { draft: 'x.status.draft', sent: 'x.status.sent' } as const
t(KEYS[state])
Inline the namespace rather than assembling it elsewhere: a computed returning
'a.b.' + x reaches the call site as an opaque variable.
Run removals as a reviewed pull request, and treat the report's uncertainKeys
and dynamicKeys sections as the audit trail.