Skip to content

Commit 46c9569

Browse files
os-zhuangclaude
andcommitted
fix(cli): inline label: is the default-locale source for i18n coverage (#3103)
A fresh `npm create objectstack` scaffold linted with 4 user-side i18n errors out of the box, for its own `<ns>_note` object — every one of them for a string the template *does* author inline (`label: 'Note'`, `pluralLabel: 'Notes'`, `label: 'Title'`, `label: 'Body'`). They were false positives. The inline `label:` is the default-locale source text, and the framework already says so everywhere except here: - the runtime resolver falls back to it when a bundle carries no entry (`i18n-resolver` `translateObject`: `lookupObjectField(...) ?? doc.label`), and its docstring documents the order `locale → fallbackChain → literal label from the metadata`; - `os i18n extract` *generates* bundles from it (`obj.label ?? objectName`). So coverage was demanding an `en` bundle that mechanically restates data the linter already holds, for text the runtime renders correctly. Worse, an object with no `pluralLabel` was told its `pluralLabel` was "missing translation" — a demand for a translation of a string that does not exist, unfixable by authoring anything but a bundle entry. Honour the contract instead: an inline label satisfies the default locale, and a bundle is what *other* locales need. A key with no source string anywhere is no longer an i18n gap — a missing label is `required/label`'s finding, and this file already only expected `confirmText`/`successMessage`/`description` when authored; `label`/`pluralLabel` were the outliers. Platform form fields keep their key: they omit `label` by design and let the renderer humanize the path ("name" → "Name"), so that derived text is a real source string other locales still owe a translation for. Mirrors the extractor's seed value, which the walker's docstring already promises. Verified on the issue's repro and against a real multi-locale project — the non-default-locale signal is preserved exactly: examples/app-todo before after os lint 39 errors, 79 warn 0 errors, 79 warn os i18n check 856 err, 1712 warn 0 err, 1712 warn Warnings are byte-identical; only the default-locale duplicates are gone. The platform baseline drops 2451 → 1634 hidden issues, a delta of exactly 817 — its `en` third (817×3 → 817×2) — because the platform ships English labels inline too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fdc244e commit 46c9569

4 files changed

Lines changed: 226 additions & 36 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): treat an inline `label:` as the default-locale source in i18n coverage
6+
7+
A fresh `npm create objectstack` scaffold reported 4 `i18n/missing-object` /
8+
`i18n/missing-field` errors for its own `<ns>_note` object, even though the
9+
template authors `label: 'Note'`, `pluralLabel: 'Notes'`, `label: 'Title'` and
10+
`label: 'Body'` inline. The only way to silence them was to commit an `en`
11+
bundle restating strings the metadata already carries.
12+
13+
The inline `label:` *is* the default-locale text: the runtime resolver falls
14+
back to it when a bundle has no entry (`translateObject`), and `os i18n
15+
extract` seeds bundles from it. Coverage now honours that contract — an inline
16+
label satisfies the default locale, and a bundle is what *other* locales need.
17+
Keys with no source string anywhere are no longer reported as i18n gaps; a
18+
missing label is already `required/label`'s finding.
19+
20+
Non-default locales are unaffected: they still warn for every untranslated key
21+
(`os lint` on `examples/app-todo` reports the same 79 warnings as before, with
22+
its 39 default-locale errors gone). `os lint --include-platform` drops the
23+
platform baseline's default-locale errors for the same reason — the platform
24+
ships English labels inline — while keeping its non-default-locale warnings.

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

Lines changed: 99 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,21 @@
1111
* the actual translation bundles attached to the stack and reports any keys
1212
* that are missing or set to an empty string.
1313
*
14+
* The inline `label:` in the metadata is the *source* string, authored in the
15+
* default locale: the runtime resolver falls back to it when a bundle carries
16+
* no entry, and `os i18n extract` seeds bundles from it. So an inline label
17+
* satisfies the default locale on its own — a bundle is what other locales
18+
* need. Keys with no source string anywhere are not reported here; a missing
19+
* label is `required/label`'s finding.
20+
*
1421
* Pure: no filesystem or network. Safe to invoke from `os lint`, `os i18n
1522
* check`, IDE tooling, and unit tests.
1623
*/
1724

1825
import type { TranslationBundle, TranslationData } from '@objectstack/spec/system';
1926
import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system';
2027
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
28+
import { humanizeFieldPath } from './i18n-extract.js';
2129

2230
export type CoverageSeverity = 'error' | 'warning';
2331

@@ -150,34 +158,68 @@ interface ExpectedKey {
150158
displayKey: string;
151159
/** Description shown in the issue message when the key is missing. */
152160
context: string;
161+
/**
162+
* The source string authored inline in the metadata (`label: 'Note'`), when
163+
* there is one. This *is* the default-locale text — see `computeI18nCoverage`.
164+
*/
165+
inline?: string;
153166
}
154167

155-
function pushKey(out: ExpectedKey[], path: string[], source: CoverageIssue['source'], context: string): void {
156-
out.push({ source, path, displayKey: path.join('.'), context });
168+
function pushKey(
169+
out: ExpectedKey[],
170+
path: string[],
171+
source: CoverageIssue['source'],
172+
context: string,
173+
inline?: string,
174+
): void {
175+
out.push({ source, path, displayKey: path.join('.'), context, inline });
157176
}
158177

178+
/** Narrow to a usable source string; empty strings are not authored text. */
179+
function inlineText(value: unknown): string | undefined {
180+
return typeof value === 'string' && value.length > 0 ? value : undefined;
181+
}
182+
183+
/**
184+
* Collects every key a translation bundle *may* carry, paired with the inline
185+
* source string the metadata already authors for it. Callers drop the keys that
186+
* are authored nowhere — see `computeI18nCoverage`.
187+
*/
159188
function collectExpectedKeys(config: any): ExpectedKey[] {
160189
const keys: ExpectedKey[] = [];
161190
const objects: any[] = Array.isArray(config?.objects) ? config.objects : [];
162191

163192
for (const obj of objects) {
164193
if (!obj?.name) continue;
165194
const objectName = obj.name as string;
166-
pushKey(keys, ['objects', objectName, 'label'], 'object', `Object "${objectName}" label`);
167-
if (obj.pluralLabel || obj.label) {
168-
pushKey(keys, ['objects', objectName, 'pluralLabel'], 'object', `Object "${objectName}" pluralLabel`);
169-
}
195+
pushKey(keys, ['objects', objectName, 'label'], 'object', `Object "${objectName}" label`, inlineText(obj.label));
196+
pushKey(
197+
keys,
198+
['objects', objectName, 'pluralLabel'],
199+
'object',
200+
`Object "${objectName}" pluralLabel`,
201+
inlineText(obj.pluralLabel),
202+
);
170203
if (obj.fields && typeof obj.fields === 'object') {
171204
for (const [fieldName, field] of Object.entries<any>(obj.fields)) {
172-
pushKey(keys, ['objects', objectName, 'fields', fieldName, 'label'], 'field', `Field ${objectName}.${fieldName} label`);
205+
pushKey(
206+
keys,
207+
['objects', objectName, 'fields', fieldName, 'label'],
208+
'field',
209+
`Field ${objectName}.${fieldName} label`,
210+
inlineText(field?.label),
211+
);
173212
const opts = field?.options;
174213
if (opts && typeof opts === 'object' && !Array.isArray(opts)) {
175-
for (const optionKey of Object.keys(opts)) {
214+
for (const [optionKey, optionLabel] of Object.entries<any>(opts)) {
215+
// Mirrors the extractor: an option's source text is its label, or
216+
// its own value when the map holds no label string.
176217
pushKey(
177218
keys,
178219
['objects', objectName, 'fields', fieldName, 'options', optionKey],
179220
'option',
180221
`Option ${objectName}.${fieldName}.${optionKey}`,
222+
inlineText(optionLabel) ?? optionKey,
181223
);
182224
}
183225
}
@@ -195,6 +237,7 @@ function collectExpectedKeys(config: any): ExpectedKey[] {
195237
['objects', objectName, '_views', view.name, 'label'],
196238
'view',
197239
`View ${objectName}.${view.name} label`,
240+
inlineText(view.label),
198241
);
199242
}
200243

@@ -205,13 +248,15 @@ function collectExpectedKeys(config: any): ExpectedKey[] {
205248
const root = objectName ? ['objects', objectName, '_actions', action.name] : ['globalActions', action.name];
206249
const source: CoverageIssue['source'] = objectName ? 'action' : 'globalAction';
207250
const ctxOwner = objectName ? `${objectName}.${action.name}` : action.name;
208-
pushKey(keys, [...root, 'label'], source, `Action ${ctxOwner} label`);
209-
if (action.confirmText) {
210-
pushKey(keys, [...root, 'confirmText'], source, `Action ${ctxOwner} confirmText`);
211-
}
212-
if (action.successMessage) {
213-
pushKey(keys, [...root, 'successMessage'], source, `Action ${ctxOwner} successMessage`);
214-
}
251+
pushKey(keys, [...root, 'label'], source, `Action ${ctxOwner} label`, inlineText(action.label));
252+
pushKey(keys, [...root, 'confirmText'], source, `Action ${ctxOwner} confirmText`, inlineText(action.confirmText));
253+
pushKey(
254+
keys,
255+
[...root, 'successMessage'],
256+
source,
257+
`Action ${ctxOwner} successMessage`,
258+
inlineText(action.successMessage),
259+
);
215260
}
216261

217262
collectMetadataFormKeys(keys);
@@ -227,11 +272,20 @@ function collectExpectedKeys(config: any): ExpectedKey[] {
227272
function collectMetadataFormKeys(out: ExpectedKey[]): void {
228273
for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) {
229274
const type = entry.type;
230-
pushKey(out, ['metadataForms', type, 'label'], 'metadataForm', `Metadata form "${type}" label`);
231-
const desc = (entry as any).description;
232-
if (typeof desc === 'string' && desc.length > 0) {
233-
pushKey(out, ['metadataForms', type, 'description'], 'metadataForm', `Metadata form "${type}" description`);
234-
}
275+
pushKey(
276+
out,
277+
['metadataForms', type, 'label'],
278+
'metadataForm',
279+
`Metadata form "${type}" label`,
280+
inlineText((entry as any).label) ?? type,
281+
);
282+
pushKey(
283+
out,
284+
['metadataForms', type, 'description'],
285+
'metadataForm',
286+
`Metadata form "${type}" description`,
287+
inlineText((entry as any).description),
288+
);
235289
}
236290
for (const [type, form] of Object.entries(METADATA_FORM_REGISTRY)) {
237291
const sections: any[] = [
@@ -241,11 +295,9 @@ function collectMetadataFormKeys(out: ExpectedKey[]): void {
241295
for (const section of sections) {
242296
if (!section || typeof section !== 'object') continue;
243297
const sectionName = normalizeMetadataSectionName(section);
244-
if (sectionName && typeof section.label === 'string') {
245-
pushKey(out, ['metadataForms', type, 'sections', sectionName, 'label'], 'metadataForm', `Metadata form ${type}.sections.${sectionName} label`);
246-
}
247-
if (sectionName && typeof section.description === 'string' && section.description.length > 0) {
248-
pushKey(out, ['metadataForms', type, 'sections', sectionName, 'description'], 'metadataForm', `Metadata form ${type}.sections.${sectionName} description`);
298+
if (sectionName) {
299+
pushKey(out, ['metadataForms', type, 'sections', sectionName, 'label'], 'metadataForm', `Metadata form ${type}.sections.${sectionName} label`, inlineText(section.label));
300+
pushKey(out, ['metadataForms', type, 'sections', sectionName, 'description'], 'metadataForm', `Metadata form ${type}.sections.${sectionName} description`, inlineText(section.description));
249301
}
250302
if (Array.isArray(section.fields)) {
251303
for (const child of section.fields) walkMetadataFormField(child, type, '', out);
@@ -259,13 +311,13 @@ function walkMetadataFormField(field: any, type: string, parentPath: string, out
259311
const name = typeof field.field === 'string' ? field.field : undefined;
260312
const path = name ? (parentPath ? `${parentPath}.${name}` : name) : parentPath;
261313
if (path) {
262-
pushKey(out, ['metadataForms', type, 'fields', path, 'label'], 'metadataForm', `Metadata form ${type}.fields.${path} label`);
263-
if (typeof field.helpText === 'string' && field.helpText.length > 0) {
264-
pushKey(out, ['metadataForms', type, 'fields', path, 'helpText'], 'metadataForm', `Metadata form ${type}.fields.${path} helpText`);
265-
}
266-
if (typeof field.placeholder === 'string' && field.placeholder.length > 0) {
267-
pushKey(out, ['metadataForms', type, 'fields', path, 'placeholder'], 'metadataForm', `Metadata form ${type}.fields.${path} placeholder`);
268-
}
314+
// Platform form fields routinely omit `label` and let the renderer
315+
// humanize the field path ("name" → "Name"). That derived text is the
316+
// source string — the field is not unlabelled — so other locales still
317+
// owe it a translation. Mirrors the extractor's seed value.
318+
pushKey(out, ['metadataForms', type, 'fields', path, 'label'], 'metadataForm', `Metadata form ${type}.fields.${path} label`, inlineText(field.label) ?? humanizeFieldPath(path));
319+
pushKey(out, ['metadataForms', type, 'fields', path, 'helpText'], 'metadataForm', `Metadata form ${type}.fields.${path} helpText`, inlineText(field.helpText));
320+
pushKey(out, ['metadataForms', type, 'fields', path, 'placeholder'], 'metadataForm', `Metadata form ${type}.fields.${path} placeholder`, inlineText(field.placeholder));
269321
}
270322
if (Array.isArray(field.fields)) {
271323
for (const child of field.fields) walkMetadataFormField(child, type, path, out);
@@ -313,15 +365,28 @@ export function computeI18nCoverage(config: any, opts: CoverageOptions = {}): Co
313365
activeLocales = discovered.includes(defaultLocale) ? discovered : [defaultLocale, ...discovered];
314366
}
315367

316-
const expected = collectExpectedKeys(config);
368+
// A key is only worth translating if a source string is authored somewhere:
369+
// inline in the metadata, or in some bundle (a project may externalize a
370+
// string it never wrote inline — other locales still owe a translation for
371+
// it). A key authored in neither place has no text to translate at all; a
372+
// missing label is `required/label`'s finding, not an i18n gap.
373+
const authoredInBundle = (path: string[]): boolean =>
374+
Object.values(merged).some((data) => lookupKey(data, path) !== undefined);
375+
const expected = collectExpectedKeys(config).filter(
376+
(key) => key.inline !== undefined || authoredInBundle(key.path),
377+
);
317378
const issues: CoverageIssue[] = [];
318379
const stats: CoverageStats[] = [];
319380

320381
for (const locale of activeLocales) {
321382
const data = merged[locale];
322383
let translated = 0;
323384
for (const key of expected) {
324-
const value = lookupKey(data, key.path);
385+
// The inline `label:` IS the default-locale text — the runtime resolver
386+
// falls back to it (i18n-resolver `translateObject`), and `os i18n
387+
// extract` seeds bundles from it. Demanding a default-locale bundle entry
388+
// that merely restates it reports a gap that does not exist.
389+
const value = lookupKey(data, key.path) ?? (locale === defaultLocale ? key.inline : undefined);
325390
if (value !== undefined) {
326391
translated += 1;
327392
continue;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@ function walkFormField(field: any, type: string, parentPath: string, out: Expect
446446
}
447447

448448
/** Match the metadata form renderer's fallback label for fields without an explicit label. */
449-
function humanizeFieldPath(path: string): string {
449+
export function humanizeFieldPath(path: string): string {
450450
const leaf = path.split('.').pop() ?? path;
451451
return leaf
452452
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')

packages/cli/test/i18n-coverage.test.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,27 @@ describe('computeI18nCoverage', () => {
138138
});
139139

140140
it('raises errors when the default locale itself is incomplete', () => {
141+
// A genuine default-locale gap: zh-CN carries a string that has no source
142+
// text anywhere for `en` — neither inline on the action nor in the bundle.
141143
const incomplete = JSON.parse(JSON.stringify(baseConfig));
142144
delete incomplete.translations[0].en.objects.account._actions.merge_accounts.label;
145+
delete incomplete.actions[0].label;
143146
const report = computeI18nCoverage(incomplete, { defaultLocale: 'en' });
144147
const enErrors = report.issues.filter((i) => i.locale === 'en' && i.severity === 'error');
145148
expect(enErrors.some((i) => i.key === 'objects.account._actions.merge_accounts.label')).toBe(true);
146149
});
147150

151+
it('does not fault the default locale for a string authored inline', () => {
152+
// Dropping a bundle entry that merely restates the inline `label:` is not
153+
// a gap — the runtime resolver falls back to the inline text. Regression
154+
// guard for the blank template linting with i18n errors out of the box.
155+
const inlineOnly = JSON.parse(JSON.stringify(baseConfig));
156+
delete inlineOnly.translations[0].en.objects.account._actions.merge_accounts.label;
157+
const report = computeI18nCoverage(inlineOnly, { defaultLocale: 'en' });
158+
const enErrors = report.issues.filter((i) => i.locale === 'en' && i.severity === 'error');
159+
expect(enErrors.map((i) => i.key)).not.toContain('objects.account._actions.merge_accounts.label');
160+
});
161+
148162
it('honours an explicit --locales filter', () => {
149163
const report = computeI18nCoverage(baseConfig, { defaultLocale: 'en', locales: ['ja-JP'] });
150164
expect(report.locales.sort()).toEqual(['en', 'ja-JP']);
@@ -159,7 +173,94 @@ describe('computeI18nCoverage', () => {
159173
const objectSources = new Set(['object', 'field', 'option', 'view', 'action', 'globalAction']);
160174
expect(report.issues.filter((i) => objectSources.has(i.source))).toEqual([]);
161175
expect(report.totals.expectedKeys).toBeGreaterThan(0); // metadataForms baseline
162-
expect(report.issues.some((i) => i.key === 'metadataForms.flow.fields.name.label')).toBe(true);
176+
});
177+
178+
describe('inline metadata as the default-locale source', () => {
179+
// Shape of the `create-objectstack` blank template: inline labels, no
180+
// translation bundle. It must lint clean (#3103).
181+
const scaffold: any = {
182+
objects: [
183+
{
184+
name: 'demo_note',
185+
label: 'Note',
186+
pluralLabel: 'Notes',
187+
fields: { title: { label: 'Title' }, body: { label: 'Body' } },
188+
},
189+
],
190+
translations: [],
191+
};
192+
193+
it('reports no default-locale errors for a bundle-less scaffold', () => {
194+
const report = computeI18nCoverage(scaffold, { defaultLocale: 'en' });
195+
expect(report.issues.filter((i) => i.severity === 'error')).toEqual([]);
196+
});
197+
198+
it('reports 100% default-locale coverage when every string is authored inline', () => {
199+
const report = computeI18nCoverage(scaffold, { defaultLocale: 'en' });
200+
expect(report.stats.find((s) => s.locale === 'en')!.coveragePercent).toBe(100);
201+
});
202+
203+
it('still warns for a non-default locale — inline text is not a translation', () => {
204+
const withZh = { ...scaffold, translations: [{ 'zh-CN': {} }] };
205+
const report = computeI18nCoverage(withZh, { defaultLocale: 'en' });
206+
const zhKeys = report.issues.filter((i) => i.locale === 'zh-CN').map((i) => i.key);
207+
expect(zhKeys).toContain('objects.demo_note.label');
208+
expect(zhKeys).toContain('objects.demo_note.fields.title.label');
209+
});
210+
211+
it('treats the inline label as the source for whatever locale is the default', () => {
212+
// Inline text is the source string, not "English" — a zh-CN-first
213+
// project authoring inline Chinese labels is complete, not broken.
214+
const zhFirst = {
215+
objects: [{ name: 'demo_note', label: '备注', pluralLabel: '备注', fields: { title: { label: '标题' } } }],
216+
translations: [],
217+
};
218+
const report = computeI18nCoverage(zhFirst, { defaultLocale: 'zh-CN' });
219+
expect(report.issues.filter((i) => i.severity === 'error')).toEqual([]);
220+
});
221+
222+
it('does not demand a pluralLabel translation when none is authored', () => {
223+
const noPlural = {
224+
objects: [{ name: 'demo_note', label: 'Note', fields: {} }],
225+
translations: [],
226+
};
227+
const report = computeI18nCoverage(noPlural, { defaultLocale: 'en' });
228+
expect(report.issues.map((i) => i.key)).not.toContain('objects.demo_note.pluralLabel');
229+
});
230+
231+
it('leaves an unlabelled field to required/label rather than reporting an i18n gap', () => {
232+
const unlabelled = {
233+
objects: [{ name: 'demo_note', label: 'Note', fields: { title: {} } }],
234+
translations: [],
235+
};
236+
const report = computeI18nCoverage(unlabelled, { defaultLocale: 'en' });
237+
expect(report.issues.map((i) => i.key)).not.toContain('objects.demo_note.fields.title.label');
238+
});
239+
240+
it('still expects a key that is authored only in a bundle', () => {
241+
// No inline label, but zh-CN externalizes one — `en` genuinely lacks a
242+
// source string for it, so the default-locale gate must still fire.
243+
const bundleOnly = {
244+
objects: [{ name: 'demo_note', label: 'Note', fields: { title: {} } }],
245+
translations: [{ 'zh-CN': { objects: { demo_note: { fields: { title: { label: '标题' } } } } } }],
246+
};
247+
const report = computeI18nCoverage(bundleOnly, { defaultLocale: 'en' });
248+
const enErrors = report.issues.filter((i) => i.locale === 'en' && i.severity === 'error');
249+
expect(enErrors.map((i) => i.key)).toContain('objects.demo_note.fields.title.label');
250+
});
251+
});
252+
253+
it('counts the platform metadataForms baseline as covered for the default locale', () => {
254+
// The registry authors those labels inline, so `en` needs no bundle. A
255+
// non-default locale still owes a translation for each of them.
256+
const report = computeI18nCoverage(
257+
{ objects: [], views: [], actions: [], translations: [{ 'zh-CN': {} }] },
258+
{ defaultLocale: 'en' },
259+
);
260+
expect(report.issues.filter((i) => i.locale === 'en')).toEqual([]);
261+
const zh = report.issues.filter((i) => i.locale === 'zh-CN');
262+
expect(zh.some((i) => i.key === 'metadataForms.flow.fields.name.label')).toBe(true);
263+
expect(zh.every((i) => i.severity === 'warning')).toBe(true);
163264
});
164265

165266
it('treats data.object as fallback for view objectName', () => {

0 commit comments

Comments
 (0)