feat(i18n): new-key gate rejects __MISSING__ markers; skills translate new keys in parallel (#13996)

On 2026-09-16 eight feature PRs added 61 keys to src/i18n/messages/en.json and
stamped `__MISSING__:<en>` into all 65 locales instead of translating.
check-new-key-coverage accepted the marker as "the key reached the locale", so
nothing blocked the PRs, and the blocking real-translation ratio gate then failed
on the release tip for everybody (pt-BR 3.2 % > 2.5 % + 0.5).

- scripts/i18n/check-new-key-coverage.mjs: a leaf whose value starts with
  `__MISSING__:` is judged exactly like an absent leaf; the FAIL message names
  the marker as the cause and prints the per-locale sync-ui-keys command and the
  parallel runner. Header/JSDoc updated.
- tests/unit/i18n-new-key-coverage.test.ts: "a new key that only carries a
  __MISSING__ marker is flagged" (was the inverse case, which encoded the old
  contract); the other six cases unchanged and green.
- scripts/i18n/translate-new-keys.sh (+ `npm run i18n:translate-new-keys`):
  committed, detached-safe runner — flock queue, N workers (default 5), 3
  attempts per locale of `sync-ui-keys.mjs --translate-markers --batch-size=40`,
  per-locale logs/.exit + batch.log/batch.status/batch.rc/batch.pid under
  _artifacts/i18n-new-keys/, non-zero exit while any locale still carries a
  marker, refuses to start (exit 2, names the five OMNIROUTE_TRANSLATION_* vars)
  when the backend env is absent. Reads only the OMNIROUTE_TRANSLATION_* lines
  of the repo .env; kills nothing, matches nothing by name.
- docs: QUALITY_GATES.md (gate table + check-new-key-coverage section) and
  I18N.md (gate table + "Translating the keys a branch adds" subsection).

The implementation/port/merge skills reference the new shared snippet
`.agents/skills/_shared/i18n-translate-new-keys.md` (skills repo, separate).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-17 13:13:13 -03:00
committed by GitHub
parent ceafa55824
commit d6f720bceb
6 changed files with 344 additions and 27 deletions

View File

@@ -144,7 +144,7 @@ Runs on every PR to `main`. Blocks merge on failure.
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
| `check-ui-keys-coverage` (inline) | UI i18n key coverage is ≥ 65% | Yes |
| `check-ui-value-drift` (inline) | A rewritten English **value** leaves no stale translation behind | Yes |
| `check-new-key-coverage` (inline) | A **new** English key reaches every locale | Yes |
| `check-new-key-coverage` (inline) | A **new** English key is translated in every locale — a `__MISSING__:` marker is rejected | Yes |
| `check-translation-ratio` | Real-translation ratio per locale (identical-to-English / placeholder / missing leaves outside the allowlist) must not exceed `config/quality/i18n-translation-baseline.json` + slack | **Advisory** |
Needs `fetch-depth: 0` — the value-drift gate diffs `en.json` against the merge base.
@@ -572,9 +572,19 @@ received them. `deepMergeFallback` substitutes English for an absent key, so the
untranslated UI rather than blank UI — real, and silent by construction.
Like its sibling it is **diff-aware**, comparing English at the merge base against the working
tree, so pre-existing gaps stay frozen and the gate needed no migration to turn on. Escape hatch:
`__MISSING__:<english>` defers a translation while keeping the runtime correct. `vi` bans
placeholders (`tests/unit/i18n-vi-completeness.test.ts`) and needs a real translation.
tree, so pre-existing gaps stay frozen and the gate needed no migration to turn on.
**A `__MISSING__:<english>` marker does not satisfy it (since 2026-09-17).** It used to be the
documented deferral — the runtime falls back to correct English — until eight feature PRs on
2026-09-16 added 61 keys and stamped the marker into all 65 locales instead of translating: this
gate accepted every one, nothing blocked the PRs, and the blocking real-translation ratio gate
then failed on the release tip for everybody (pt-BR 3.2 % > 2.5 % + 0.5). A marker is now judged
as an absent translation. Fix a red with
`node scripts/i18n/sync-ui-keys.mjs --locale=<codes> --translate-markers --batch-size=40`, or
all locales in parallel with `npm run i18n:translate-new-keys` (`scripts/i18n/translate-new-keys.sh`,
detached-safe, refuses to start without the `OMNIROUTE_TRANSLATION_*` env). A key that must stay
English (a pinned product/engine/flag name) belongs in `scripts/i18n/untranslatable-keys.json`,
never behind a marker. `vi` bans markers outright (`tests/unit/i18n-vi-completeness.test.ts`).
#### `check-vitest-exclusions` — parked-test gate

