From d6f720bcebe3f5d92cb873d1c9e7b9b1507dabfe Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 17 Sep 2026 13:13:13 -0300 Subject: [PATCH] feat(i18n): new-key gate rejects __MISSING__ markers; skills translate new keys in parallel (#13996) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On 2026-09-16 eight feature PRs added 61 keys to src/i18n/messages/en.json and stamped `__MISSING__:` 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). --- docs/architecture/QUALITY_GATES.md | 18 +- docs/guides/I18N.md | 37 +++- package.json | 1 + scripts/i18n/check-new-key-coverage.mjs | 41 ++-- scripts/i18n/translate-new-keys.sh | 259 +++++++++++++++++++++++ tests/unit/i18n-new-key-coverage.test.ts | 15 +- 6 files changed, 344 insertions(+), 27 deletions(-) create mode 100755 scripts/i18n/translate-new-keys.sh diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md index b41d7e6eda..4c28fd4cf2 100644 --- a/docs/architecture/QUALITY_GATES.md +++ b/docs/architecture/QUALITY_GATES.md @@ -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__:` 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__:` 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= --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 diff --git a/docs/guides/I18N.md b/docs/guides/I18N.md index 930eb7a51c..f3f7d68246 100644 --- a/docs/guides/I18N.md +++ b/docs/guides/I18N.md @@ -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 `.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: diff --git a/package.json b/package.json index d448055847..8ac5cef97c 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/i18n/check-new-key-coverage.mjs b/scripts/i18n/check-new-key-coverage.mjs index ef77e49442..0db18e5b6c 100644 --- a/scripts/i18n/check-new-key-coverage.mjs +++ b/scripts/i18n/check-new-key-coverage.mjs @@ -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__:` 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__:` 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= --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__:` to defer (the runtime then falls back\n" + - " to English). `vi` bans placeholders — it needs a real translation." + "\n A `__MISSING__:` 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); } diff --git a/scripts/i18n/translate-new-keys.sh b/scripts/i18n/translate-new-keys.sh new file mode 100755 index 0000000000..d07b6febd2 --- /dev/null +++ b/scripts/i18n/translate-new-keys.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# OmniRoute — translate the `__MISSING__:` 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__:` 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= --locale= --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): +# .log the sync-ui-keys output of the last attempt +# .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=] +# 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= subset of locale codes (default: every .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/.log — re-run with --locales= 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 diff --git a/tests/unit/i18n-new-key-coverage.test.ts b/tests/unit/i18n-new-key-coverage.test.ts index f27949b55b..ad7b4847c9 100644 --- a/tests/unit/i18n-new-key-coverage.test.ts +++ b/tests/unit/i18n-new-key-coverage.test.ts @@ -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__:` 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", () => {