Skip to content

Commit 028f271

Browse files
committed
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>
1 parent a9550bf commit 028f271

5 files changed

Lines changed: 845 additions & 1 deletion

File tree

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)