View File

@@ -220,13 +220,36 @@ adapter and must not be edited by hand. The Google-Translate generator
Three gates guard the catalogs, and they see different things:
| Gate | Sees |
| -------------------------------- | ----------------------------------------------------------------------- |
| `npm run i18n:check-ui-coverage` | ≥ 80 % of leaves translated per locale |
| `npm run i18n:check-new-keys` | a key the PR adds to `en.json` reached every locale |
| `npm run i18n:check-keys` | every locale carries exactly the key set of `en.json`, whatever the age |
| `npm run i18n:check-keys:cli` | the CLI catalogs carry exactly the key set of `bin/cli/locales/en.json` |
| `npm run i18n:check-ratio` | share of leaves still identical to English may only fall (ratchet) |
| Gate | Sees |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `npm run i18n:check-ui-coverage` | ≥ 80 % of leaves translated per locale |
| `npm run i18n:check-new-keys` | a key the PR adds to `en.json` is translated in every locale — a `__MISSING__:` marker counts as absent (since 2026-09-17) |
| `npm run i18n:check-keys` | every locale carries exactly the key set of `en.json`, whatever the age |
| `npm run i18n:check-keys:cli` | the CLI catalogs carry exactly the key set of `bin/cli/locales/en.json` |
| `npm run i18n:check-ratio` | share of leaves still identical to English may only fall (ratchet) |
### Translating the keys a branch adds (parallel runner)
A branch that adds or changes keys in `en.json` must translate them before its PR opens — the
new-key gate rejects `__MISSING__:` markers (on 2026-09-16 eight feature PRs shipped 61 keys as
markers into all 65 locales and the ratio gate went red on the release tip for everybody).
`scripts/i18n/translate-new-keys.sh` translates every locale in parallel and is safe to detach:
```bash
# pinned-name guard first: keys a test pins to English go in scripts/i18n/untranslatable-keys.json
rg -n 'assert\.equal\(.*messages\.[a-zA-Z.]+, "' tests/unit
# needs OMNIROUTE_TRANSLATION_API_URL / _API_KEY / _MODEL in .env — refuses to start otherwise
npm run i18n:translate-new-keys # all locales, 5 workers
bash scripts/i18n/translate-new-keys.sh --locales=pt-BR,es # a subset
bash scripts/i18n/translate-new-keys.sh --catalog=cli --workers=3 # bin/cli/locales
# detached: the shell returns at once; poll _artifacts/i18n-new-keys/batch.rc (0 = done)
nohup setsid bash scripts/i18n/translate-new-keys.sh > _artifacts/i18n-new-keys/runner.out 2>&1 &
```
Each locale gets three attempts of `sync-ui-keys.mjs --translate-markers --batch-size=40`;
per-locale logs and `<code>.exit` land in `_artifacts/i18n-new-keys/`, and the script exits
non-zero while any selected locale still carries a marker. Then
`npm run i18n:check-keys && npm run i18n:check-ratio && npm run i18n:check-new-keys`.
**After every merge of the base into a locale branch**, re-sync the locales the branch owns —
the base keeps adding keys while a batch translates:

View File

@@ -287,6 +287,7 @@
"alibaba:sync-allowlist": "node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs",
"check:vitest-exclusions": "node scripts/check/check-vitest-exclusions.mjs",
"i18n:check-new-keys": "node scripts/i18n/check-new-key-coverage.mjs",
"i18n:translate-new-keys": "bash scripts/i18n/translate-new-keys.sh",
"i18n:check-keys": "node scripts/i18n/check-key-completeness.mjs",
"i18n:check-keys:cli": "node scripts/i18n/check-key-completeness.mjs --catalog=cli"
},

