Skip to content

Commit e2c2620

Browse files
claude[bot]claude
andauthored
fix(cli): os i18n check counts the coverage an app actually owns, so --strict / --threshold can gate an app package (#17223)
* fix(cli): os i18n check counts the coverage an app actually owns `collectExpectedEntries` walks the Studio metadata-form registries unconditionally, so every stack's expected set carries ~773 `metadataForms.*` keys that `@objectstack/platform-objects` translates and the runtime serves. `os lint` hides them and says so; `os i18n extract` has `--no-metadata-forms`; `os i18n check` — the one command that publishes a PERCENTAGE — carried them in its denominator, so an application with its own surface fully translated read 38.9% and `--strict` / `--threshold` could not gate it. Ownership is now OBSERVED rather than assumed: the baseline counts when the stack itself ships those translations, and does not when it does not. That keeps the package which owns the family gated with no flag, which an unconditional exclusion would not. `--include-platform` (`os lint`'s own spelling) and `--no-include-platform` force either way. `os lint` is unchanged: the shared seam still defaults to counting the baseline, because lint folds it away one seam later and counts what it folded for its own hint line. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com> * docs(i18n): state who owns the metadata-form baseline where the gate is taught `content/docs/ui/translations.mdx` recommends `os i18n check --strict --threshold` as the CI gate; that advice is now reachable for an app package, and the page has to say what is and is not in the number it produces — including why shipping your own `metadataForms` bundle to move it is the wrong repair. The published i18n skill listed metadata forms among the surfaces `check` always reports on. That sentence is false as of this change, so it is corrected in place rather than left to read as a scope statement. `content/docs/protocol/kernel/i18n-standard.mdx` names `metadataForms` only in its ORPHAN-key paragraph (the reverse direction, which `os validate` / `os lint` / `os compile` answer) — untouched by this change and left alone. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 47863f4 commit e2c2620

8 files changed

Lines changed: 929 additions & 4 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
fix(cli): `os i18n check` counts the coverage an app actually owns, so `--strict` / `--threshold` can gate an app package (#16681)
6+
7+
## What was wrong
8+
9+
`collectExpectedEntries` walks the Studio metadata-form registries
10+
unconditionally — identically for every config, an empty one included — so
11+
every stack's expected set carries ~773 `metadataForms.*` keys that
12+
`@objectstack/platform-objects` translates and the runtime already serves.
13+
14+
Two of the three commands that see that family already knew it is not the
15+
author's. `os lint` hides it and says so ("platform built-ins: 773 i18n
16+
issue(s) hidden — rerun with `--include-platform`"); `os i18n extract` has
17+
`--no-metadata-forms`. `os i18n check` is the one command that publishes a
18+
**percentage**, and it carried the baseline in its denominator:
19+
20+
```
21+
Coverage by locale
22+
en ████████████████████████ 100.0% (1265/1265, missing 0)
23+
zh-CN █████████░░░░░░░░░░░░░░░ 38.9% (492/1265, missing 773)
24+
```
25+
26+
That is an application with every key it owns translated. `--strict` and
27+
`--threshold` — the two flags whose entire purpose is CI gating — therefore
28+
could not gate an app package at all, and the only way to move the number was
29+
to ship a copy of the platform's bundle, which would *override* the platform's
30+
own and go stale at the next upgrade. The workaround was worse than the defect.
31+
32+
## What it does now
33+
34+
**Ownership is observed, not assumed.** The baseline counts toward coverage
35+
when the stack under examination ships those translations itself, and does not
36+
when it does not — read from the config's own `translations` bundles, requiring
37+
a non-empty string leaf so an `--fill=empty` scaffold is not mistaken for a
38+
claim of ownership. An app gets a number about its own surface with no flag;
39+
`platform-objects`, which does ship the family, stays gated on it with no flag
40+
either. An unconditional exclusion would have turned the app side green by
41+
deleting the platform's own gate, and is what the negative-control tests forbid.
42+
43+
**The flag is `os lint`'s, spelling and all.** `--include-platform` forces the
44+
baseline in; `--no-include-platform` forces it out, for a package that ships a
45+
partial baseline and does not intend to own the rest. Absent, the decision is
46+
the observed one — three states, not two.
47+
48+
**Both output faces carry the decision.** `--json` gains
49+
`platformMetadataForms: { mode, excludedKeys }`, and the console prints
50+
`platform built-ins: N key(s) not counted — rerun with --include-platform to
51+
gate them here` under the coverage table, rendered from those same two numbers.
52+
53+
`os lint` is unchanged. The shared `computeI18nCoverage` seam still counts the
54+
baseline by default, because lint folds it away one seam later and counts what
55+
it folded for its own hint line.
56+
57+
## Compatibility
58+
59+
Additive on the command surface; an invocation that was refused is now
60+
accepted, and no flag is removed or renamed. The behaviour that changes is the
61+
**default coverage number for a stack that ships no `metadataForms` bundle** —
62+
it stops reporting a debt that stack must not pay. A run that wants the old
63+
numbers back asks for them with `--include-platform`, on the same argv.

‎content/docs/ui/translations.mdx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,23 @@ A missing string in the **default** locale is an error; missing strings in
244244
other locales are warnings until you set `--strict` / `--threshold`. The Todo
245245
example ships a completeness test alongside its bundles — worth copying.
246246

247+
### What counts as *your* coverage
248+
249+
The Studio's own metadata forms (`metadataForms.*` — several hundred keys
250+
across every metadata type) are translated by `@objectstack/platform-objects`
251+
and served from there, so they are **not** in your coverage number: an app that
252+
translated everything it declares reads 100%, not 39%. ⛔ Do not "fix" a low
253+
number by shipping your own `metadataForms` bundle — yours would override the
254+
platform's and go stale at the next upgrade.
255+
256+
The rule is **ownership**, read from your own bundles rather than assumed: ship
257+
translations for that family and you are asked to complete them, which is how
258+
the platform packages stay gated on the strings they do own. Pass
259+
`--include-platform` to audit the baseline anyway (`os lint`'s flag, same
260+
meaning), or `--no-include-platform` to keep it out even though you ship part
261+
of it. The command prints how many keys it left out, and `--json` carries the
262+
same two numbers as `platformMetadataForms`.
263+
247264
### Which locales get checked
248265

249266
Your project decides, and the tooling never assumes. `os lint`, `os i18n check`

‎packages/cli/src/commands/i18n/check.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,44 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
/**
4+
* `os i18n check` — the coverage gate.
5+
*
6+
* ## The platform `metadataForms.*` baseline, across all three commands
7+
*
8+
* `collectExpectedEntries` walks the Studio metadata-form registries
9+
* unconditionally, identically for every config — ~773 keys that
10+
* `@objectstack/platform-objects` translates and the runtime serves. Three
11+
* commands see that family and each has to say something about it. They used
12+
* to say three different things, and this one said nothing at all, which is
13+
* how `--strict` / `--threshold` — the two flags whose entire purpose is CI
14+
* gating — became unusable for an application package: every app read ~39%
15+
* with its own surface fully translated, and the only way to "fix" the number
16+
* was to ship a copy of the platform's bundle that would override it and go
17+
* stale at the next upgrade.
18+
*
19+
* command what the baseline does to it default opt-in / out
20+
* ------------------ ------------------------------- ------------- ---------------------------
21+
* os lint adds findings to the report hidden --include-platform
22+
* os i18n extract adds a companion FILE / JSON emitted --no-metadata-forms
23+
* member (`metadataFormsCounts`
24+
* reports its size either way)
25+
* os i18n check moves the coverage DENOMINATOR auto: counted --include-platform /
26+
* only when --no-include-platform
27+
* this stack
28+
* ships their
29+
* translations
30+
*
31+
* ⚠️ The three differ because the OUTPUTS differ, and reading the table as
32+
* three dialects of one setting is the mistake it exists to prevent: `lint`
33+
* reports findings and can fold at the report seam; `extract` writes files and
34+
* chooses a file set; only `check` publishes a **percentage**, so for it the
35+
* question is which keys are in the denominator. That is also why this command
36+
* is the one that can answer it without a flag — ownership of the baseline is
37+
* observable from the config's own bundles ({@link stackAuthorsMetadataForms}),
38+
* so an app gets its own number and `platform-objects`, which ships those
39+
* translations, keeps being gated on them.
40+
*/
41+
342
import { Args, Command, Flags } from '@oclif/core';
443
import chalk from 'chalk';
544
import { normalizeStackInput } from '@objectstack/spec';
@@ -52,6 +91,7 @@ export default class I18nCheck extends Command {
5291
'$ os i18n check ./objectstack.config.ts',
5392
'$ os i18n check --locales=en,zh-CN,ja-JP',
5493
'$ os i18n check --strict --threshold=95',
94+
'$ os i18n check --include-platform',
5595
'$ os i18n check --json',
5696
];
5797

@@ -78,6 +118,25 @@ export default class I18nCheck extends Command {
78118
'show-keys': Flags.boolean({
79119
description: 'List every missing key (otherwise the first 20 per locale are shown)',
80120
}),
121+
// The same flag NAME and the same default as `os lint`, deliberately: this
122+
// command was the odd one out of three, and a third vocabulary for one
123+
// decision is what made an author go read the source to find out whether
124+
// the platform bucket counts. `os i18n extract` spells its half
125+
// `--no-metadata-forms`, which selects an emitted FILE SET rather than a
126+
// gated population — see the table in the module note at the top of this
127+
// file.
128+
//
129+
// `allowNo` gives the third state a percentage gate needs. Absent, the
130+
// decision is `auto` — observed from the config, so neither an app nor the
131+
// platform package has to discover a flag to get the right number.
132+
// `--include-platform` forces the baseline in; `--no-include-platform`
133+
// forces it out, for a package that ships a partial baseline and does not
134+
// intend to own the rest of it.
135+
'include-platform': Flags.boolean({
136+
allowNo: true,
137+
description:
138+
'Count platform built-in metadata forms toward coverage (default: only when this stack ships their translations — the platform packages own them otherwise)',
139+
}),
81140
};
82141

83142
async run(): Promise<void> {
@@ -98,6 +157,14 @@ export default class I18nCheck extends Command {
98157
defaultLocale: flags['default-locale'],
99158
locales: flags.locales ? flags.locales.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
100159
strict: flags.strict,
160+
// Unset ⇒ `auto`. ⛔ Not `?? false`: an absent boolean and an explicit
161+
// `--no-include-platform` are different requests here, and collapsing
162+
// them would delete the observed-ownership default that makes this
163+
// command usable without a flag on both sides.
164+
platformMetadataForms:
165+
flags['include-platform'] === undefined
166+
? 'auto'
167+
: flags['include-platform'] ? 'include' : 'exclude',
101168
});
102169

103170
const thresholdViolations = flags.threshold !== undefined
@@ -126,6 +193,17 @@ export default class I18nCheck extends Command {
126193
chalk.dim(` (${stat.translated}/${stat.expected}, missing ${stat.missing})`),
127194
);
128195
}
196+
// Printed under the table, where the denominator it explains is: every
197+
// number above was computed without these keys. Same sentence shape as
198+
// `os lint`'s, and rendered from the same two fields `--json` carries in
199+
// `platformMetadataForms`, so the two faces cannot disagree.
200+
if (report.platformMetadataForms.excludedKeys > 0) {
201+
console.log(
202+
chalk.dim(
203+
` platform built-ins: ${report.platformMetadataForms.excludedKeys} key(s) not counted — rerun with --include-platform to gate them here`,
204+
),
205+
);
206+
}
129207
console.log('');
130208

