Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .changeset/i18n-check-platform-bucket-and-app-gating.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
"@objectstack/cli": minor
---

fix(cli): `os i18n check` counts the coverage an app actually owns, so `--strict` / `--threshold` can gate an app package (#16681)

## What was wrong

`collectExpectedEntries` walks the Studio metadata-form registries
unconditionally — identically for every config, an empty one included — so
every stack's expected set carries ~773 `metadataForms.*` keys that
`@objectstack/platform-objects` translates and the runtime already serves.

Two of the three commands that see that family already knew it is not the
author's. `os lint` hides it and says so ("platform built-ins: 773 i18n
issue(s) hidden — rerun with `--include-platform`"); `os i18n extract` has
`--no-metadata-forms`. `os i18n check` is the one command that publishes a
**percentage**, and it carried the baseline in its denominator:

```
Coverage by locale
en ████████████████████████ 100.0% (1265/1265, missing 0)
zh-CN █████████░░░░░░░░░░░░░░░ 38.9% (492/1265, missing 773)
```

That is an application with every key it owns translated. `--strict` and
`--threshold` — the two flags whose entire purpose is CI gating — therefore
could not gate an app package at all, and the only way to move the number was
to ship a copy of the platform's bundle, which would *override* the platform's
own and go stale at the next upgrade. The workaround was worse than the defect.

## What it does now

**Ownership is observed, not assumed.** The baseline counts toward coverage
when the stack under examination ships those translations itself, and does not
when it does not — read from the config's own `translations` bundles, requiring
a non-empty string leaf so an `--fill=empty` scaffold is not mistaken for a
claim of ownership. An app gets a number about its own surface with no flag;
`platform-objects`, which does ship the family, stays gated on it with no flag
either. An unconditional exclusion would have turned the app side green by
deleting the platform's own gate, and is what the negative-control tests forbid.

**The flag is `os lint`'s, spelling and all.** `--include-platform` forces the
baseline in; `--no-include-platform` forces it out, for a package that ships a
partial baseline and does not intend to own the rest. Absent, the decision is
the observed one — three states, not two.

**Both output faces carry the decision.** `--json` gains
`platformMetadataForms: { mode, excludedKeys }`, and the console prints
`platform built-ins: N key(s) not counted — rerun with --include-platform to
gate them here` under the coverage table, rendered from those same two numbers.

`os lint` is unchanged. The shared `computeI18nCoverage` seam still counts the
baseline by default, because lint folds it away one seam later and counts what
it folded for its own hint line.

## Compatibility

Additive on the command surface; an invocation that was refused is now
accepted, and no flag is removed or renamed. The behaviour that changes is the
**default coverage number for a stack that ships no `metadataForms` bundle** —
it stops reporting a debt that stack must not pay. A run that wants the old
numbers back asks for them with `--include-platform`, on the same argv.
17 changes: 17 additions & 0 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,23 @@ A missing string in the **default** locale is an error; missing strings in
other locales are warnings until you set `--strict` / `--threshold`. The Todo
example ships a completeness test alongside its bundles — worth copying.

### What counts as *your* coverage

The Studio's own metadata forms (`metadataForms.*` — several hundred keys
across every metadata type) are translated by `@objectstack/platform-objects`
and served from there, so they are **not** in your coverage number: an app that
translated everything it declares reads 100%, not 39%. ⛔ Do not "fix" a low
number by shipping your own `metadataForms` bundle — yours would override the
platform's and go stale at the next upgrade.

The rule is **ownership**, read from your own bundles rather than assumed: ship
translations for that family and you are asked to complete them, which is how
the platform packages stay gated on the strings they do own. Pass
`--include-platform` to audit the baseline anyway (`os lint`'s flag, same
meaning), or `--no-include-platform` to keep it out even though you ship part
of it. The command prints how many keys it left out, and `--json` carries the
same two numbers as `platformMetadataForms`.

### Which locales get checked

