fix(#687): triage the I3 literal tail — 21 sites, 7 files on the ratchet - #751
Conversation
Follow-on to #747, which made the category measurable. This spends that measurement: it works the `translate` list down from 40 to 19 and puts six more files on the per-file ratchet. Two scanner rules, both learned from the triage rather than guessed: 1. Version, phase and build stamps are `diagnostic`, not prose. `omadia · v1`, `B.0 Draft-Store`, `Phase B.5 (Workspace-UI)` and `omadia · v1 · Slice 1.1` sit in product footers and read like text, but localising them changes an identifier an operator quotes in a bug report. (One of them already had a translated LABEL — `t('footer.phaseLabel')` — with the value left hardcoded beside it.) Whether internal roadmap vocabulary belongs in a shipped footer at all is a product question, flagged rather than answered here. 2. Every `placeholder=` is a judgement call, not only the one-word ones. Of six hits, five were example VALUES that must not be translated (`Release sign-off`, `Release approver`, `id-1, id-2, …`, `email | uri | date-time | uuid`) and one was a real instruction (`Reason (optional)`). Nothing in the syntax separates them, so multi-word placeholders now land in `review`. The `diagnostic` bucket was checked in the dangerous direction: it contains exactly those four stamps and nothing legitimate. 27 keys across six files. The judgement calls worth naming: `block`/`boost` are actions an operator picks, so by GLOSSARY.md they are German (`blockieren`/`hervorheben`) while their `value` attributes stay; `Vault` and `Bucket` are product/loan nouns that stay English but are now catalogue keys with explicit allowlist reasons rather than implicit exemptions. They would have passed silently either way — `i18n-validate` exempts short capitalised nouns by design, a gap its own comment names as a follow-up. Verification: i18n:check OK (3760 keys), tsc clean, eslint clean, 742/742 vitest. Mutation check: restoring one literal turns exactly the new per-file ratchet red. Remaining: 19 `translate`, most of them sentences split across JSX elements (`rendered markdown (`, `Stream live ·`, `s tool-time`) that need ICU restructuring rather than a 1:1 key — deliberately left for a separate pass.
…ings were invisible
Extending the scan to the shapes it could not parse found a hardcoded GERMAN
tooltip and two German badge labels, all in `chat/page.tsx`, all shipped.
Three gaps, each a construct the first version simply did not visit:
1. Template literals with substitutions. `title={`Triage-Klassifizierer:
${a} → ${b}`}` is the shape every interpolated status line uses, and
`stringValueOf` handled only plain string literals. Note this one also slips
past the repo's own `git diff | grep '[äöüÄÖÜß]'` self-check in
`web-ui/CLAUDE.md` — "Triage-Klassifizierer" has no umlaut.
2. Strings in plain objects rendered later. `const verdict = { simple: { label:
'einfach' } }` then `<span>{v.label}</span>` — neither JSX text nor a JSX
prop. Two German labels lived there. Restricted to the same closed prop
list, so the only false positive it can produce is a `label` on an API
payload; 24 hits repo-wide, no explosion.
3. My own over-exemption. #747 skipped ALL of `chat/page.tsx` because
`web-ui/CLAUDE.md` exempts `MOCK_KG_WALK` — but the docs exempt a SYMBOL,
not a file, and the fixture is a const at line 79 while the German tooltip
sat at 1251. Exemptions are now scoped to the declaration. An exemption must
be no wider than the thing it excuses.
Two smaller corrections that fell out of this:
- Rendering a template as `{}` per substitution collided with the placeholder
rule (`\{[^}]*\}`), which would have marked every interpolated string exempt
and silently re-hidden the class this change exists to reveal. Classification
now runs on the literal parts; only the display form carries `{}`.
- Text containing umlauts or ß is `translate` outright — German in a component
is the severest class in the repo rules, so it outranks every exemption
below. Precision over recall: it cannot false-positive, and it cannot catch
umlaut-free German, which still surfaces as `review`.
The widened scan immediately turned the ratchet red on `ListView.tsx` — a
`title={`User: ${user}`}` that had been invisible when the file was declared
swept. That is the ratchet working: it is content-based, so widening the
question re-asks it of every swept file.
Verification: i18n:check OK (3766 keys), tsc clean, eslint clean, 742/742
vitest. Mutation check: restoring `label: 'einfach'` makes the object-label
detector report it again; reverting returns 0.
|
Pushed a second commit that widens the scan — and it found three shipped German strings that the first version structurally could not see. Three blind spots, each a construct the parser never visited1. Template literals with substitutions. title={`Triage-Klassifizierer: ${routing.classifierModel} → ${routing.bucket} → ${routing.model}`}A hardcoded German tooltip in 2. Strings in plain objects rendered later. const verdict = { simple: { label: 'einfach' }, complex: { label: 'komplex' }, … };
…
<span>{v.label}</span>Neither JSX text nor a JSX prop. Two more German labels, same file. Restricted to the same closed prop list, so the only false positive it can produce is a 3. My own over-exemption in the first commit. It skipped all of Two corrections that fell out
The ratchet earned its keepWidening the scan immediately turned Verification
|
Follow-on to #747, which made the I3 category measurable. This spends that measurement:
translategoes from 40 → 19, and the per-file ratchet grows from 1 → 7 files.Two scanner rules, learned from the triage
Neither was guessed — both come from looking at what the first pass actually returned.
1. Version / phase / build stamps are
diagnostic, not prose.store/page.tsx:229omadia · v1store/[id]/page.tsx:464omadia · v1 · Slice 1.1store/builder/page.tsx:181B.0 Draft-StoreWorkspace.tsx:2070Phase B.5 (Workspace-UI)They sit in product footers and read like text, but localising them changes an identifier an operator quotes back in a bug report. One of them is telling:
store/builder/page.tsxalready renders{t('footer.phaseLabel')}and then the hardcoded value right beside it — the label was translated, the value was not.Checked in the dangerous direction: the
diagnosticbucket contains exactly those four and nothing legitimate.2. Every
placeholder=is a judgement call, not only the one-word ones.#747 forced single-token placeholders to
placeholderand left multi-word ones intranslate. The triage showed that was the wrong cut — of six hits, five were example values that must not be translated (Release sign-off,Release approver,id-1, id-2, …,email | uri | date-time | uuid) and one was a real instruction (Reason (optional)). Nothing in the syntax separates an example value from an instruction, so multi-word placeholders now land inreview— the bucket that means "a human decides", which is exactly true here.The translations
27 keys across six files, all of which reach zero actionable hits and join
SWEPT_COMPONENTS.The judgement calls worth naming, since that is what
reviewexists to force:block/boost(kg-priorities action select) → German. GLOSSARY.md puts descriptions of what is happening in German and keeps product nouns English; these are actions an operator picks, soblockieren/hervorheben. Thevalueattributes stayblock/boost— the wire contract is untouched.Vault,Bucket→ stay English (product noun, established loanword) but are now catalogue keys with explicitbrand/loanwordentries ini18n-identical-allowlist.json, rather than implicit exemptions.Status→ identical in both locales, allowlisted asloanword.Resolution→Behebung. NotAuflösung, which in German software reads as screen resolution.On that allowlist point: these five would have passed silently either way.
i18n-validateexempts short capitalised nouns from the identical-value gate by design — a gap its own comment names as the follow-up. Listing them anyway puts the decision where a reader looks instead of relying on a rule that happens not to fire.Verification
npm run i18n:check— OK, 3760 keystsc --noEmitclean;eslintclean on every changed filenpm test— 742/742 across 88 fileslabel={t('prefix')}→label="Prefix") turns exactly the new per-file ratchet red; reverting returns 15/15.What is left, and why
19
translatehits remain. Most are sentences split across JSX elements — the scanner seesrendered markdown (,Stream live ·,· last activity,s tool-time,User · Tenant— where the fix is an ICU message with placeholders, not a 1:1 key. Restructuring those changes markup rather than just extracting strings, so it belongs in its own pass rather than being smuggled into a translation PR.The rest are taglines (
an Agentic OS) and format names (OpenAPI 3 (JSON)) that need a brand decision before a key.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.