Duplicates and Divergence
What a Collision Is
A collision is one key defined in two layers that the same app consumes: a shared layer and a layer that overrides it. The app resolves the key from the overriding layer, so the shared layer's value is dead for that app — silently, with no warning at build time.
Two flavors, and the difference is the whole point:
| Kind | Means | Why it matters |
|---|---|---|
| Same value on both sides | A copy | Harmless today, drift tomorrow: the next person changes one and not the other |
| Different values — divergent | An override | Someone meant it, or nobody noticed. Either way the shared value is never seen by this app |
Finding Them
the-i18n-cli find-duplicates
The agent-facing equivalent is the find_duplicate_keys MCP tool, with the same
arguments and the same result. Both compare values in one reference locale — the
project default unless you pass another:
the-i18n-cli find-duplicates --locale de
A result looks like this:
{
"collisions": [
{
"key": "common.cancel",
"sharedLayer": "ui",
"childLayer": "checkout",
"sharedValue": "Cancel",
"childValue": "Discard",
"divergent": true
}
],
"summary": {
"totalCollisions": 1,
"divergentCount": 1,
"pairsChecked": 2,
"locale": "en"
}
}
pairsChecked is the number of (shared layer, overriding layer) pairs compared,
which tells you whether a clean report means "no collisions" or "nothing to
compare".
Which Pairs Get Compared
Pairs come from each app's layer precedence order in the consumer graph. For any two locale-backed layers an app consumes, the one listed earlier overrides the later one at runtime, so the earlier is the child and the later is the shared base. Precedence, not popularity: a layer consumed by three apps is still the child against a base only one app consumes, if that app lists it first.
Comparison is leaf-by-leaf in the reference locale. Values that are not strings — an array of plural forms, say — are compared structurally, so two equal arrays are a copy and not a divergence.
Fixing a Collision
Delete one side. Never move the key — both layers already define it, so moving is a delete plus a no-op, and doing it in the wrong order loses a value. Which side to delete follows from the values:
- The child value is authoritative and the shared value is stale → delete the shared copy, if no other consumer relies on it.
- The child copy was accidental → delete the child copy and let the app fall through to the shared value.
- The override is deliberate — one app really does say "Discard" — → keep both and treat the collision as known. There is no suppression list for duplicates; the report shows it every run.
The same guidance ships in the result's guidance field, so an agent acting on
the report reaches the same conclusion as you do.
find-duplicates --locale <code> once per locale
you ship, or at least for the locales the divergent key appears in.Limits
- One locale per run. The comparison is against a single reference locale; divergence in another locale is invisible until you run it for that locale.
- Locale files only. No source code is scanned. A collision is a statement about definitions, not about whether either definition is used — pair it with orphan detection for that.
- Needs app-to-layer edges. With no app information in the config, no pairs
can be derived and the summary says so in its
messagefield rather than guessing at a hierarchy. The same happens, with a different message, when no app consumes more than one locale-backed layer — a single-layer project has nothing to collide. - Leaf keys only. A key defined as an object in one layer and a string in another is compared where both sides have leaves; the shape mismatch itself is not reported.
- No CI gate.
find-duplicatesreports and exits zero. To fail a pipeline on collisions, readsummary.totalCollisionsorsummary.divergentCountfrom the report yourself.
Next: misplaced usages — the other cross-layer finding, and the one that is never removed.