131209
// ── Per-locale missing keys ──

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

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,47 @@ export interface CoverageReport {
9999
errors: number;
100100
warnings: number;
101101
};
102+
/**
103+
* What this run did with the registry-driven `metadataForms.*` baseline, and
104+
* how many keys that decision moved.
105+
*
106+
* Reported rather than left implicit because the decision moves the
107+
* **denominator**: `stats[].expected` and `coveragePercent` mean different
108+
* things under the two modes, and a consumer reading a percentage out of
109+
* `os i18n check --json` has no other way to tell which one it is holding.
110+
* The console hint line is rendered from these same two numbers, so the two
111+
* faces of the command cannot disagree about it.
112+
*
113+
* `excludedKeys` is `0` under `'included'` — never absent, so a machine
114+
* consumer keying off presence never has to distinguish "counted them" from
115+
* "this version does not tell me".
116+
*/
117+
platformMetadataForms: {
118+
mode: PlatformMetadataFormsMode;
119+
/** Authored platform keys dropped from the expected set (0 when included). */
120+
excludedKeys: number;
121+
};
102122
}
103123

124+
/** The disposition a report actually reached — never `'auto'`, which is a request. */
125+
export type PlatformMetadataFormsMode = 'included' | 'excluded';
126+
127+
/**
128+
* What a caller asks for; {@link resolvePlatformMetadataForms} turns it into a
129+
* {@link PlatformMetadataFormsMode}.
130+
*
131+
* - `'include'` — count the baseline. The **default**, because `os lint` is
132+
* the other caller and it folds the baseline away at the REPORT seam
133+
* instead, off `CoverageIssue['source']`, so it needs the issues to exist
134+
* in order to count them for its `--include-platform` hint line. ⛔ Flipping
135+
* this default would zero that hint silently; `i18n-platform-bucket.test.ts`
136+
* pins the coupling.
137+
* - `'exclude'` — drop it.
138+
* - `'auto'` — drop it unless this stack authors it (see
139+
* {@link stackAuthorsMetadataForms}).
140+
*/
141+
export type PlatformMetadataFormsOption = 'include' | 'exclude' | 'auto';
142+
104143
export interface CoverageOptions {
105144
/**
106145
* The locale that *must* be translated. Missing keys here surface as
@@ -119,6 +158,12 @@ export interface CoverageOptions {
119158
* errors. Useful for CI gates that demand full translation parity.
120159
*/
121160
strict?: boolean;
161+
/**
162+
* How to treat the registry-driven `metadataForms.*` baseline. Defaults to
163+
* `'include'` — see {@link PlatformMetadataFormsOption} for why that, and not
164+
* `'auto'`, is the default at THIS seam.
165+
*/
166+
platformMetadataForms?: PlatformMetadataFormsOption;
122167
}
123168

124169
// ─── Bundle helpers ────────────────────────────────────────────────────
@@ -177,6 +222,74 @@ function flattenBundles(bundles: TranslationBundle[]): { merged: TranslationBund
177222
return { merged, locales: Array.from(localesSet).sort() };
178223
}
179224

