From 97bbadbf3a4ff984520717dfa30f3a95767341c6 Mon Sep 17 00:00:00 2001 From: FullGas1 <51051389+FullGas1@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:52:29 +0200 Subject: [PATCH 1/4] chore(backlog): open FIX-I18N-DICT-GUARD lot + ADR 0013 PRD, 3 tickets and ADR for a CI-enforced i18n dictionary guard: block new MISSING keys unconditionally, block new empty non-EN stubs unless labeled skip-i18n, and fix translate_i18n.py's stub detection so the guard is actually satisfiable locally. Grilled with docs 2026-08-10. --- .backlog/FIX-I18N-DICT-GUARD/PRD.md | 158 ++++++++++++++++++ .../tickets/01-ci-missing-key-guard.md | 38 +++++ .../02-fix-translate-stub-detection.md | 48 ++++++ .../tickets/03-ci-empty-stub-diff-guard.md | 44 +++++ .backlog/README.md | 1 + dev/adr/0013-ci-i18n-dict-guard.md | 76 +++++++++ dev/adr/README.md | 1 + 7 files changed, 366 insertions(+) create mode 100644 .backlog/FIX-I18N-DICT-GUARD/PRD.md create mode 100644 .backlog/FIX-I18N-DICT-GUARD/tickets/01-ci-missing-key-guard.md create mode 100644 .backlog/FIX-I18N-DICT-GUARD/tickets/02-fix-translate-stub-detection.md create mode 100644 .backlog/FIX-I18N-DICT-GUARD/tickets/03-ci-empty-stub-diff-guard.md create mode 100644 dev/adr/0013-ci-i18n-dict-guard.md diff --git a/.backlog/FIX-I18N-DICT-GUARD/PRD.md b/.backlog/FIX-I18N-DICT-GUARD/PRD.md new file mode 100644 index 0000000..91995a4 --- /dev/null +++ b/.backlog/FIX-I18N-DICT-GUARD/PRD.md @@ -0,0 +1,158 @@ +Status: ready + +# FIX-I18N-DICT-GUARD — CI-enforced i18n dictionary guard + +Reported by **FullGas**: some F10 menu entries stay untranslated when switching CTLD's interface +to Korean. Grilled with docs on 2026-08-10 (**ADR 0013**). + +## Problem Statement + +A Mission Maker switching CTLD's language to Korean (or Spanish) sees some F10 menu entries fall +back to English — the entries themselves are fine, they simply were never translated. Nothing +during development or the PR process caught this: `develop` today carries 91 untranslated entries +in `CTLD_i18n_ko.lua` and 76 in `CTLD_i18n_es.lua`, and nothing stops a new PR from adding more. + +The tooling meant to prevent exactly this doesn't work as documented: + +- `generate_i18n_dicts.ps1 -Apply` (run by every `merge_CTLD.ps1`) adds a key missing from a + dictionary as an empty stub (`= ""`) — it does not translate. +- `translate_i18n.py` (`BUILD-DICT-AI-TRANSLATE`, PR #60) is supposed to fill those stubs locally + via Claude when `ANTHROPIC_API_KEY` is set, but its stub-detection only recognises a value + identical to the English text as "untranslated" — never an empty string. So a freshly-added key + is never selected for translation, with or without the API key. +- The only existing guard, `.githooks/pre-push` (`BUILD-DICT-AUTOSYNC`, PR #59), is opt-in + (`git config core.hooksPath .githooks`, a manual post-clone step) and checks only that a key + exists in every dictionary — an empty value already satisfies it. + +## Solution + +A new CI job, `i18n-guard`, blocks a PR that introduces an i18n key without an entry in all four +dictionaries (`MISSING`, unconditional — no bypass, costs nothing to fix) or leaves a *newly +introduced* non-EN entry empty (bypassable via a `skip-i18n` label, for contributors without local +`ANTHROPIC_API_KEY` access). It is diff-scoped against the PR's base, on the same model as the +existing `changelog-guard` job (`CHORE-DOC-GATES`, PR #39) — it only ever prevents *new* debt, +never retroactively fails a PR over the 167 entries already on `develop`. + +`translate_i18n.py`'s stub detection is fixed in the same lot (a value of `""` is now also treated +as a stub) so a developer running `merge_CTLD.ps1` locally with `ANTHROPIC_API_KEY` set can +actually satisfy the guard — without that fix, the guard would be unsatisfiable by the very tool +built to satisfy it. + +Full rationale and rejected alternatives (CI auto-translate, absolute/non-diff guard, no bypass, +extending the `build` job instead of a new one): **ADR 0013**. + +## User Stories + +1. As a Mission Maker playing CTLD in Korean, I want every F10 menu entry translated, so that I + don't hit raw English fallback text mid-mission. +2. As a Mission Maker playing CTLD in Spanish, I want the same guarantee, so translation coverage + isn't a Korean-only fix. +3. As a CTLD contributor adding a new `ctld.tr()` string, I want CI to tell me my PR is missing a + dictionary key, so I don't ship an untranslatable string by accident. +4. As a CTLD contributor, I want CI to fail if the key I added stays untranslated in FR/ES/KO, so + untranslated debt can't grow silently again. +5. As a CTLD contributor without `ANTHROPIC_API_KEY` available, I want an escape hatch, so a + translation gap I can't personally close doesn't block an otherwise-ready contribution. +6. As a maintainer, I want the `skip-i18n` label to be a deliberate, visible action on the PR, not + a default, so bypassing translation stays an exception a reviewer can see and account for. +7. As a maintainer, I want the `MISSING`-key check to have no bypass, so no key ever ships without + at least an entry in all four dictionaries, label or not. +8. As a developer running `merge_CTLD.ps1` locally with `ANTHROPIC_API_KEY` set, I want + `translate_i18n.py` to actually pick up and translate the stubs it just created, so the tool + does what its own docstring says it does. +9. As a maintainer, I want the guard scoped to each PR's diff rather than the whole repository + state, so the 167 pre-existing empty entries don't block unrelated PRs the day this ships. +10. As a maintainer, I want the pre-existing debt tracked explicitly rather than silently ignored, + so a follow-up lot repays it deliberately instead of it being forgotten. +11. As a developer touching the new detection logic later, I want it covered by unit tests, so a + future change to the stub or diff logic doesn't silently regress — `tools/build/` has zero test + coverage today. +12. As a reviewer, I want the new CI job structured like `changelog-guard` (same trigger shape, + same label-bypass mechanism), so the pattern is immediately recognisable rather than a one-off. +13. As a developer relying on the local `pre-push` hook, I want it to keep working exactly as + before, so this lot doesn't slow it down or duplicate logic it doesn't need. +14. As a future contributor reading `dev/adr/`, I want the trade-offs behind "CI blocks but never + auto-translates" recorded, so nobody "fixes" that as an oversight later. + +## Implementation Decisions + +- **New CI job `i18n-guard`** in `ci.yml`, PR-triggered only (`if: github.event_name == + 'pull_request'`), `ubuntu-latest` (ships `pwsh` natively — no `windows-latest` needed), + `fetch-depth: 0` checkout — same shape as `changelog-guard`. +- **`MISSING` check**: reuse `generate_i18n_dicts.ps1`'s existing dry-run (no changes to that + script — its scan of `ctld.tr()` calls and the config YAML is already correct); the job greps + its output for `MISSING` and fails unconditionally if found. `STALE` entries in that same output + are ignored by this job, exactly as `.githooks/pre-push` already treats them (warn-only, + non-blocking, unaffected by this lot). +- **New empty-stub check**: a new script, `tools/build/check_i18n_diff.py`, compares each non-EN + dictionary's content at the PR base vs. head (`git show :` vs. the checked-out + file) and reports any key whose head value is `""` where the base value was non-empty or the key + was absent — i.e. genuinely new to this PR, not pre-existing debt. Exits non-zero listing the + offending keys when any are found. +- **Shared parsing**: the dict-file parser currently private to `translate_i18n.py` + (`_parse_dict`) is extracted into a new small shared module, `tools/build/i18n_dict_utils.py`, + and reused by both `translate_i18n.py` and `check_i18n_diff.py` — one parser, not two that can + drift apart. +- **`skip-i18n` label bypass**: applies only to the new-empty-stub step, read from + `github.event.pull_request.labels.*.name` into a `HAS_SKIP` env var, same pattern as + `changelog-guard`'s `skip-changelog`. The `MISSING` step has no such condition — it always runs. +- **`translate_i18n.py` fix**: the stub-selection predicate (currently `lang_dict.get(k) == v`) + is extended to also match `lang_dict.get(k) == ""`, for any key not listed in that dictionary's + `__keep_en` block. This is the only change to `translate_i18n.py`'s behavior in this lot. +- **`generate_i18n_dicts.ps1`**: unchanged. Its `""`-for-missing-non-EN convention on `-Apply` is + the existing contract; the fix targets the consumer (`translate_i18n.py`), not the producer. +- **`.githooks/pre-push`**: unchanged, out of scope — stays `MISSING`-only. CI becomes the single + authoritative gate; the hook remains a best-effort, non-mandatory local pre-check. +- **CI Python setup**: the new job needs its own `actions/setup-python` step (it's a fresh + `ubuntu-latest` job, separate from `python-quality`'s `tools/ctld-tools`-scoped environment). + +## Testing Decisions + +- Tests target external behavior (inputs → outputs of pure functions), not internal wiring — + consistent with the rest of the repo's test philosophy. +- **`tools/build/i18n_dict_utils.py`** (the extracted parser): unit-tested against dict-file text + fixtures (well-formed entries, escaped quotes, the `__keep_en` block). +- **`tools/build/check_i18n_diff.py`**'s core function (`find_new_empty_keys` or equivalent): unit + tested with base/head text fixture pairs — cases: key newly added and empty (flagged); key + already empty in base and still empty in head (not flagged — pre-existing debt, not new); key + translated in base then blanked in head (flagged); key genuinely translated (not flagged). +- **`translate_i18n.py`**'s stub-selection predicate: unit tested standalone — empty value → stub; + value identical to EN text → stub; a real translation → not a stub; a key in `__keep_en` → never + a stub regardless of its value. +- This is the **first test coverage `tools/build/` has ever had** — no prior art inside that + directory to follow; the nearest prior art is `tools/ctld-tools`'s own pytest suite (fixture- and + `tmp_path`-based, no mocking of file I/O internals) for style, even though it's a separate + package. +- **Not unit tested**: the CI job's YAML itself (trigger condition, label read, checkout/diff + mechanics) — same as `changelog-guard`, which has no dedicated test either. Verified manually by + opening a real test PR at merge time (adds/omits a key, with/without the label) rather than + simulated in a test harness. +- **Wiring**: a new step in `python-quality.yml` (already triggered on `tools/build/**`) runs + `pip install pytest` (no poetry — matches the existing "pip only" decision recorded for + `translate_i18n.py`) then `pytest tools/build/`. + +## Out of Scope + +- **Repaying the 167 pre-existing empty entries** (91 KO + 76 ES) already on `develop`. This guard + only prevents new debt. A follow-up lot to repay the existing debt must follow immediately — + tracked as a priority follow-up, not covered by this PRD. +- **Auto-translating in CI** (a bot job calling Claude and committing back to the PR branch). + Rejected in ADR 0013: would require `ANTHROPIC_API_KEY` as a shared CI secret and a + write-capable token, reversing `BUILD-DICT-AI-TRANSLATE`'s deliberate "local-only" decision. +- **Changing which languages exist** (fr/es/ko stays the complete non-EN set; nothing here adds or + removes a language). +- **Modifying `.githooks/pre-push`** or its activation story (still a manual + `git config core.hooksPath .githooks` post-clone step, untouched by this lot). +- **Blocking on `STALE` keys.** Stays a non-blocking signal, as it already is locally. + +## Further Notes + +- Full trade-off record: **ADR 0013** (`dev/adr/0013-ci-i18n-dict-guard.md`). +- Prior art this lot builds directly on: `BUILD-DICT-AUTOSYNC` (PR #59, introduced + `generate_i18n_dicts.ps1 -Apply` in the build + the `pre-push` hook), `BUILD-DICT-AI-TRANSLATE` + (PR #60, introduced `translate_i18n.py` and the local-only `ANTHROPIC_API_KEY` decision this lot + preserves), `CHORE-DOC-GATES` (PR #39, introduced the `changelog-guard` pattern this lot mirrors + job-for-job). +- Debt baseline as of 2026-08-10, for the follow-up repayment lot to start from: 91 empty entries + in `CTLD_i18n_ko.lua`, 76 in `CTLD_i18n_es.lua`, 0 in `CTLD_i18n_fr.lua` (filled by hand in + `040bf8c`, outside the auto-translate path). diff --git a/.backlog/FIX-I18N-DICT-GUARD/tickets/01-ci-missing-key-guard.md b/.backlog/FIX-I18N-DICT-GUARD/tickets/01-ci-missing-key-guard.md new file mode 100644 index 0000000..ef25087 --- /dev/null +++ b/.backlog/FIX-I18N-DICT-GUARD/tickets/01-ci-missing-key-guard.md @@ -0,0 +1,38 @@ +Status: ready + +# 01 — CI job `i18n-guard`: unconditional block on `MISSING` + +## Parent + +`.backlog/FIX-I18N-DICT-GUARD/PRD.md` (ADR 0013) + +## What to build + +A new CI job, `i18n-guard`, in `ci.yml`: PR-triggered only, `ubuntu-latest` (ships `pwsh` +natively), `fetch-depth: 0` checkout — same shape as the existing `changelog-guard` job. + +For this ticket, the job runs a single check: it reuses `generate_i18n_dicts.ps1`'s existing +dry-run (no changes to that script) to detect any i18n key used in `src/` (via `ctld.tr()` or the +config YAML) that is missing from one or more of the four dictionaries +(`CTLD_i18n_en/fr/es/ko.lua`). If the dry-run output contains `MISSING`, the job fails. This check +has no bypass — it is always enforced, regardless of any PR label. `STALE` entries in the same +dry-run output are ignored by this job (non-blocking, same as the existing local +`.githooks/pre-push` hook). + +This ticket only adds the job and this one check. The second check (new-empty-stub detection with +the `skip-i18n` bypass) is a separate ticket that extends this same job. + +## Acceptance criteria + +- [ ] `i18n-guard` job exists in `ci.yml`, triggered only on `pull_request` events. +- [ ] The job fails when a PR introduces a `ctld.tr()` key (or a config-YAML `desc`/`name` label) + absent from any of the four dictionaries. +- [ ] The job passes when all keys used in `src/` exist in all four dictionaries, even if some + non-EN values are empty (that check is out of scope for this ticket). +- [ ] `STALE` entries reported by the dry-run do not fail the job. +- [ ] Manually verified with a throwaway test PR: one that removes a dictionary entry still used + by `src/` (fails), and one where the dictionaries are in sync (passes). + +## Blocked by + +None - can start immediately diff --git a/.backlog/FIX-I18N-DICT-GUARD/tickets/02-fix-translate-stub-detection.md b/.backlog/FIX-I18N-DICT-GUARD/tickets/02-fix-translate-stub-detection.md new file mode 100644 index 0000000..77f03fc --- /dev/null +++ b/.backlog/FIX-I18N-DICT-GUARD/tickets/02-fix-translate-stub-detection.md @@ -0,0 +1,48 @@ +Status: ready + +# 02 — Fix `translate_i18n.py` stub detection + test coverage for `tools/build/` + +## Parent + +`.backlog/FIX-I18N-DICT-GUARD/PRD.md` (ADR 0013) + +## What to build + +`translate_i18n.py`'s stub-selection logic currently treats a non-EN dictionary entry as +"untranslated" only when its value is identical to the English text (`lang_dict.get(k) == v`). But +`generate_i18n_dicts.ps1 -Apply` writes a freshly-added non-EN entry as an empty string (`""`), not +a copy of the English value — so a newly-added key is never selected for translation, with or +without `ANTHROPIC_API_KEY` set locally. Extend the predicate to also match an empty value, for any +key not listed in that dictionary's `__keep_en` block. + +While making this testable, extract the dictionary-file parser currently private to +`translate_i18n.py` (`_parse_dict`) into a new small shared module, `tools/build/ +i18n_dict_utils.py`. This module will also be consumed by ticket 03's diff-checker script, so both +tools rely on one parser rather than two that can drift apart. + +This is also the first test coverage `tools/build/` has ever had, so this ticket wires up the test +runner: a new step in `python-quality.yml` (already triggered on `tools/build/**` changes) installs +pytest with plain `pip` (no poetry — matches the existing "pip only" decision for +`translate_i18n.py`) and runs `pytest tools/build/`. + +## Acceptance criteria + +- [ ] The dict-file parser is extracted into `tools/build/i18n_dict_utils.py` and reused by + `translate_i18n.py` (no behavior change to parsing itself). +- [ ] `translate_i18n.py`'s stub-selection predicate treats a value of `""` as a stub needing + translation, in addition to a value identical to the EN text. +- [ ] A key listed in a dictionary's `__keep_en` block is never selected as a stub, regardless of + its value (empty or otherwise) — existing behavior preserved. +- [ ] Unit tests cover: empty value → stub; value identical to EN text → stub; a real translation → + not a stub; a `__keep_en`-listed key → never a stub. +- [ ] Unit tests cover the extracted parser against representative dict-file text fixtures + (well-formed entries, escaped quotes, a `__keep_en` block). +- [ ] `python-quality.yml` runs `pytest tools/build/` as part of its pipeline and the new tests + execute (and pass) in CI, not only locally. +- [ ] Manually verified: running `translate_i18n.py` locally (with `ANTHROPIC_API_KEY` set) against + a dictionary containing a freshly-added `""` entry results in that entry being sent for + translation. + +## Blocked by + +None - can start immediately (parallel with ticket 01) diff --git a/.backlog/FIX-I18N-DICT-GUARD/tickets/03-ci-empty-stub-diff-guard.md b/.backlog/FIX-I18N-DICT-GUARD/tickets/03-ci-empty-stub-diff-guard.md new file mode 100644 index 0000000..22ba5ff --- /dev/null +++ b/.backlog/FIX-I18N-DICT-GUARD/tickets/03-ci-empty-stub-diff-guard.md @@ -0,0 +1,44 @@ +Status: ready + +# 03 — New-empty-stub diff detection + `skip-i18n` bypass + +## Parent + +`.backlog/FIX-I18N-DICT-GUARD/PRD.md` (ADR 0013) + +## What to build + +A new script, `tools/build/check_i18n_diff.py`, that compares each non-EN dictionary +(`CTLD_i18n_fr/es/ko.lua`) at the PR's base ref vs. its head: for each key, if the head value is +`""` where the base value was either non-empty or the key was absent from base entirely, that key +is reported as a newly-introduced empty stub — genuinely new to this PR, not part of the +pre-existing debt already on `develop`. The script reuses `tools/build/i18n_dict_utils.py` (from +ticket 02) for parsing, and exits non-zero listing the offending keys when any are found. + +Extend the `i18n-guard` job added in ticket 01 with a new step running this script. Unlike the +`MISSING` check, this step is bypassable: read the PR's labels for `skip-i18n` into an env var +(same pattern `changelog-guard` already uses for `skip-changelog`) and skip the step entirely when +present. + +## Acceptance criteria + +- [ ] `check_i18n_diff.py` exists, reuses the shared parser from ticket 02, and correctly + classifies: a key newly added and empty (flagged); a key already empty at the PR base and + still empty at head (not flagged — pre-existing debt); a key translated at base then blanked + at head (flagged); a key with a real translation (not flagged). +- [ ] Unit tests cover all four cases above using base/head text fixture pairs — no git or network + access required for the core comparison logic. +- [ ] The `i18n-guard` job (ticket 01) runs this check as an additional step for every PR. +- [ ] The step fails the job when a newly-introduced empty stub is found in fr, es, or ko. +- [ ] The step is skipped (job passes regardless of stub content) when the PR carries the + `skip-i18n` label. +- [ ] The existing `MISSING` check from ticket 01 is unaffected by the `skip-i18n` label — it still + always runs and still always blocks. +- [ ] Manually verified with a throwaway test PR: a PR adding a new `ctld.tr()` call with an empty + non-EN stub fails the job; applying `skip-i18n` makes it pass; filling in the translation + instead of applying the label also makes it pass. + +## Blocked by + +- Ticket 01 (`01-ci-missing-key-guard`) — the `i18n-guard` job must exist to extend. +- Ticket 02 (`02-fix-translate-stub-detection`) — reuses `tools/build/i18n_dict_utils.py`. diff --git a/.backlog/README.md b/.backlog/README.md index 6989105..8c78b78 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -17,6 +17,7 @@ authored **per lot, when the lot is started** (not in batch). | `CHORE-UNTRACK-BUILT-ENGINE` | merged (PR #110) | `CTLD.lua` is generated and committed anyway. `.gitignore` line 5 calls it deliberate — *"available at repo root for DCS missions"* — a bootstrap-era reason that no longer holds: nothing points at it, and **VMCT, the assumed consumer, does not** (its `vendored.yaml` pins `2.0.0-rc3` with *"re-download the CTLD.lua asset from the matching release"* and watches `github-release`). The cost is paid every time: **26 of the 28 merges touching `src/` over 30 days carried the regenerated file** — a one-megabyte generated diff nobody reviews, and a guaranteed conflict between parallel PRs. The trap, measured rather than assumed: `python-quality` runs on ubuntu and never builds the engine, so deleting the file alone would drop the suite from **262 passed** to **234 passed / 27 skipped / 1 failed** while CI stayed green. So: build the engine in that job first (`merge_CTLD.ps1` made portable — two `\` paths), fix `test_inject_into_miz` (it crashes instead of skipping), then untrack. Depends on `FEAT-DEV-BUILD-CHANNEL`, which is what keeps the engine downloadable. No history rewriting (471 blobs = 2.8 MiB packed). | `chore/untrack-built-engine` | | `FEAT-CUSTOM-BEACON-SOUNDS` | merged (PR #112) | A beacon sound the Mission Maker chooses, instead of a text box naming a file the tool never installs. Grilled with Zip on 2026-08-08: custom is **derived** from `radioSound` (no second key that could disagree with the engine); a chosen file enters the mission under a **reserved name** (**ADR 0012**) because a Mission Maker whose own file is called `beacon.ogg` would otherwise see it silently overwritten; the original name survives as a schema-only label (`FIX-TOOL-I18N-LANG`'s lesson — a catalogue key would make every pre-lot configuration report a missing setting at mission start); the bytes are read at selection and live in the session, so reopening a `.miz` reinstalls them **on another machine with the original file gone**. `OggS` checked, no size cap, nothing deleted from the archive. | `feature/custom-beacon-sounds` | | `FEAT-DEV-BUILD-CHANNEL` | merged (PR #109) | An exe to hand a tester between two releases. Zip's first idea — the exe grafting an arbitrary `CTLD.lua` into a copy of itself — **works** (verified: rc6 + 1.17 MB appended still runs) and was dropped anyway: it pairs a new engine with the exe's older schema and interface, an unsigned exe altered after the build reads as tampered, and `--version` would keep lying. The `build-exe` job already produces a complete exe from a commit in **2 min 06 s** on free public-repo runners; it only lacked a trigger. Built on every merge into `develop`, published as an artifact **and** a floating `dev` pre-release (an artifact answers `401` to an anonymous download), versioned `-`. | `feature/dev-build-channel` | +| [`FIX-I18N-DICT-GUARD`](FIX-I18N-DICT-GUARD/PRD.md) | ready | Reported by **FullGas**: some F10 menu entries stay untranslated in Korean. Root cause: `translate_i18n.py`'s stub detection never matches the `""` empty-stub convention `generate_i18n_dicts.ps1 -Apply` actually writes, so freshly-added keys are never picked up for translation even with `ANTHROPIC_API_KEY` set — and the only existing guard (`.githooks/pre-push`, opt-in, `MISSING`-only) doesn't check translation content. New CI job `i18n-guard`, diff-scoped like `changelog-guard`: blocks unconditionally on `MISSING`, blocks by default (bypassable via `skip-i18n` label) on a newly-introduced empty non-EN entry. Fixes `translate_i18n.py`'s stub detection in the same lot. Pre-existing debt (91 KO + 76 ES empty entries) explicitly out of scope — a follow-up repayment lot must follow immediately. See **ADR 0013**. | — | | [`FIX-FIELD-EXTRACT-CASUALTIES`](FIX-FIELD-EXTRACT-CASUALTIES/PRD.md) | merged (PR #111) | Field extraction (`embarkFromField`) returns the troop count frozen at deploy time instead of the survivor count — an undeclared legacy-parity deviation. Fix counts live DCS units (excluding `SVNT_*` servants) at extraction time; adds troop counts to the "Extract from field" menu labels; auto-despawns an orphaned mortar servant when its operator dies leaving zero real troops. Also fixed a pre-existing bug where `onUnitDead` never fired in-game. | `fix/field-extract-casualties` | | [`FIX-MENU-DOUBLE-MULTICREW`](FIX-MENU-DOUBLE-MULTICREW/PRD.md) | merged (PR #106) | F10 menu duplication on multi-crew aircraft (CH-47 pilot + copilot); menu loss when one crew member leaves a shared group. | — | | `DOCS-RELEASE-LIFECYCLE` | merged (PR #107) | Question from **FullGas**: does the exe download the latest build after a merge, or does a release have to be published? The answer is only in the code — `FEAT-ONE-CLICK-INSTALL` chose to **bundle** the engine (`--add-data "../../CTLD.lua;ctld_data"`, `release.yml` triggered on `published-v*` only), so an exe installs the engine of its own release, offline, and never updates itself. Nothing user-facing says it, so the natural assumption is the opposite. Documented in the README and the mission-maker guide (EN + FR), with the pre-release detail that goes with it: every rc publishes as a pre-release, so **no release carries the *Latest* badge** today and `releases/latest` redirects to the Releases index. No mechanism change. | `docs/release-lifecycle` | diff --git a/dev/adr/0013-ci-i18n-dict-guard.md b/dev/adr/0013-ci-i18n-dict-guard.md new file mode 100644 index 0000000..435cae9 --- /dev/null +++ b/dev/adr/0013-ci-i18n-dict-guard.md @@ -0,0 +1,76 @@ +# ADR 0013 — CI-enforced i18n dictionary guard, diff-scoped with a translation-only bypass + +**Date:** 2026-08-10 +**Status:** Accepted +**Lot:** FIX-I18N-DICT-GUARD (to be formalized via `to-prd`) + +## Context + +`ctld.tr()` keys are synchronised into the four dictionaries (`CTLD_i18n_en/fr/es/ko.lua`) by +`generate_i18n_dicts.ps1 -Apply`, run unconditionally by `merge_CTLD.ps1`. It appends missing keys +as empty stubs (`= ""` for non-EN) — it does not translate. The intended translation step, +`translate_i18n.py` (Claude Haiku, `BUILD-DICT-AI-TRANSLATE`, PR #60), only runs locally when +`ANTHROPIC_API_KEY` is set — a deliberate choice in that lot's PRD, absent from CI on purpose. + +Two gaps let untranslated entries reach `develop` undetected: + +1. **`translate_i18n.py`'s stub definition never matches what `generate_i18n_dicts.ps1` writes.** + It treats a stub as "value identical to the EN text" (the original PRD's definition of + "untranslated"), but new keys are appended as `""`, not a copy of the EN value. So even with + `ANTHROPIC_API_KEY` set, freshly-added keys are never selected for translation. +2. **The only guard that exists (`.githooks/pre-push`) is opt-in** (`git config core.hooksPath + .githooks`, a manual step after clone) and checks *only* for `MISSING` keys — an entry present + with an empty value already satisfies it. It is not active by default; verified not active on + the machine that surfaced this issue. + +Consequence, verified against `develop` on 2026-08-10: 91 empty entries in `CTLD_i18n_ko.lua`, 76 +in `CTLD_i18n_es.lua`. `CTLD_i18n_fr.lua` has none — filled by hand, not through the auto-translate +path, in `040bf8c`. + +## Decision + +**A new CI job, `i18n-guard`** (own job, PR-triggered, `ubuntu-latest` — ships `pwsh` natively, no +`windows-latest` needed), modeled on the existing `changelog-guard` job: + +- **Blocks unconditionally** on any key `ctld.tr()`/the config YAML introduces that is `MISSING` + from any of the four dictionaries. No bypass: fixing it costs a local `merge_CTLD.ps1` run, no + API key required. +- **Blocks by default, bypassable via the `skip-i18n` label**, on any *newly introduced* non-EN + entry that stays empty (`""`) — diff-scoped against the PR's base, the same technique + `changelog-guard` uses. Scoped rather than absolute: an absolute check would fail every PR today + against the 167 pre-existing empty entries. +- **`translate_i18n.py`'s stub detection is fixed** in the same lot to also treat `== ""` as a + stub needing translation (not only `== EN value`) — the guard is only satisfiable locally if the + tool it depends on actually works. +- **The 167 pre-existing empty entries are explicitly out of scope.** This guard only prevents new + debt; repaying the existing debt is a separate, immediately-following lot (tracked outside this + ADR). +- **`.githooks/pre-push` is left unchanged** (`MISSING`-only, non-blocking on stale/empty). CI is + the single source of enforcement truth; the hook remains a best-effort local pre-check, not + authoritative and not always active. + +## Considered options + +- **Auto-fix in CI** (a bot job runs `generate_i18n_dicts.ps1 -Apply` + `translate_i18n.py` and + commits the result back to the PR branch). Rejected: requires `ANTHROPIC_API_KEY` as a shared + GitHub secret and a token with write access to contributors' PR branches — reverses the + deliberate "local-only, absent from CI" decision in `BUILD-DICT-AI-TRANSLATE`, and introduces + bot-commit/re-trigger loops this repo doesn't otherwise have. +- **Absolute (non-diff) guard.** Rejected: would fail every PR immediately against the existing + 167-entry debt, coupling this lot to a full translation pass before it could ship at all. +- **No bypass label.** Rejected: the repo is public and `ANTHROPIC_API_KEY` is not guaranteed + available to every contributor; a zero-tolerance guard would block legitimate external + contributions on translation alone. +- **Extend the existing `build` job** instead of a new job. Rejected: `build` runs on + `push`+`pull_request` generically and isn't scoped to a PR-base diff, and conflates artifact + production with a content guard — the repo's existing pattern is one job per concern + (`lua-lint`, `gitleaks`, `changelog-guard`). + +## Consequences + +- `skip-i18n` bypasses only the "must be non-empty" rule, never `MISSING` — so a bypassed PR can + still land new empty stubs, growing the debt bucket the follow-up repayment lot has to cover. + That lot must account for debt accrued after this guard ships, not only the 167 counted here. +- A developer only learns about untranslated debt at PR time (CI), not at `git push` time — the + local hook was deliberately left MISSING-only rather than duplicating the diff-vs-base check in + bash. diff --git a/dev/adr/README.md b/dev/adr/README.md index 2d36fd6..fd6df06 100644 --- a/dev/adr/README.md +++ b/dev/adr/README.md @@ -18,3 +18,4 @@ Retroactive ADRs document decisions already made during the v2.0.0 rewrite. | [0010](0010-startup-report-two-family-separation.md) | Startup report and two-family output separation | Accepted | | [0011](0011-complete-yaml-config-and-webapp-tooling.md) | Complete-YAML config model and web-app tooling for ctld-tools | Accepted — supersedes 0008 and 0009 points 2 & 3 | | [0012](0012-canonical-names-for-custom-beacon-sounds.md) | Canonical file names for custom beacon sounds | Accepted | +| [0013](0013-ci-i18n-dict-guard.md) | CI-enforced i18n dictionary guard, diff-scoped with a translation-only bypass | Accepted | From 6c32e24cf675c7d0b937e4d7b0d75fba4f5fad83 Mon Sep 17 00:00:00 2001 From: FullGas1 <51051389+FullGas1@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:03:01 +0200 Subject: [PATCH 2/4] fix(i18n): translate_i18n.py now detects empty stubs (ticket 02) Its stub-selection predicate only ever matched a non-EN value identical to the EN text. generate_i18n_dicts.ps1 -Apply writes a freshly-added non-EN entry as "" instead, so a new key was never selected for translation, with or without ANTHROPIC_API_KEY set. Extract the dict-file parser into a shared tools/build/i18n_dict_utils.py (also consumed by ticket 03's diff checker), extend the stub predicate to match "", and add tools/build/'s first test coverage. Wire `pytest tools/build/` into python-quality.yml so it runs in CI. Part of FIX-I18N-DICT-GUARD (ADR 0013). --- .github/workflows/python-quality.yml | 8 +++++ tools/build/i18n_dict_utils.py | 36 +++++++++++++++++++ tools/build/test_i18n_dict_utils.py | 43 ++++++++++++++++++++++ tools/build/test_translate_i18n.py | 39 ++++++++++++++++++++ tools/build/translate_i18n.py | 53 +++++++++++----------------- 5 files changed, 147 insertions(+), 32 deletions(-) create mode 100644 tools/build/i18n_dict_utils.py create mode 100644 tools/build/test_i18n_dict_utils.py create mode 100644 tools/build/test_translate_i18n.py diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml index f555d3b..1a22858 100644 --- a/.github/workflows/python-quality.yml +++ b/.github/workflows/python-quality.yml @@ -59,3 +59,11 @@ jobs: # == fresh emit from src/CTLD_config.yaml) and the embed/wrap round-trip. - name: Tests (pytest + coverage) run: poetry run pytest + + # tools/build/ is standalone scripts (no poetry project of its own, "pip only" per + # translate_i18n.py's original design) — install pytest directly and run against it. + - name: Tests (tools/build/, plain pytest) + working-directory: ${{ github.workspace }} + run: | + pip install pytest + pytest tools/build/ diff --git a/tools/build/i18n_dict_utils.py b/tools/build/i18n_dict_utils.py new file mode 100644 index 0000000..a6fde25 --- /dev/null +++ b/tools/build/i18n_dict_utils.py @@ -0,0 +1,36 @@ +"""i18n_dict_utils.py — shared parsing helpers for src/CTLD_i18n_*.lua dictionary files. + +Used by translate_i18n.py and check_i18n_diff.py so both tools parse dict-file content the +same way instead of maintaining their own copy that can drift. +""" + +import re + +_ENTRY_RE = re.compile(r'ctld\.i18n\["[^"]+"\]\["([^"]+)"\]\s*=\s*"((?:[^"\\]|\\.)*)"\s*') +_KEEP_EN_RE = re.compile(r'\["([^"]+)"\]\s*=\s*true') + + +def parse_dict(text: str) -> dict[str, str]: + """Parse a CTLD_i18n_XX.lua dict file's content into {key: value} (excludes translation_version).""" + result: dict[str, str] = {} + for m in _ENTRY_RE.finditer(text): + key, val = m.group(1), m.group(2) + if key != "translation_version": + result[key] = val + return result + + +def parse_keep_en(text: str) -> set[str]: + """Parse the keys listed in a dict file's `__keep_en = { ["key"] = true, ... }` block.""" + keep_en: set[str] = set() + in_block = False + for line in text.splitlines(): + if "__keep_en" in line and "=" in line and "{" in line: + in_block = True + if in_block: + m = _KEEP_EN_RE.search(line) + if m: + keep_en.add(m.group(1)) + if "}" in line and "__keep_en" not in line: + in_block = False + return keep_en diff --git a/tools/build/test_i18n_dict_utils.py b/tools/build/test_i18n_dict_utils.py new file mode 100644 index 0000000..af787da --- /dev/null +++ b/tools/build/test_i18n_dict_utils.py @@ -0,0 +1,43 @@ +"""Parsing a CTLD_i18n_XX.lua dict file's content into keys/values and __keep_en set.""" + +from i18n_dict_utils import parse_dict, parse_keep_en + + +def test_parses_well_formed_entries(): + text = ( + 'ctld.i18n["ko"]["Drop Crate(s)"] = "테스트"\n' + 'ctld.i18n["ko"]["Cut Slingload"] = ""\n' + ) + assert parse_dict(text) == { + "Drop Crate(s)": "테스트", + "Cut Slingload": "", + } + + +def test_excludes_translation_version(): + text = ( + 'ctld.i18n["ko"].translation_version = "1.17"\n' + 'ctld.i18n["ko"]["Actions"] = "액션"\n' + ) + assert "translation_version" not in parse_dict(text) + + +def test_handles_escaped_quotes_in_value(): + text = 'ctld.i18n["ko"]["Greeting"] = "Say \\"Hi\\""\n' + assert parse_dict(text) == {"Greeting": 'Say \\"Hi\\"'} + + +def test_parse_keep_en_collects_block_keys_only(): + text = ( + 'ctld.i18n["fr"].__keep_en = {\n' + ' ["Humvee - MG"] = true,\n' + ' ["BTR-D"] = true,\n' + "}\n" + 'ctld.i18n["fr"]["Actions"] = "Actions"\n' + ) + assert parse_keep_en(text) == {"Humvee - MG", "BTR-D"} + + +def test_parse_keep_en_empty_when_no_block(): + text = 'ctld.i18n["fr"]["Actions"] = "Actions"\n' + assert parse_keep_en(text) == set() diff --git a/tools/build/test_translate_i18n.py b/tools/build/test_translate_i18n.py new file mode 100644 index 0000000..7459a6f --- /dev/null +++ b/tools/build/test_translate_i18n.py @@ -0,0 +1,39 @@ +"""Stub detection: which entries translate_i18n.py sends to Claude for translation.""" + +from translate_i18n import _collect_stubs, _is_stub + + +def test_empty_value_is_a_stub(): + assert _is_stub("", "Actions") is True + + +def test_value_identical_to_en_is_a_stub(): + assert _is_stub("Actions", "Actions") is True + + +def test_real_translation_is_not_a_stub(): + assert _is_stub("액션", "Actions") is False + + +def test_collect_stubs_finds_empty_entries(): + en_dict = {"Actions": "Actions", "Cut Slingload": "Cut Slingload"} + lang_dict = {"Actions": "", "Cut Slingload": "잘라내기"} + assert _collect_stubs(en_dict, lang_dict, keep_en=set()) == {"Actions": "Actions"} + + +def test_collect_stubs_finds_en_copies(): + en_dict = {"Actions": "Actions"} + lang_dict = {"Actions": "Actions"} + assert _collect_stubs(en_dict, lang_dict, keep_en=set()) == {"Actions": "Actions"} + + +def test_collect_stubs_excludes_keep_en_keys_even_when_empty(): + en_dict = {"Humvee - MG": "Humvee - MG"} + lang_dict = {"Humvee - MG": ""} + assert _collect_stubs(en_dict, lang_dict, keep_en={"Humvee - MG"}) == {} + + +def test_collect_stubs_skips_real_translations(): + en_dict = {"Actions": "Actions"} + lang_dict = {"Actions": "액션"} + assert _collect_stubs(en_dict, lang_dict, keep_en=set()) == {} diff --git a/tools/build/translate_i18n.py b/tools/build/translate_i18n.py index 15c97b1..85dfccd 100644 --- a/tools/build/translate_i18n.py +++ b/tools/build/translate_i18n.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """translate_i18n.py — Fill empty i18n stubs via the Claude API. -Reads src/CTLD_i18n_*.lua, identifies stubs (value == EN value), +Reads src/CTLD_i18n_*.lua, identifies stubs (empty, or value == EN value), and fills them using Claude claude-haiku-4-5-20251001, one batch call per language. Writes results back in-place. Non-blocking: any error prints a WARNING and exits 0. @@ -16,6 +16,8 @@ import json from pathlib import Path +from i18n_dict_utils import parse_dict, parse_keep_en + # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- @@ -23,20 +25,21 @@ LANG_NAMES = {"fr": "French", "es": "Spanish", "ko": "Korean"} # --------------------------------------------------------------------------- -# Parse a CTLD_i18n_XX.lua dict file into {key: value} (excludes translation_version) +# A stub is an entry not worth keeping as-is: empty (generate_i18n_dicts.ps1's +# convention for a freshly-added non-EN key), or still a verbatim copy of the +# EN text (the pre-existing convention this predicate used to check alone). # --------------------------------------------------------------------------- -_ENTRY_RE = re.compile(r'ctld\.i18n\["[^"]+"\]\["([^"]+)"\]\s*=\s*"((?:[^"\\]|\\.)*)"\s*') -# Matches keys inside a __keep_en = { ["key"] = true, ... } block -_KEEP_EN_RE = re.compile(r'\["([^"]+)"\]\s*=\s*true') +def _is_stub(lang_value: str | None, en_value: str) -> bool: + return lang_value == en_value or lang_value == "" -def _parse_dict(path: Path) -> dict[str, str]: - text = path.read_text(encoding="utf-8") - result = {} - for m in _ENTRY_RE.finditer(text): - key, val = m.group(1), m.group(2) - if key != "translation_version": - result[key] = val - return result + +def _collect_stubs( + en_dict: dict[str, str], lang_dict: dict[str, str], keep_en: set[str] +) -> dict[str, str]: + return { + k: v for k, v in en_dict.items() + if k not in keep_en and _is_stub(lang_dict.get(k), v) + } # --------------------------------------------------------------------------- # Write translated values back into the Lua source file @@ -93,7 +96,7 @@ def main() -> int: print(f"[translate-i18n] WARNING: EN dict not found at {en_path}", flush=True) return 0 - en_dict = _parse_dict(en_path) + en_dict = parse_dict(en_path.read_text(encoding="utf-8")) if not en_dict: print("[translate-i18n] WARNING: EN dict is empty — nothing to translate.", flush=True) return 0 @@ -113,24 +116,10 @@ def main() -> int: continue lang_text = lang_path.read_text(encoding="utf-8") - lang_dict = _parse_dict(lang_path) - - # Keys marked intentionally EN in __keep_en block — exclude from stub detection - keep_en: set[str] = set() - in_block = False - for line in lang_text.splitlines(): - if "__keep_en" in line and "=" in line and "{" in line: - in_block = True - if in_block: - m = _KEEP_EN_RE.search(line) - if m: - keep_en.add(m.group(1)) - if "}" in line and "__keep_en" not in line: - in_block = False - - # A stub = key present in lang dict with value == EN value, not in __keep_en - stubs = {k: v for k, v in en_dict.items() - if lang_dict.get(k) == v and k not in keep_en} + lang_dict = parse_dict(lang_text) + keep_en = parse_keep_en(lang_text) + + stubs = _collect_stubs(en_dict, lang_dict, keep_en) if not stubs: print(f"[translate-i18n] {lang}: no stubs — skipped.", flush=True) From 4d8669ce7e405a00b7985f974850890abf1bc9bb Mon Sep 17 00:00:00 2001 From: FullGas1 <51051389+FullGas1@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:03:25 +0200 Subject: [PATCH 3/4] feat(ci): add i18n-guard, blocking new missing or untranslated keys New PR-only CI job i18n-guard, modeled on changelog-guard: covers tickets 01 and 03 of FIX-I18N-DICT-GUARD. Unconditional block on a ctld.tr or config-YAML key missing from any of the four dictionaries - reuses generate_i18n_dicts.ps1's existing dry-run; STALE stays non-blocking, unchanged. Diff-scoped block against the PR base on a newly-introduced empty non-EN entry, via the new tools/build/check_i18n_diff.py. Bypassable with the skip-i18n label for contributors without local ANTHROPIC_API_KEY access. Pre-existing debt already on develop, 91 KO plus 76 ES empty entries, is untouched by this diff-scoped check - a follow-up lot repays it. CHANGELOG updated. Closes FIX-I18N-DICT-GUARD, see ADR 0013. --- .github/workflows/ci.yml | 45 +++++++++++++++++ CHANGELOG.md | 18 +++++++ tools/build/check_i18n_diff.py | 77 +++++++++++++++++++++++++++++ tools/build/test_check_i18n_diff.py | 40 +++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 tools/build/check_i18n_diff.py create mode 100644 tools/build/test_check_i18n_diff.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e11d14d..f8af3cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,4 +194,49 @@ jobs: echo "No src/ changes — CHANGELOG guard not applicable." fi + # ───────────────────────────────────────────────────────────── + # i18n dictionary guard — blocks a PR that introduces a ctld.tr()/config-YAML + # key missing from one or more of the four i18n dictionaries (unconditional, + # no escape hatch: fixing it costs a local merge_CTLD.ps1 run, no API key + # required), or that leaves a newly-introduced non-EN entry empty (escape + # hatch: the 'skip-i18n' label, for contributors without local + # ANTHROPIC_API_KEY access). Diff-scoped against the PR base, so the + # pre-existing dictionary debt already on develop never blocks an unrelated + # PR. Runs on pull requests only. STALE keys are reported by the MISSING + # dry-run but are not blocking here. See ADR 0013. + # ───────────────────────────────────────────────────────────── + i18n-guard: + name: i18n Dictionary Guard + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Check for i18n keys missing from a dictionary + shell: pwsh + run: | + $output = & tools/build/generate_i18n_dicts.ps1 2>&1 | Out-String + Write-Host $output + if ($output -match "MISSING") { + Write-Host "::error::This PR introduces i18n key(s) missing from one or more dictionaries. Run tools/build/merge_CTLD.ps1 locally (or generate_i18n_dicts.ps1 -Apply) to add the stubs, then commit the result." + exit 1 + } + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + - name: Check for newly-introduced empty i18n entries + env: + HAS_SKIP: ${{ contains(github.event.pull_request.labels.*.name, 'skip-i18n') }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if [ "$HAS_SKIP" = "true" ]; then + echo "skip-i18n label present — empty-stub check bypassed." + exit 0 + fi + if ! python tools/build/check_i18n_diff.py "$BASE_SHA"; then + echo "::error::This PR leaves a newly-introduced i18n entry untranslated. Run tools/build/merge_CTLD.ps1 locally with ANTHROPIC_API_KEY set to translate it, or apply the 'skip-i18n' label if a maintainer will translate it later." + exit 1 + fi + # Releases are handled by .github/workflows/release.yml (tag published-v*). diff --git a/CHANGELOG.md b/CHANGELOG.md index e992848..f7148ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ Versioning follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed — CI now catches untranslated i18n menu entries before merge (FIX-I18N-DICT-GUARD) + +- **`translate_i18n.py`'s stub detection was broken since it shipped**: it only recognised a + non-EN dictionary value identical to the English text as "untranslated" — never an empty + string, which is exactly what `generate_i18n_dicts.ps1 -Apply` writes for a freshly-added key. + So a new `ctld.tr()` string was never picked up for translation, with or without + `ANTHROPIC_API_KEY` set locally. Fixed: an empty value is now also treated as a stub. +- **New CI job `i18n-guard`** (diff-scoped against the PR base, same shape as `changelog-guard`): + fails a PR that introduces an i18n key missing from any of the four dictionaries + (`CTLD_i18n_en/fr/es/ko.lua`, unconditional — no bypass), or that leaves a *newly introduced* + non-EN entry empty (bypassable via the `skip-i18n` label, for contributors without local + `ANTHROPIC_API_KEY` access). Does not retroactively block on pre-existing untranslated entries. +- **New `tools/build/check_i18n_diff.py`** + shared parser `tools/build/i18n_dict_utils.py` + (extracted from `translate_i18n.py`, now reused by both scripts). +- Out of scope here: the 91 `CTLD_i18n_ko.lua` and 76 `CTLD_i18n_es.lua` entries already empty on + `develop` — a follow-up lot repays that debt; this guard only prevents it from growing further. +- See ADR 0013. + ### Fixed — field-extracted troop count now reflects casualties (FIX-FIELD-EXTRACT-CASUALTIES) - **Extracting a dropped troop group from the field now counts live survivors**, not the diff --git a/tools/build/check_i18n_diff.py b/tools/build/check_i18n_diff.py new file mode 100644 index 0000000..80b99f5 --- /dev/null +++ b/tools/build/check_i18n_diff.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""check_i18n_diff.py — Fail if a PR introduces a newly-empty non-EN i18n entry. + +Compares each non-EN dictionary (src/CTLD_i18n_fr/es/ko.lua) at a given base ref against its +current (head) content. A key is "newly empty" when its head value is "" and it either did not +exist at base or held a non-empty value there — i.e. genuinely new to this PR, not part of the +pre-existing debt already on develop (a key already empty at base is left alone). + +Usage: python tools/build/check_i18n_diff.py +Exit: 0 if no newly-empty entries; 1 otherwise (offending keys printed to stdout). +""" + +import subprocess +import sys +from pathlib import Path + +from i18n_dict_utils import parse_dict + +LANGS = ("fr", "es", "ko") + + +def find_new_empty_keys(base_text: str, head_text: str) -> list[str]: + base_dict = parse_dict(base_text) + head_dict = parse_dict(head_text) + new_empty = [ + key + for key, head_value in head_dict.items() + if head_value == "" and base_dict.get(key) != "" + ] + return sorted(new_empty) + + +def _git_show(ref: str, path: str, cwd: Path) -> str: + result = subprocess.run( + ["git", "show", f"{ref}:{path}"], + cwd=cwd, capture_output=True, text=True, encoding="utf-8", + ) + if result.returncode != 0: + # Absent at base (new file, or path didn't exist yet) — every head key is new. + return "" + return result.stdout + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: check_i18n_diff.py ", file=sys.stderr) + return 2 + base_ref = sys.argv[1] + + repo_root = Path(__file__).resolve().parent.parent.parent + src_dir = repo_root / "src" + + total = 0 + for lang in LANGS: + head_path = src_dir / f"CTLD_i18n_{lang}.lua" + if not head_path.exists(): + continue + head_text = head_path.read_text(encoding="utf-8") + base_text = _git_show(base_ref, f"src/CTLD_i18n_{lang}.lua", cwd=repo_root) + + new_empty = find_new_empty_keys(base_text, head_text) + if new_empty: + print(f"[check-i18n-diff] {lang}: {len(new_empty)} newly-empty key(s):") + for key in new_empty: + print(f" - {key}") + total += len(new_empty) + + if total: + print(f"[check-i18n-diff] {total} newly-empty i18n entry/entries introduced by this PR.") + return 1 + + print("[check-i18n-diff] No newly-empty i18n entries.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/build/test_check_i18n_diff.py b/tools/build/test_check_i18n_diff.py new file mode 100644 index 0000000..ef64c86 --- /dev/null +++ b/tools/build/test_check_i18n_diff.py @@ -0,0 +1,40 @@ +"""New-empty-stub detection: which i18n keys a PR newly blanked out, vs. pre-existing debt.""" + +from check_i18n_diff import find_new_empty_keys + + +def test_new_key_added_empty_is_flagged(): + base_text = "" + head_text = 'ctld.i18n["ko"]["Actions"] = ""\n' + assert find_new_empty_keys(base_text, head_text) == ["Actions"] + + +def test_already_empty_at_base_is_not_flagged(): + base_text = 'ctld.i18n["ko"]["Actions"] = ""\n' + head_text = 'ctld.i18n["ko"]["Actions"] = ""\n' + assert find_new_empty_keys(base_text, head_text) == [] + + +def test_translated_then_blanked_is_flagged(): + base_text = 'ctld.i18n["ko"]["Actions"] = "액션"\n' + head_text = 'ctld.i18n["ko"]["Actions"] = ""\n' + assert find_new_empty_keys(base_text, head_text) == ["Actions"] + + +def test_real_translation_is_not_flagged(): + base_text = 'ctld.i18n["ko"]["Actions"] = ""\n' + head_text = 'ctld.i18n["ko"]["Actions"] = "액션"\n' + assert find_new_empty_keys(base_text, head_text) == [] + + +def test_multiple_keys_only_new_empties_reported(): + base_text = ( + 'ctld.i18n["ko"]["Already Empty"] = ""\n' + 'ctld.i18n["ko"]["Translated"] = "번역됨"\n' + ) + head_text = ( + 'ctld.i18n["ko"]["Already Empty"] = ""\n' + 'ctld.i18n["ko"]["Translated"] = "번역됨"\n' + 'ctld.i18n["ko"]["New Key"] = ""\n' + ) + assert find_new_empty_keys(base_text, head_text) == ["New Key"] From b857431d623d2232e0861154b64a435c337758b6 Mon Sep 17 00:00:00 2001 From: FullGas1 <51051389+FullGas1@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:05:02 +0200 Subject: [PATCH 4/4] chore(backlog): mark FIX-I18N-DICT-GUARD as merged (PR #115) Per the repo's default workflow, the README index line is set in the PR itself rather than as a separate post-merge commit. --- .backlog/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.backlog/README.md b/.backlog/README.md index 8c78b78..d432297 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -17,7 +17,7 @@ authored **per lot, when the lot is started** (not in batch). | `CHORE-UNTRACK-BUILT-ENGINE` | merged (PR #110) | `CTLD.lua` is generated and committed anyway. `.gitignore` line 5 calls it deliberate — *"available at repo root for DCS missions"* — a bootstrap-era reason that no longer holds: nothing points at it, and **VMCT, the assumed consumer, does not** (its `vendored.yaml` pins `2.0.0-rc3` with *"re-download the CTLD.lua asset from the matching release"* and watches `github-release`). The cost is paid every time: **26 of the 28 merges touching `src/` over 30 days carried the regenerated file** — a one-megabyte generated diff nobody reviews, and a guaranteed conflict between parallel PRs. The trap, measured rather than assumed: `python-quality` runs on ubuntu and never builds the engine, so deleting the file alone would drop the suite from **262 passed** to **234 passed / 27 skipped / 1 failed** while CI stayed green. So: build the engine in that job first (`merge_CTLD.ps1` made portable — two `\` paths), fix `test_inject_into_miz` (it crashes instead of skipping), then untrack. Depends on `FEAT-DEV-BUILD-CHANNEL`, which is what keeps the engine downloadable. No history rewriting (471 blobs = 2.8 MiB packed). | `chore/untrack-built-engine` | | `FEAT-CUSTOM-BEACON-SOUNDS` | merged (PR #112) | A beacon sound the Mission Maker chooses, instead of a text box naming a file the tool never installs. Grilled with Zip on 2026-08-08: custom is **derived** from `radioSound` (no second key that could disagree with the engine); a chosen file enters the mission under a **reserved name** (**ADR 0012**) because a Mission Maker whose own file is called `beacon.ogg` would otherwise see it silently overwritten; the original name survives as a schema-only label (`FIX-TOOL-I18N-LANG`'s lesson — a catalogue key would make every pre-lot configuration report a missing setting at mission start); the bytes are read at selection and live in the session, so reopening a `.miz` reinstalls them **on another machine with the original file gone**. `OggS` checked, no size cap, nothing deleted from the archive. | `feature/custom-beacon-sounds` | | `FEAT-DEV-BUILD-CHANNEL` | merged (PR #109) | An exe to hand a tester between two releases. Zip's first idea — the exe grafting an arbitrary `CTLD.lua` into a copy of itself — **works** (verified: rc6 + 1.17 MB appended still runs) and was dropped anyway: it pairs a new engine with the exe's older schema and interface, an unsigned exe altered after the build reads as tampered, and `--version` would keep lying. The `build-exe` job already produces a complete exe from a commit in **2 min 06 s** on free public-repo runners; it only lacked a trigger. Built on every merge into `develop`, published as an artifact **and** a floating `dev` pre-release (an artifact answers `401` to an anonymous download), versioned `-`. | `feature/dev-build-channel` | -| [`FIX-I18N-DICT-GUARD`](FIX-I18N-DICT-GUARD/PRD.md) | ready | Reported by **FullGas**: some F10 menu entries stay untranslated in Korean. Root cause: `translate_i18n.py`'s stub detection never matches the `""` empty-stub convention `generate_i18n_dicts.ps1 -Apply` actually writes, so freshly-added keys are never picked up for translation even with `ANTHROPIC_API_KEY` set — and the only existing guard (`.githooks/pre-push`, opt-in, `MISSING`-only) doesn't check translation content. New CI job `i18n-guard`, diff-scoped like `changelog-guard`: blocks unconditionally on `MISSING`, blocks by default (bypassable via `skip-i18n` label) on a newly-introduced empty non-EN entry. Fixes `translate_i18n.py`'s stub detection in the same lot. Pre-existing debt (91 KO + 76 ES empty entries) explicitly out of scope — a follow-up repayment lot must follow immediately. See **ADR 0013**. | — | +| [`FIX-I18N-DICT-GUARD`](FIX-I18N-DICT-GUARD/PRD.md) | merged (PR #115) | Reported by **FullGas**: some F10 menu entries stay untranslated in Korean. Root cause: `translate_i18n.py`'s stub detection never matches the `""` empty-stub convention `generate_i18n_dicts.ps1 -Apply` actually writes, so freshly-added keys are never picked up for translation even with `ANTHROPIC_API_KEY` set — and the only existing guard (`.githooks/pre-push`, opt-in, `MISSING`-only) doesn't check translation content. New CI job `i18n-guard`, diff-scoped like `changelog-guard`: blocks unconditionally on `MISSING`, blocks by default (bypassable via `skip-i18n` label) on a newly-introduced empty non-EN entry. Fixes `translate_i18n.py`'s stub detection in the same lot. Pre-existing debt (91 KO + 76 ES empty entries) explicitly out of scope — a follow-up repayment lot must follow immediately. See **ADR 0013**. | `fix/i18n-dict-guard` | | [`FIX-FIELD-EXTRACT-CASUALTIES`](FIX-FIELD-EXTRACT-CASUALTIES/PRD.md) | merged (PR #111) | Field extraction (`embarkFromField`) returns the troop count frozen at deploy time instead of the survivor count — an undeclared legacy-parity deviation. Fix counts live DCS units (excluding `SVNT_*` servants) at extraction time; adds troop counts to the "Extract from field" menu labels; auto-despawns an orphaned mortar servant when its operator dies leaving zero real troops. Also fixed a pre-existing bug where `onUnitDead` never fired in-game. | `fix/field-extract-casualties` | | [`FIX-MENU-DOUBLE-MULTICREW`](FIX-MENU-DOUBLE-MULTICREW/PRD.md) | merged (PR #106) | F10 menu duplication on multi-crew aircraft (CH-47 pilot + copilot); menu loss when one crew member leaves a shared group. | — | | `DOCS-RELEASE-LIFECYCLE` | merged (PR #107) | Question from **FullGas**: does the exe download the latest build after a merge, or does a release have to be published? The answer is only in the code — `FEAT-ONE-CLICK-INSTALL` chose to **bundle** the engine (`--add-data "../../CTLD.lua;ctld_data"`, `release.yml` triggered on `published-v*` only), so an exe installs the engine of its own release, offline, and never updates itself. Nothing user-facing says it, so the natural assumption is the opposite. Documented in the README and the mission-maker guide (EN + FR), with the pre-release detail that goes with it: every rc publishes as a pre-release, so **no release carries the *Latest* badge** today and `releases/latest` redirects to the Releases index. No mechanism change. | `docs/release-lifecycle` |