Your project decides, and the tooling never assumes. `os lint`, `os i18n check`
Expand Down
78 changes: 78 additions & 0 deletions packages/cli/src/commands/i18n/check.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,44 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `os i18n check` — the coverage gate.
*
* ## The platform `metadataForms.*` baseline, across all three commands
*
* `collectExpectedEntries` walks the Studio metadata-form registries
* unconditionally, identically for every config — ~773 keys that
* `@objectstack/platform-objects` translates and the runtime serves. Three
* commands see that family and each has to say something about it. They used
* to say three different things, and this one said nothing at all, which is
* how `--strict` / `--threshold` — the two flags whose entire purpose is CI
* gating — became unusable for an application package: every app read ~39%
* with its own surface fully translated, and the only way to "fix" the number
* was to ship a copy of the platform's bundle that would override it and go
* stale at the next upgrade.
*
* command what the baseline does to it default opt-in / out
* ------------------ ------------------------------- ------------- ---------------------------
* os lint adds findings to the report hidden --include-platform
* os i18n extract adds a companion FILE / JSON emitted --no-metadata-forms
* member (`metadataFormsCounts`
* reports its size either way)
* os i18n check moves the coverage DENOMINATOR auto: counted --include-platform /
* only when --no-include-platform
* this stack
* ships their
* translations
*
* ⚠️ The three differ because the OUTPUTS differ, and reading the table as
* three dialects of one setting is the mistake it exists to prevent: `lint`
* reports findings and can fold at the report seam; `extract` writes files and
* chooses a file set; only `check` publishes a **percentage**, so for it the
* question is which keys are in the denominator. That is also why this command
* is the one that can answer it without a flag — ownership of the baseline is
* observable from the config's own bundles ({@link stackAuthorsMetadataForms}),
* so an app gets its own number and `platform-objects`, which ships those
* translations, keeps being gated on them.
*/

import { Args, Command, Flags } from '@oclif/core';
import chalk from 'chalk';
import { normalizeStackInput } from '@objectstack/spec';
Expand Down Expand Up @@ -52,6 +91,7 @@ export default class I18nCheck extends Command {
'$ os i18n check ./objectstack.config.ts',
'$ os i18n check --locales=en,zh-CN,ja-JP',
'$ os i18n check --strict --threshold=95',
'$ os i18n check --include-platform',
'$ os i18n check --json',
];

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

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

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

// ── Per-locale missing keys ──
Expand Down
128 changes: 127 additions & 1 deletion packages/cli/src/utils/i18n-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,47 @@ export interface CoverageReport {
errors: number;
warnings: number;
};
/**
* What this run did with the registry-driven `metadataForms.*` baseline, and
* how many keys that decision moved.
*
* Reported rather than left implicit because the decision moves the
* **denominator**: `stats[].expected` and `coveragePercent` mean different
* things under the two modes, and a consumer reading a percentage out of
* `os i18n check --json` has no other way to tell which one it is holding.
* The console hint line is rendered from these same two numbers, so the two
* faces of the command cannot disagree about it.
*
* `excludedKeys` is `0` under `'included'` — never absent, so a machine
* consumer keying off presence never has to distinguish "counted them" from
* "this version does not tell me".
*/
platformMetadataForms: {
mode: PlatformMetadataFormsMode;
/** Authored platform keys dropped from the expected set (0 when included). */
excludedKeys: number;
};
}

/** The disposition a report actually reached — never `'auto'`, which is a request. */
export type PlatformMetadataFormsMode = 'included' | 'excluded';

/**
* What a caller asks for; {@link resolvePlatformMetadataForms} turns it into a
* {@link PlatformMetadataFormsMode}.
*
* - `'include'` — count the baseline. The **default**, because `os lint` is
* the other caller and it folds the baseline away at the REPORT seam
* instead, off `CoverageIssue['source']`, so it needs the issues to exist
* in order to count them for its `--include-platform` hint line. ⛔ Flipping
* this default would zero that hint silently; `i18n-platform-bucket.test.ts`
* pins the coupling.
* - `'exclude'` — drop it.
* - `'auto'` — drop it unless this stack authors it (see
* {@link stackAuthorsMetadataForms}).
*/
export type PlatformMetadataFormsOption = 'include' | 'exclude' | 'auto';

export interface CoverageOptions {
/**
* The locale that *must* be translated. Missing keys here surface as
Expand All @@ -119,6 +158,12 @@ export interface CoverageOptions {
* errors. Useful for CI gates that demand full translation parity.
*/
strict?: boolean;
/**
* How to treat the registry-driven `metadataForms.*` baseline. Defaults to
* `'include'` — see {@link PlatformMetadataFormsOption} for why that, and not
* `'auto'`, is the default at THIS seam.
*/
platformMetadataForms?: PlatformMetadataFormsOption;
}

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

// ─── Who owns the platform baseline ────────────────────────────────────