View File

@@ -23,13 +23,22 @@
*
* How this gate works: DIFF-AWARE, like its sibling. It compares the English catalog at the
* merge base against the working tree; every key that is NEW in English must be present and
* non-placeholder in every locale. Pre-existing gaps are deliberately frozen — this gate
* judges only what the current change adds, so it can be turned on without a migration.
* TRANSLATED in every locale. Pre-existing gaps are deliberately frozen — this gate judges
* only what the current change adds, so it can be turned on without a migration.
*
* Escape hatch, same as the sibling: set the value to `__MISSING__:<english>` to make the
* runtime fall back to correct English and queue the key for the translation pipeline.
* NOTE that `vi` bans placeholders (tests/unit/i18n-vi-completeness.test.ts), so `vi` needs
* a real translation.
* A `__MISSING__:<english>` marker does NOT satisfy this gate (since 2026-09-17). It used to:
* the marker was the documented deferral, because the runtime falls back to correct English.
* Then on 2026-09-16 eight feature PRs added 61 keys to en.json and stamped the marker into
* all 65 locales instead of translating; this gate accepted every one of them, nothing blocked
* the PRs, and the real-translation ratio gate (`check-translation-ratio`, blocking) went red
* on the release tip for everybody (pt-BR 3.2 % > 2.5 % + 0.5). A marker is an absent
* translation wearing a runtime-safe coat, and it is judged as absent here. Translate:
*
* node scripts/i18n/sync-ui-keys.mjs --locale=<codes> --translate-markers --batch-size=40
* bash scripts/i18n/translate-new-keys.sh # same thing, all locales in parallel
*
* Keys that must stay English (product/engine/flag names a test pins) go in
* `scripts/i18n/untranslatable-keys.json`, never behind a marker.
*
* Usage:
* node scripts/i18n/check-new-key-coverage.mjs # strict, exit 1
@@ -68,8 +77,8 @@ export function flattenLeaves(node, prefix = "", out = {}) {
/**
* Pure core: which (key, locale) pairs are keys new in English that a locale never got?
*
* A `__MISSING__:` placeholder counts as satisfied — it is the documented, runtime-correct
* way to defer a translation.
* A `__MISSING__:` placeholder counts as ABSENT — it is not a translation, and accepting it
* is what let the 2026-09-16 batch ship 61 untranslated keys into 65 locales.
*
* @param {object} args
* @param {object} args.baseEn en.json at the base ref
@@ -91,7 +100,7 @@ export function findUntranslatedNewKeys({ baseEn, headEn, headLocales }) {
for (const key of newKeys) {
const value = flat[key];
const satisfied =
typeof value === "string" && (value.trim() !== "" || value.startsWith(PLACEHOLDER_PREFIX));
typeof value === "string" && value.trim() !== "" && !value.startsWith(PLACEHOLDER_PREFIX);
if (!satisfied) gaps.push({ key, locale });
}
}
@@ -186,14 +195,20 @@ function main() {
}
const label = opts.warn ? "WARN" : "FAIL";
console.error(
`\n[i18n-new-keys] ${label}${byKey.size} new English key(s) missing from some locales:`
`\n[i18n-new-keys] ${label}${byKey.size} new English key(s) untranslated in some locales:`
);
for (const [key, locales] of byKey) {
console.error(`${key}missing in ${locales.length}: ${locales.join(", ")}`);
console.error(`${key}untranslated in ${locales.length}: ${locales.join(", ")}`);
}
const codes = [...new Set(gaps.map((g) => g.locale))].sort().join(",");
console.error(
"\n Translate them, or set `__MISSING__:<english>` to defer (the runtime then falls back\n" +
" to English). `vi` bans placeholders — it needs a real translation."
"\n A `__MISSING__:<english>` marker does not count — it is an absent translation.\n" +
" Translate the keys (needs OMNIROUTE_TRANSLATION_API_URL/_API_KEY/_MODEL in .env):\n" +
` node scripts/i18n/sync-ui-keys.mjs --locale=${codes} --translate-markers --batch-size=40\n` +
" or, all locales in parallel (detached runner):\n" +
" bash scripts/i18n/translate-new-keys.sh\n" +
" A key that must stay English (a pinned product/engine/flag name) belongs in\n" +
" scripts/i18n/untranslatable-keys.json."
);
if (!opts.warn) process.exit(1);
}

View File

@@ -0,0 +1,259 @@
#!/usr/bin/env bash
# OmniRoute — translate the `__MISSING__:<en>` markers a branch adds, every locale in parallel.
#
# Why this exists: on 2026-09-16 eight feature PRs added 61 keys to `src/i18n/messages/en.json`
# and stamped `__MISSING__:<en>` into all 65 locales instead of translating. The new-key gate
# (`scripts/i18n/check-new-key-coverage.mjs`) now rejects markers, so a branch that adds keys
# has to translate them before its PR opens — and translating 65 locales one after the other
# is what makes people skip it. This runner does it in parallel and is safe to detach.
#
# What it does: N workers pop locale codes from a queue (`flock`-serialized) and each runs
# node scripts/i18n/sync-ui-keys.mjs --catalog=<c> --locale=<code> --translate-markers --batch-size=40
# with up to 3 attempts per locale. A locale is DONE only when the run exits 0 AND its catalog
# carries no `__MISSING__` marker any more. Everything is written under `_artifacts/i18n-new-keys/`
# (gitignored, disposable):
# <code>.log the sync-ui-keys output of the last attempt
# <code>.exit 0 on success, else the last exit code (1 when markers survived a rc=0 run)
# batch.log one line per attempt + START/END markers
# batch.status running | done | failed
# batch.rc the script's final exit code (written last — poll this file)
# batch.pid PID of this script (kill by PID, never `pkill -f`)
#
# Usage:
# bash scripts/i18n/translate-new-keys.sh [--catalog=ui|cli] [--workers=N] [--locales=<csv>]
# npm run i18n:translate-new-keys -- --locales=pt-BR,es
# # detached (the session keeps working; poll _artifacts/i18n-new-keys/batch.rc):
# mkdir -p _artifacts/i18n-new-keys
# nohup setsid bash scripts/i18n/translate-new-keys.sh > _artifacts/i18n-new-keys/runner.out 2>&1 &
#
# --catalog=ui|cli ui = src/i18n/messages (default); cli = bin/cli/locales
# --workers=N parallel locales (default 5)
# --locales=<csv> subset of locale codes (default: every <code>.json in the catalog but en)
#
# Backend: the translation env block must be present — in the shell or in the repo `.env`
# (the script reads only the OMNIROUTE_TRANSLATION_* lines of `.env`; already-set variables
# win). Without it the script refuses to start and names the variables. It never leaves
# markers silently: the exit code is non-zero while any selected locale still carries one.
#
# Exit codes: 0 all selected locales translated · 1 some locale still has markers / failed ·
# 2 usage or environment error (nothing ran).
set -u
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
ROOT=$(cd "$SCRIPT_DIR/../.." && pwd)
ART="$ROOT/_artifacts/i18n-new-keys"
CATALOG=ui
WORKERS=5
LOCALES=""
RETRY_SLEEP=${OMNIROUTE_TRANSLATION_RETRY_SLEEP:-30}
usage() {
sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
}
for arg in "$@"; do
case "$arg" in
--catalog=*) CATALOG="${arg#--catalog=}" ;;
--workers=*) WORKERS="${arg#--workers=}" ;;
--locales=*) LOCALES="${arg#--locales=}" ;;
--locale=*) LOCALES="${arg#--locale=}" ;;
-h | --help)
usage
exit 0
;;
*)
echo "[i18n-new-keys] unknown argument: $arg" >&2
usage >&2
exit 2
;;
esac
done
case "$CATALOG" in
ui) DIR="$ROOT/src/i18n/messages" ;;
cli) DIR="$ROOT/bin/cli/locales" ;;
*)
echo "[i18n-new-keys] --catalog must be ui or cli (got: $CATALOG)" >&2
exit 2
;;
esac
case "$WORKERS" in
'' | *[!0-9]* | 0)
echo "[i18n-new-keys] --workers must be a positive integer (got: $WORKERS)" >&2
exit 2
;;
esac
# ----- translation backend env: shell first, then the OMNIROUTE_TRANSLATION_* lines of .env
load_translation_env() {
local env_file="$ROOT/.env" line key value
[ -f "$env_file" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
line="${line#"${line%%[![:space:]]*}"}" # ltrim
line="${line#export }"
case "$line" in
OMNIROUTE_TRANSLATION_*=*) ;;
*) continue ;;
esac
key="${line%%=*}"
value="${line#*=}"
value="${value%"${value##*[![:space:]]}"}" # rtrim
case "$value" in
\"*\") value="${value#\"}" value="${value%\"}" ;;
\'*\') value="${value#\'}" value="${value%\'}" ;;
esac
if [ -z "${!key:-}" ]; then
export "$key=$value"
fi
done <"$env_file"
}
load_translation_env
REQUIRED_VARS="OMNIROUTE_TRANSLATION_API_URL OMNIROUTE_TRANSLATION_API_KEY OMNIROUTE_TRANSLATION_MODEL"
OPTIONAL_VARS="OMNIROUTE_TRANSLATION_CONCURRENCY OMNIROUTE_TRANSLATION_TIMEOUT_MS"
missing=""
for v in $REQUIRED_VARS; do
[ -n "${!v:-}" ] || missing="$missing $v"
done
if [ -n "$missing" ]; then
{
echo "[i18n-new-keys] REFUSING TO START — translation backend not configured."
echo " Missing (required):$missing"
echo " The full block (put it in $ROOT/.env or export it in the shell):"
for v in $REQUIRED_VARS $OPTIONAL_VARS; do
if [ -n "${!v:-}" ]; then echo " $v (set)"; else echo " $v (MISSING)"; fi
done
echo " Nothing was translated; the __MISSING__ markers are still in place and the"
echo " new-key gate (npm run i18n:check-new-keys) will reject them."
} >&2
exit 2
fi
# ----- locale selection: on-disk catalogs minus en, or the explicit --locales subset
selected=()
if [ -n "$LOCALES" ]; then
IFS=',' read -r -a requested <<<"$LOCALES"
for code in "${requested[@]}"; do
code="${code//[[:space:]]/}"
[ -n "$code" ] || continue
if [ "$code" = "en" ]; then
echo "[i18n-new-keys] en is the source catalog, skipping it" >&2
continue
fi
if [ ! -f "$DIR/$code.json" ]; then
echo "[i18n-new-keys] unknown locale: $code ($DIR/$code.json does not exist)" >&2
exit 2
fi
selected+=("$code")
done
else
for f in "$DIR"/*.json; do
code=$(basename "$f" .json)
[ "$code" = "en" ] && continue
selected+=("$code")
done
fi
if [ "${#selected[@]}" -eq 0 ]; then
echo "[i18n-new-keys] no locale selected" >&2
exit 2
fi
# ----- artifacts (fresh per run for the selected locales)
mkdir -p "$ART"
QUEUE="$ART/queue.txt"
LOCK="$ART/queue.lock"
: >"$QUEUE"
for code in "${selected[@]}"; do
echo "$code" >>"$QUEUE"
rm -f "$ART/$code.exit"
done
rm -f "$ART/batch.rc"
echo running >"$ART/batch.status"
echo $$ >"$ART/batch.pid"
log() { echo "$*" >>"$ART/batch.log"; }
count_markers() {
# grep -c prints 0 and exits 1 when nothing matches — the count is what we want.
grep -c "__MISSING__" "$DIR/$1.json" 2>/dev/null || true
}
next_locale() {
# pop the first line of the queue atomically
(
flock 9
local l
l=$(head -n 1 "$QUEUE")
[ -n "$l" ] && sed -i '1d' "$QUEUE"
echo "$l"
) 9>"$LOCK"
}
worker() {
local id=$1 code rc left t0 wall attempt
while :; do
code=$(next_locale)
[ -z "$code" ] && break
for attempt in 1 2 3; do
log "[$code] w$id attempt $attempt start $(date -Is)"
t0=$(date +%s)
(
cd "$ROOT" && node scripts/i18n/sync-ui-keys.mjs --catalog="$CATALOG" --locale="$code" \
--translate-markers --batch-size=40
) >"$ART/$code.log" 2>&1
rc=$?
wall=$(($(date +%s) - t0))
left=$(count_markers "$code")
log "[$code] w$id attempt $attempt exit=$rc wall=${wall}s markers_left=$left $(date -Is)"
if [ "$rc" -eq 0 ] && [ "$left" -eq 0 ]; then
echo 0 >"$ART/$code.exit"
log "[$code] DONE"
break
fi
if [ "$attempt" -eq 3 ]; then
[ "$rc" -eq 0 ] && rc=1
echo "$rc" >"$ART/$code.exit"
log "[$code] FAILED exit=$rc markers_left=$left"
else
sleep "$RETRY_SLEEP"
fi
done
done
}
echo "[i18n-new-keys] catalog=$CATALOG locales=${#selected[@]} workers=$WORKERS artifacts=$ART"
log "BATCH START catalog=$CATALOG workers=$WORKERS locales=${selected[*]} $(date -Is)"
pids=()
for i in $(seq 1 "$WORKERS"); do
worker "$i" &
pids+=("$!")
done
wait "${pids[@]}"
# ----- verdict: every selected locale must have exit 0 AND zero markers on disk
failed=""
for code in "${selected[@]}"; do
ex=$(cat "$ART/$code.exit" 2>/dev/null || echo missing)
left=$(count_markers "$code")
if [ "$ex" != "0" ] || [ "$left" -ne 0 ]; then
failed="$failed $code(exit=$ex,markers=$left)"
fi
done
if [ -n "$failed" ]; then
log "BATCH END failed:$failed $(date -Is)"
echo failed >"$ART/batch.status"
echo 1 >"$ART/batch.rc"
echo "[i18n-new-keys] FAILED — markers still present or run failed for:$failed" >&2
echo " logs: $ART/<code>.log — re-run with --locales=<csv> for just those." >&2
exit 1
fi
log "BATCH END ok $(date -Is)"
echo done >"$ART/batch.status"
echo 0 >"$ART/batch.rc"
echo "[i18n-new-keys] DONE — ${#selected[@]} locale(s) translated, no __MISSING__ marker left."
echo " Now: npm run i18n:check-keys && npm run i18n:check-ratio && npm run i18n:check-new-keys"
exit 0

View File

@@ -34,13 +34,22 @@ test("a new key translated everywhere passes", () => {
assert.deepEqual(gaps, []);
});
test("a __MISSING__ placeholder satisfies the gate — it is the documented deferral", () => {
/**
* 2026-09-16: eight feature PRs added 61 keys to en.json and stamped `__MISSING__:<en>` into
* all 65 locales instead of translating. This gate accepted the marker as "the key reached the
* locale", nothing blocked the PRs, and the real-translation ratio gate then went red on the
* release tip for everybody (pt-BR 3.2 % > 2.5 % + 0.5). A marker is an absent translation.
*/
test("a new key that only carries a __MISSING__ marker is flagged", () => {
const gaps = findUntranslatedNewKeys({
baseEn: en(),
headEn: en({ fresh: "Fresh" }),
headLocales: { pt: { ui: { existing: "Existente", fresh: "__MISSING__:Fresh" } } },
headLocales: {
pt: { ui: { existing: "Existente", fresh: "__MISSING__:Fresh" } },
de: { ui: { existing: "Vorhanden", fresh: "Frisch" } },
},
});
assert.deepEqual(gaps, []);
assert.deepEqual(gaps, [{ key: "ui.fresh", locale: "pt" }]);
});
test("an empty string does NOT satisfy the gate", () => {