Skip to content

cli: os i18n check cannot gate an app package — it has no --no-metadata-forms (which extract and lint both have) and collectExpectedEntries emits object action keys twice #16681

Description

@os-sam

Found while releasing an app built on the platform (objectstack-ai/ats#60, cli 17.3.0). The app's release card asked for "100 % label coverage from the i18n coverage report"; the report cannot say 100 % for an app package, for two reasons that are the tool's rather than the bundle's. Both verified in this repository's source, not inferred from the output.

1. check is missing the flag its two siblings have

With every app-owned key translated:

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

All 773 missing keys are metadataForms.* — the Studio's own metadata forms across 27 types. Three parts of this repository already agree those are the platform's to translate:

  • os lint hides them by default and says so: "platform built-ins: 773 i18n issue(s) hidden — rerun with --include-platform", the flag's help text adding "the platform packages ship those translations";
  • os i18n extract has --no-metadata-forms for the same reason;
  • the runtime already serves them — GET /api/v1/i18n/translations/zh-CN on an app that ships none returns a populated metadataForms group, out of @objectstack/platform-objects.

check is the odd one out. Flags actually declared, read straight off the two command files:

packages/cli/src/commands/i18n/extract.ts   --check --fill --filter --json --locales
                                            --no-merge --no-metadata-forms --objects-only --out
packages/cli/src/commands/i18n/check.ts     --json --locales --show-keys --strict --threshold

So --strict and --threshold — the two flags whose entire purpose is CI gating — are unusable for an app package: they fail on strings the app does not own and must not duplicate, since an app-shipped copy would override the platform's and go stale on the next upgrade.

2. collectExpectedEntries emits object action keys twice

packages/cli/src/utils/i18n-extract.ts walks actions in two places, both writing into the same namespace:

  • :1178// Inline object-level actions (some schemas declare them on the object).['objects', objectName, '_actions', aname]
  • :1252// ── Top-level actions ── → for an action naming an object, ['objects', objectName as string, '_actions', action.name]

and pushEntry (:504-516) appends without de-duplicating by path:

if (typeof sourceValue === 'string') {
  out.push({ path, sourceValue, inline: sourceValue, source, ...extra });
  return;
}

An app that declares its actions at the top level, bound to an object, is therefore counted once per walk. Measured on the app: the expected set holds 492 app-owned entries over 482 distinct keys, the surplus being exactly the ten objects.*._actions.* keys (ats_convert_inquiry label/description/confirmText/successMessage, ats_reject_inquiry label/successMessage, ats_spam_inquiry label/successMessage, ats_public_apply_link label/description).

Independent corroboration, from a tool that shares no code with the CLI. That app has since built its own source-parity gate which resolves labels through os i18n extract --json. It reports 482 keys from the extractor — matching the distinct count exactly, and ten short of what check expects. Two unrelated consumers agreeing on 482 while check says 492 puts the surplus in the counter, not in the app.

It also breaks an invariant this file states about itself

collectExpectedEntries' own doc comment (:1090-1096) says:

This is the single place the gate lives, so os lint's coverage report and os i18n extract's skeleton can never disagree about which keys an author is being asked for.

They do disagree here — the extractor emits 382 object keys where the check expects 392. The mechanism is that the skeleton is materialised as a nested tree, where writing the same path twice collapses onto one leaf, while the coverage count is expected.length on the flat array (i18n-coverage.ts:504, :552). The duplicate is invisible in one output and load-bearing in the other, which is why it has survived.

Related and already closed: #14728 fixed the same double-count shape in check:i18n-coverage ("70 of the 691 baselined units are byte-identical duplicate findings"). This is the surviving instance, in collectExpectedEntries itself. #16121 and #14894 are adjacent extract counting/flag defects, both closed; neither covers either half of this.

What would close it

  1. Give check the same platform-bucket default lint has — hidden unless asked for — or at minimum the --no-metadata-forms switch extract already accepts, so --strict / --threshold can gate an app package.
  2. De-duplicate by path in collectExpectedEntries (or make pushEntry idempotent per path), so the count and the skeleton agree the way the doc comment promises.

Until then an app cannot use the coverage percentage as a release gate. The one measured here uses objectstack lint --i18n-strict instead, which hides the platform bucket correctly, and reports the raw number as 38.9 % with the app-owned surface at 100 % rather than rounding the difference away.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions