Skip to content

Commit 51d59e4

Browse files
fix(cli): a written inline I18nLabel map is no longer reported as an untranslated string (#15980)
* wip(cli): inline I18nLabel maps recorded as authored for coverage Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(cli): pin inline I18nLabel map coverage (red-then-green) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * fix(cli): keep the inline key present on derived entries Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(cli): changeset for inline locale map coverage Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(cli): pin that a locale map never becomes a bundle seed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent acab609 commit 51d59e4

5 files changed

Lines changed: 720 additions & 65 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os lint` / `os i18n check` stop reporting a written inline locale map as an untranslated string.
6+
7+
`I18nLabelSchema` authorizes two forms of a display label: a plain string, whose translations live in a bundle, and an **inline locale map**`{ en: 'Members', 'zh-CN': '成员' }` — written out at the authoring site and picked at render time. Rulings on both forms make the map the one localisation route for props that have no bundle key at all, so a page localised that way is fully localised.
8+
9+
The coverage walk could not see it. `inlineText()` narrowed a map to `undefined` — the same value an **absent** prop produces — so one diagnostic carried two opposite facts, and the gate reported a prop written out in four languages exactly as it reports a prop nobody wrote:
10+
11+
- with no bundle entry, the key was dropped from the expected set entirely: neither covered nor missing, invisible in the counts;
12+
- with a bundle entry for one locale, the key came back with no inline evidence, and every locale the **map** held and the bundle did not was reported `missing translation` — about text that was right there in the file.
13+
14+
An entry now carries a third axis beside `sourceValue` and `inline`: `inlineLocales`, the map the author wrote, verbatim. Coverage reads it per locale — a locale the map carries counts as covered, a locale it omits is reported as a gap, and the default locale is satisfied by the map the way it has always been satisfied by an inline string. The read is deliberately narrower than the renderer's: only the tag-matching limbs of the shared `resolveI18nLabel` rule count, because falling back to `en` or to the untagged `default` entry **is** what an untranslated locale looks like.
15+
16+
Two things this deliberately does not do. The map is still **never extracted**: no bundle row is scaffolded for it, and no key family is added — a translator working from the locale bundle still will not find these strings, which is the cost of the form and is now stated where an author chooses it (`i18n.zod.ts`, and the extractor's own header). And no key is synthesised from a node's position in the page tree: position-addressed keys would turn a reorder of two sibling components into a silent, all-green swap of their translations. If inline maps are ever to be extracted, the recorded direction is identity first — `component.id` / `section.name` / `tabs item.value` made mandatory and gate-enforced, then the existing `pages.<page>.components.<id>.<key>` family reused.
17+
18+
Net effect on a project that authors no inline maps: none. On one that does, the gate starts telling the truth in both directions — the false `missing translation` goes, and a map that genuinely omits a locale is reported for the first time.

packages/cli/src/utils/i18n-coverage.ts

Lines changed: 133 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@
2222
* need. Keys with no source string anywhere are not reported here; a missing
2323
* label is `required/label`'s finding.
2424
*
25+
* An inline LOCALE MAP — `{ en: 'Members', 'zh-CN': '成员' }`, the second form
26+
* `I18nLabelSchema` authorizes — is a source string in every locale it
27+
* carries, and is read as one here (#14749, maintainer ruling 2026-09-03,
28+
* Q2 = B1). It used to be read as nothing at all: the walker narrowed it to
29+
* `undefined` on its way here, which is also what an absent prop produces, so
30+
* a prop localised into four languages and a prop nobody wrote were the same
31+
* input to this file. Locales the map carries now count as covered; locales it
32+
* omits are gaps, on the same footing as a bundle that omits them. The map is
33+
* still never extracted and never gets a bundle row — that half is the
34+
* extractor's header, and ⛔ no key is invented from a node path.
35+
*
2536
* Which locales get checked is the project's call, never an assumption: the
2637
* `i18n.supportedLocales` block declares them, and absent that block only the
2738
* locales a bundle already exists for are checked. A project that does not
@@ -181,6 +192,16 @@ interface ExpectedKey {
181192
* authors one. This *is* the default-locale text — see `computeI18nCoverage`.
182193
*/
183194
inline?: string;
195+
/**
196+
* The inline locale map the author wrote at this prop, when they wrote one
197+
* (`{ en: 'Members', 'zh-CN': '成员' }`) — the second authorized form of an
198+
* `I18nLabel`, carried here verbatim by the walker.
199+
*
200+
* Present ⇒ this key IS authored, whatever `inline` says. See
201+
* {@link inlineLocaleText} for how a locale is read out of it, and
202+
* `computeI18nCoverage` for why that read is narrower than the renderer's.
203+
*/
204+
inlineLocales?: Readonly<Record<string, string>>;
184205
}
185206

186207
/**
@@ -331,12 +352,92 @@ function collectExpectedKeys(config: any): ExpectedKey[] {
331352
displayKey: entry.path.join('.'),
332353
context: describeEntry(entry, source),
333354
inline: entry.inline,
355+
inlineLocales: entry.inlineLocales,
334356
};
335357
});
336358
}
337359

338360
// ─── Lookup ────────────────────────────────────────────────────────────
339361

362+
/**
363+
* The text an inline locale map holds **for `locale` specifically** — or
364+
* `undefined`, which is this detector's word for "not translated here".
365+
*
366+
* ## Why this is narrower than the renderer's rule, deliberately
367+
*
368+
* `resolveI18nLabel` (and objectui's `pickLocalized`, which it is pinned to
369+
* limb for limb) answers a different question: *what should I put on screen
370+
* for this reader?* Its six limbs therefore end in three fallbacks — the
371+
* untagged `default` entry, the `en` entry, then any string in the map — so it
372+
* essentially never misses. Reading coverage off it would report every locale
373+
* as covered the moment a map exists, which is the mirror image of the bug
374+
* this function was added to fix: one answer standing for two opposite facts.
375+
*
376+
* So only the **tag-matching** limbs count as coverage, in the reference's own
377+
* order and with its own case rules:
378+
*
379+
* 1. the exact tag — `zh-CN` reads the key `zh-CN`;
380+
* 2. the base language — `zh-CN` reads the key `zh`;
381+
* 3. the first region-qualified sibling sharing that base — `zh` reads
382+
* `zh-CN`; `zh-TW` reads `zh-CN` too. Same language, other region: what
383+
* the renderer really shows, and a translation into the language asked
384+
* for.
385+
*
386+
* The fallback limbs are excluded because falling back **is** what an
387+
* untranslated locale looks like: a `ja-JP` reader shown the `en` entry is
388+
* precisely the gap this gate exists to report, and `default` is the entry an
389+
* author writes for locales they did **not** translate.
390+
*
391+
* `i18n-label-resolver.ts` is the authority on the limb rule and on the case
392+
* asymmetry mirrored here (region case is irrelevant because limb 3 compares
393+
* language subtags only; language case is significant, because the reference
394+
* folds neither side). `inline-locale-coverage-parity.test.ts` pins this
395+
* function against `resolveI18nLabel`: whenever this says "covered", the
396+
* renderer really shows that entry.
397+
*
398+
* Empty values do not count, matching {@link lookupKey}'s rule on the bundle
399+
* side — an empty translation is not a translation, whichever side it is
400+
* written on.
401+
*/
402+
export function inlineLocaleText(
403+
map: Readonly<Record<string, string>> | undefined,
404+
locale: string,
405+
): string | undefined {
406+
if (!map) return undefined;
407+
const read = (tag: string): string | undefined => {
408+
if (!Object.prototype.hasOwnProperty.call(map, tag)) return undefined;
409+
const value = map[tag];
410+
return typeof value === 'string' && value.length > 0 ? value : undefined;
411+
};
412+
const tag = (locale || 'en').trim();
413+
const exact = read(tag);
414+
if (exact !== undefined) return exact;
415+
const base = tag.split('-')[0];
416+
const baseHit = read(base);
417+
if (baseHit !== undefined) return baseHit;
418+
for (const key of Object.keys(map)) {
419+
if (key.split('-')[0] === base) {
420+
const sibling = read(key);
421+
if (sibling !== undefined) return sibling;
422+
}
423+
}
424+
return undefined;
425+
}
426+
427+
/**
428+
* Any text the inline map holds at all — the test for "did the author write
429+
* something here", used only to decide whether the key is worth reporting on.
430+
*
431+
* ⛔ Not a coverage answer: see {@link inlineLocaleText} for that.
432+
*/
433+
function inlineLocaleAny(map: Readonly<Record<string, string>> | undefined): string | undefined {
434+
if (!map) return undefined;
435+
for (const value of Object.values(map)) {
436+
if (typeof value === 'string' && value.length > 0) return value;
437+
}
438+
return undefined;
439+
}
440+
340441
function lookupKey(data: TranslationData | undefined, path: string[]): string | undefined {
341442
let current: any = data;
342443
for (const segment of path) {
@@ -389,10 +490,22 @@ export function computeI18nCoverage(config: any, opts: CoverageOptions = {}): Co
389490
// string it never wrote inline — other locales still owe a translation for
390491
// it). A key authored in neither place has no text to translate at all; a
391492
// missing label is `required/label`'s finding, not an i18n gap.
493+
//
494+
// An inline LOCALE MAP is authored text too (#14749, maintainer ruling
495+
// 2026-09-03, Q2 = B1). It used to fail this test — the walker narrowed a
496+
// map to `undefined` on its way here, the same value an absent prop
497+
// produces — so a prop written out in four languages was dropped from the
498+
// expected set exactly as if nobody had written it, and a bundle entry for
499+
// one of its locales pulled it back in and then reported every OTHER locale
500+
// as missing. Both readings were the same defect: one diagnostic standing
501+
// for two opposite facts.
392502
const authoredInBundle = (path: string[]): boolean =>
393503
Object.values(merged).some((data) => lookupKey(data, path) !== undefined);
394504
const expected = collectExpectedKeys(config).filter(
395-
(key) => key.inline !== undefined || authoredInBundle(key.path),
505+
(key) =>
506+
key.inline !== undefined
507+
|| inlineLocaleAny(key.inlineLocales) !== undefined
508+
|| authoredInBundle(key.path),
396509
);
397510
const issues: CoverageIssue[] = [];
398511
const stats: CoverageStats[] = [];
@@ -401,11 +514,25 @@ export function computeI18nCoverage(config: any, opts: CoverageOptions = {}): Co
401514
const data = merged[locale];
402515
let translated = 0;
403516
for (const key of expected) {
404-
// The inline `label:` IS the default-locale text — the runtime resolver
405-
// falls back to it (i18n-resolver `translateObject`), and `os i18n
406-
// extract` seeds bundles from it. Demanding a default-locale bundle entry
407-
// that merely restates it reports a gap that does not exist.
408-
const value = lookupKey(data, key.path) ?? (locale === defaultLocale ? key.inline : undefined);
517+
// Three sources, most specific first.
518+
//
519+
// 1. The bundle entry for this locale.
520+
// 2. The inline locale map's entry FOR THIS LOCALE — tag-matched, never
521+
// the renderer's fallback limbs (see `inlineLocaleText`). A locale
522+
// the map carries is translated; one it does not is a gap, which is
523+
// the whole of what the B1 ruling asks this gate to be able to say.
524+
// 3. The inline `label:` IS the default-locale text — the runtime
525+
// resolver falls back to it (i18n-resolver `translateObject`), and
526+
// `os i18n extract` seeds bundles from it. Demanding a default-locale
527+
// bundle entry that merely restates it reports a gap that does not
528+
// exist. A map satisfies the default locale on the same grounds and
529+
// by the same reading: whatever it resolves to there IS the source
530+
// text a reader of the default locale sees, so `inlineLocaleAny`
531+
// stands in for `inline` when the author wrote a map instead of a
532+
// string.
533+
const value = lookupKey(data, key.path)
534+
?? inlineLocaleText(key.inlineLocales, locale)
535+
?? (locale === defaultLocale ? (key.inline ?? inlineLocaleAny(key.inlineLocales)) : undefined);
409536
if (value !== undefined) {
410537
translated += 1;
411538
continue;

0 commit comments

Comments
 (0)