/**
* Does this stack author the registry-driven `metadataForms.*` baseline
* itself?
*
* ## Why the question is asked of the CONFIG and not of a flag
*
* The `metadataForms.*` family is not walked out of the stack under
* examination at all: {@link collectExpectedEntries} builds it from
* `METADATA_FORM_REGISTRY` + `DEFAULT_METADATA_TYPE_REGISTRY`, identically for
* every config, empty ones included — ~773 Studio-form keys. For an
* application that is somebody else's surface: `@objectstack/platform-objects`
* ships those translations and the runtime serves them, so an app-shipped copy
* would *override* the platform's and go stale at the next upgrade. Counting
* them against an app's coverage percentage therefore reports a debt the app
* must not pay, which is what made `--strict` / `--threshold` unusable for an
* app package — the two flags whose entire purpose is CI gating.
*
* An unconditional exclusion is the wrong repair and is deliberately not what
* this is. It would turn the app side green by deleting the gate on the side
* that *does* own those strings: `platform-objects`' own extract config carries
* `metadataForms` in every locale bundle it declares, and its coverage number
* is a real number about real work. So ownership is **observed**, from the one
* place it is already written down — the bundles the stack itself attaches.
* Ship the baseline and you are asked to complete it; ship none of it and it
* is not yours.
*
* A non-empty **string leaf** is the test, not the mere presence of the group:
* an empty `metadataForms: {}`, or a scaffold of empty strings, is what `os
* i18n extract --fill=empty` leaves behind before anyone translates anything,
* and reading that as a claim of ownership would hand an app the 773-key debt
* on the strength of a placeholder. That is the same rule {@link lookupKey}
* applies on every other bundle read: an empty translation is not a
* translation.
*/
export function stackAuthorsMetadataForms(config: any): boolean {
const bundles: unknown[] = Array.isArray(config?.translations) ? config.translations : [];
const hasText = (node: unknown): boolean => {
if (typeof node === 'string') return node.length > 0;
if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
return Object.values(node as Record<string, unknown>).some(hasText);
};
for (const bundle of bundles) {
if (!bundle || typeof bundle !== 'object') continue;
for (const data of Object.values(bundle as Record<string, unknown>)) {
if (!data || typeof data !== 'object') continue;
if (hasText((data as Record<string, unknown>).metadataForms)) return true;
}
}
return false;
}

/** Turn a caller's request into the disposition a report will record. */
function resolvePlatformMetadataForms(
option: PlatformMetadataFormsOption | undefined,
config: any,
): PlatformMetadataFormsMode {
switch (option ?? 'include') {
case 'exclude':
return 'excluded';
case 'auto':
return stackAuthorsMetadataForms(config) ? 'included' : 'excluded';
default:
return 'included';
}
}

// ─── Expected key extraction ───────────────────────────────────────────

interface ExpectedKey {
Expand Down Expand Up @@ -501,12 +614,24 @@ export function computeI18nCoverage(config: any, opts: CoverageOptions = {}): Co
// for two opposite facts.
const authoredInBundle = (path: string[]): boolean =>
Object.values(merged).some((data) => lookupKey(data, path) !== undefined);
const expected = collectExpectedKeys(config).filter(
const authored = collectExpectedKeys(config).filter(
(key) =>
key.inline !== undefined
|| inlineLocaleAny(key.inlineLocales) !== undefined
|| authoredInBundle(key.path),
);

// The platform baseline is dropped from the POPULATION, not from the issue
// list, because this report's headline number is a percentage: an app that
// has translated every string it owns reads 38.9% while 773 of its 1265
// "expected" keys belong to `@objectstack/platform-objects`. `os lint` folds
// the same family away one seam later (`foldCoverageIssues`, keyed on
// `CoverageIssue['source']`) and can afford to, because it reports findings
// and never a denominator.
const platformMode = resolvePlatformMetadataForms(opts.platformMetadataForms, config);
const expected =
platformMode === 'included' ? authored : authored.filter((key) => key.source !== 'metadataForm');
const excludedPlatformKeys = authored.length - expected.length;
const issues: CoverageIssue[] = [];
const stats: CoverageStats[] = [];

Expand Down Expand Up @@ -570,5 +695,6 @@ export function computeI18nCoverage(config: any, opts: CoverageOptions = {}): Co
errors,
warnings,
},
platformMetadataForms: { mode: platformMode, excludedKeys: excludedPlatformKeys },
};
}
Loading
Loading