Monorepos

Shared Layers and Duplicates

A Shared Library, Two Apps

monorepo/
├── packages/ui/          # shared: buttons, dialogs, form errors
├── apps/checkout/        # consumes ui
├── apps/admin/           # consumes ui
└── scripts/              # release tooling, no app

Three layers, two apps. ui is a shared layer: consumed by more than one app. Each layer's scope is its own root, the roots of the apps that consume it, and the project root — which claims scripts/:

LayerScope
uipackages/ui, apps/checkout, apps/admin, project root
checkoutapps/checkout, project root
adminapps/admin, project root

What changes against a repository-wide scan is who may vouch for what:

Key, defined inReferenced inOutcome
uipackages/uiUsed. A library's own code protects its keys
uiapps/checkout onlyUsed. One consumer is enough
uinowhereOrphan — the only bucket orphans --remove deletes from
checkoutapps/admin onlyMisplaced usage — reported, never removed

The last row is what an unscoped scan gets wrong: a repository-wide grep finds the string in apps/admin, calls the key used, and the broken screen ships. See misplaced usages and orphan detection.

Drop apps/admin and ui has one consumer, whose scope is the whole project. Per-layer scope collapses to global scope and the scan behaves like an unscoped one.

Collisions

A collision is one key defined in two layers the same app consumes: a shared layer and a layer that overrides it. The app resolves the overriding layer, so the shared value is dead for that app, with no warning at build time.

KindMeansWhy it matters
Same value on both sidesA copyHarmless today, drift tomorrow
Different values — divergentAn overrideThe shared value is never seen by this app
the-i18n-cli find-duplicates --locale de
{
  "collisions": [
    {
      "key": "common.cancel",
      "sharedLayer": "ui",
      "childLayer": "checkout",
      "sharedValue": "Cancel",
      "childValue": "Discard",
      "divergent": true
    }
  ],
  "summary": { "totalCollisions": 1, "divergentCount": 1, "pairsChecked": 2, "locale": "en" }
}

pairsChecked tells you whether a clean report means "no collisions" or "nothing to compare". Pairs come from each app's layer precedence order: of two locale-backed layers an app consumes, the earlier is the child and the later is the shared base. Comparison is leaf by leaf in one reference locale, structural for values that are not strings, so two equal arrays of plural forms are a copy rather than a divergence.

Fixing a Collision

Delete one side. Never move the key — both layers already define it, so a move is a delete plus a no-op and the wrong order loses a value.

  • The child value is authoritative → delete the shared copy, if no other consumer relies on it.
  • The child copy was accidental → delete it and let the app fall through.
  • The override is deliberate → keep both. There is no suppression list; the report shows it every run.

The same guidance ships in the result's guidance field.

Check every locale before deleting. Values can be identical in English and divergent in German, in which case deleting the child copy changes the German UI. Each run compares one reference locale — pass --locale <code> once per locale you ship.

Two more things to expect. With no app information in the config no pairs can be derived, and the summary says so in its message field rather than guessing at a hierarchy; the same happens when no app consumes more than one locale-backed layer. And duplicate detection has no gate flag: it reports and exits 0, so failing a pipeline on collisions means reading summary.totalCollisions or summary.divergentCount yourself.

Copyright © 2026