225+
// ─── Who owns the platform baseline ────────────────────────────────────
226+
227+
/**
228+
* Does this stack author the registry-driven `metadataForms.*` baseline
229+
* itself?
230+
*
231+
* ## Why the question is asked of the CONFIG and not of a flag
232+
*
233+
* The `metadataForms.*` family is not walked out of the stack under
234+
* examination at all: {@link collectExpectedEntries} builds it from
235+
* `METADATA_FORM_REGISTRY` + `DEFAULT_METADATA_TYPE_REGISTRY`, identically for
236+
* every config, empty ones included — ~773 Studio-form keys. For an
237+
* application that is somebody else's surface: `@objectstack/platform-objects`
238+
* ships those translations and the runtime serves them, so an app-shipped copy
239+
* would *override* the platform's and go stale at the next upgrade. Counting
240+
* them against an app's coverage percentage therefore reports a debt the app
241+
* must not pay, which is what made `--strict` / `--threshold` unusable for an
242+
* app package — the two flags whose entire purpose is CI gating.
243+
*
244+
* An unconditional exclusion is the wrong repair and is deliberately not what
245+
* this is. It would turn the app side green by deleting the gate on the side
246+
* that *does* own those strings: `platform-objects`' own extract config carries
247+
* `metadataForms` in every locale bundle it declares, and its coverage number
248+
* is a real number about real work. So ownership is **observed**, from the one
249+
* place it is already written down — the bundles the stack itself attaches.
250+
* Ship the baseline and you are asked to complete it; ship none of it and it
251+
* is not yours.
252+
*
253+
* A non-empty **string leaf** is the test, not the mere presence of the group:
254+
* an empty `metadataForms: {}`, or a scaffold of empty strings, is what `os
255+
* i18n extract --fill=empty` leaves behind before anyone translates anything,
256+
* and reading that as a claim of ownership would hand an app the 773-key debt
257+
* on the strength of a placeholder. That is the same rule {@link lookupKey}
258+
* applies on every other bundle read: an empty translation is not a
259+
* translation.
260+
*/
261+
export function stackAuthorsMetadataForms(config: any): boolean {
262+
const bundles: unknown[] = Array.isArray(config?.translations) ? config.translations : [];
263+
const hasText = (node: unknown): boolean => {
264+
if (typeof node === 'string') return node.length > 0;
265+
if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
266+
return Object.values(node as Record<string, unknown>).some(hasText);
267+
};
268+
for (const bundle of bundles) {
269+
if (!bundle || typeof bundle !== 'object') continue;
270+
for (const data of Object.values(bundle as Record<string, unknown>)) {
271+
if (!data || typeof data !== 'object') continue;
272+
if (hasText((data as Record<string, unknown>).metadataForms)) return true;
273+
}
274+
}
275+
return false;
276+
}
277+
278+
/** Turn a caller's request into the disposition a report will record. */
279+
function resolvePlatformMetadataForms(
280+
option: PlatformMetadataFormsOption | undefined,
281+
config: any,
282+
): PlatformMetadataFormsMode {
283+
switch (option ?? 'include') {
284+
case 'exclude':
285+
return 'excluded';
286+
case 'auto':
287+
return stackAuthorsMetadataForms(config) ? 'included' : 'excluded';
288+
default:
289+
return 'included';
290+
}
291+
}
292+
180293
// ─── Expected key extraction ───────────────────────────────────────────
181294

182295
interface ExpectedKey {
@@ -501,12 +614,24 @@ export function computeI18nCoverage(config: any, opts: CoverageOptions = {}): Co
501614
// for two opposite facts.
502615
const authoredInBundle = (path: string[]): boolean =>
503616
Object.values(merged).some((data) => lookupKey(data, path) !== undefined);
504-
const expected = collectExpectedKeys(config).filter(
617+
const authored = collectExpectedKeys(config).filter(
505618
(key) =>
506619
key.inline !== undefined
507620
|| inlineLocaleAny(key.inlineLocales) !== undefined
508621
|| authoredInBundle(key.path),
509622
);
623+
624+
// The platform baseline is dropped from the POPULATION, not from the issue
625+
// list, because this report's headline number is a percentage: an app that
626+
// has translated every string it owns reads 38.9% while 773 of its 1265
627+
// "expected" keys belong to `@objectstack/platform-objects`. `os lint` folds
628+
// the same family away one seam later (`foldCoverageIssues`, keyed on
629+
// `CoverageIssue['source']`) and can afford to, because it reports findings
630+
// and never a denominator.
631+
const platformMode = resolvePlatformMetadataForms(opts.platformMetadataForms, config);
632+
const expected =
633+
platformMode === 'included' ? authored : authored.filter((key) => key.source !== 'metadataForm');
634+
const excludedPlatformKeys = authored.length - expected.length;
510635
const issues: CoverageIssue[] = [];
511636
const stats: CoverageStats[] = [];
512637

@@ -570,5 +695,6 @@ export function computeI18nCoverage(config: any, opts: CoverageOptions = {}): Co
570695
errors,
571696
warnings,
572697
},
698+
platformMetadataForms: { mode: platformMode, excludedKeys: excludedPlatformKeys },
573699
};
574700
}

0 commit comments

Comments
 (0)