Skip to content
Draft
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
22 changes: 22 additions & 0 deletions .changeset/i18n-extract-metadata-forms-flag-independence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@objectstack/cli": patch
---

`os i18n extract --no-metadata-forms` is honoured whatever `--objects-only` is set to, and the Studio metadata-form baseline lands in exactly one module.

The flag gated only the `<locale>.metadata-forms.generated.ts` companion. The stack module's renderer had a third mode, `kind: 'full'`, that serialised the WHOLE `TranslationData` — the baseline included — and `--no-objects-only` selected it. So the two flags stopped being independent the moment the second one was passed, in both directions:

- **`--no-metadata-forms --no-objects-only`** suppressed the companion and wrote the same keys into `<locale>.objects.generated.ts` instead. Driven on a one-object, one-app stack with `i18n.defaultLocale: 'zh-CN'`: the emitted zh-CN module carried **776 leaves, of which 773 were the metadata-form baseline** the flag had just switched off (the stack's own surface is 3). Those 773 are **English** — the default locale is filled from the source labels and the metadata-form registry authors them in English — so a non-English default locale shipped the platform's English Studio strings inside its own application bundle.
- **`--no-objects-only` alone** wrote those 773 keys **twice**, once in each module.

`--objects-only` picks the stack module's sub-tree; `--metadata-forms` decides whether the baseline is emitted at all, and it is now the only control over it **on both faces**. Both flags keep exactly the meaning their `--help` already gave them, and nothing here picks a winner between them — the overlap was in the emitter, never in the two meanings.

`'full'` is renamed `'stack'` and omits `metadataForms`, so the module a run writes and the baseline companion beside it are disjoint, and under `'stack'` the two together are everything the extractor built (3 + 773 = 776 on the fixture above — the extractor's own count, none dropped, none duplicated). ⚠️ That is a statement about the PAIR a run emits, not about "three kinds partitioning the leaves": `'objects'` is a sub-selection of `'stack'`, not a sibling of it.

`--json`, documented as "output JSON instead of writing files", mirrors that file set: `bundles` is the stack module and a `metadataForms` map is the companion, keyed by the locales whose companion would be written and gated by the same predicate. That map is new. It exists because the first cut of this change stopped the fold on the `--json` face as well and left the baseline with no JSON home at all — measured, `--json --no-objects-only` with the flag ON and with `--no-metadata-forms` returned payloads equal in every field but `duration`, so on that face the flag decided nothing, the mirror image of the defect this card reports. `metadataFormsCounts` reports the baseline's size in every run, as before.

**No bundle in this repository moves.** All nine extract configs run under the default `--objects-only`, whose emitted module, export name and type signature are byte-for-byte unchanged — `pnpm check:i18n` stays green on the committed tree. A stack that DOES pass `--no-objects-only` regenerates a smaller `<locale>.objects.generated.ts`: its export keeps its name and narrows from `TranslationData` to `Omit<TranslationData, 'metadataForms'>`, and the baseline it used to duplicate is in the companion beside it unless `--no-metadata-forms` says it should not be there at all.

**What content moves where.** On the file face nothing published loses content: under the default `--objects-only` the output is byte-identical, and under `--no-objects-only` the baseline moves out of the stack module into the companion the same command already writes — unless `--no-metadata-forms` says it should not exist, which is the ask. On the `--json` face the baseline moves from inside `bundles` to its own top-level key, and under `--no-metadata-forms` it is now absent, which it never was before: that face did not honour the flag at all.

The regression pin spawns the real CLI and takes a group census of the bytes it wrote, and drives `--json` in BOTH flag states. The one-state version of that case could not have failed on the axis that failed here — a pin that exercises only the flag-OFF path can never detect a flag that does nothing. The sibling pin that mirrors the emit rule and checks file NAMES stayed green through all of this: the file set was right in every combination, and only the content was wrong.
55 changes: 50 additions & 5 deletions packages/cli/src/commands/i18n/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
extractTranslations,
renderTranslationModule,
renderSourceHashModule,
stackAuthoredSubtree,
parseSourceHashModule,
narrowToCommittedSections,
type FillStrategy,
Expand Down Expand Up @@ -87,13 +88,14 @@ export default class I18nExtract extends Command {
default: false,
}),
'objects-only': Flags.boolean({
description: 'Emit only the objects/globalActions subtree (default). Disable to include apps/dashboards.',
description:
'Emit only the objects/globalActions subtree (default). Disable to include apps/dashboards. Never carries the Studio metadata-form baseline either way — that is --metadata-forms, which writes it to its own file.',
default: true,
allowNo: true,
}),
'metadata-forms': Flags.boolean({
description:
'Also write <locale>.metadata-forms.generated.ts for the Studio metadata-form baseline (default). Pass --no-metadata-forms in a package that owns only its own objects — that baseline belongs to one package, not every plugin.',
'Also write <locale>.metadata-forms.generated.ts for the Studio metadata-form baseline (default). Pass --no-metadata-forms in a package that owns only its own objects — that baseline belongs to one package, not every plugin. This is the only control over it: no other flag emits or suppresses that baseline.',
default: true,
allowNo: true,
}),
Expand Down Expand Up @@ -197,6 +199,16 @@ export default class I18nExtract extends Command {
// only its own objects passes `--no-metadata-forms`; without it, `--check`
// demands a baseline copy the package deliberately does not commit and
// fails on a tree that is in fact in sync.
//
// ⚠️ That orthogonality was a claim this file made and did not keep
// (#14894). It held only while `--objects-only` was in effect: under
// `--no-objects-only` the renderer's `kind: 'full'` folded the baseline
// into the objects module, so `--no-metadata-forms` suppressed a copy
// that was still being written next door — and with the flag left on,
// both copies were written. This predicate is now the ONLY thing that
// decides whether the baseline is emitted, because the stack module no
// longer carries it (`stackAuthoredSubtree`). Nothing here picks a winner
// between the two flags; there is no longer anything for them to contest.
const emitsMetadataForms = (locale: string): boolean =>
flags['metadata-forms'] && (metadataFormsCounts[locale] ?? 0) > 0;

Expand Down Expand Up @@ -251,9 +263,42 @@ export default class I18nExtract extends Command {
totalExpected: result.totalExpected,
counts: result.counts,
metadataFormsCounts,
bundles: objectsOnly
? Object.fromEntries(localesEmitted.map((l) => [l, result.bundles[l].objects ?? {}]))
: result.bundles,
// `--json` is documented as "output JSON instead of writing files",
// so this payload mirrors the FILE SET: `bundles` is the stack
// module, `metadataForms` below is the companion (#14894).
bundles: Object.fromEntries(
localesEmitted.map((l) => [
l,
objectsOnly ? (result.bundles[l].objects ?? {}) : stackAuthoredSubtree(result.bundles[l]),
]),
),
// The baseline's JSON home, gated by {@link emitsMetadataForms} —
// the SAME predicate that decides the companion file, deliberately
// not a second one.
//
// ⚠️ Two predicates is what the review of this card's first commit
// caught, and the reading is worth keeping: that commit stopped the
// `kind: 'full'` fold on this face too, and left the baseline with no
// JSON home at all. Driven on a one-object, one-app stack with
// `defaultLocale: 'zh-CN'`, `--json --no-objects-only` with the flag
// ON and with `--no-metadata-forms` produced payloads that were equal
// in every field but `duration` — 3 leaves in `bundles`, no baseline
// in either, and `metadataFormsCounts` reporting 773 in both. So on
// this face the flag decided NOTHING, in the opposite direction from
// the defect the card reported (where it was the fold that ignored
// it). A flag that is ignored is a flag that is ignored, whichever
// way the output falls.
//
// Keyed by locale and PRESENT ONLY for the locales whose companion is
// written, so the key set here and the `*.metadata-forms.generated.ts`
// set are the same set by construction. The map itself is always
// emitted — an empty map says "no baseline in this run", which is a
// reading; a missing key would be indistinguishable from an older CLI.
metadataForms: Object.fromEntries(
localesEmitted
.filter((l) => emitsMetadataForms(l))
.map((l) => [l, result.bundles[l].metadataForms ?? {}]),
),
duration: timer.elapsed(),
});
return;
Expand Down
71 changes: 62 additions & 9 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1838,6 +1838,22 @@ export function extractTranslations(config: any, opts: ExtractOptions = {}): Ext

// ─── Serialization ─────────────────────────────────────────────────────

/**
* One locale's translations MINUS the registry-driven `metadataForms`
* baseline — everything the stack itself authors (#14894).
*
* The baseline is not part of any stack's authored surface: it is derived from
* the platform's metadata-form registry, is identical for every stack, and has
* its own module (`<locale>.metadata-forms.generated.ts`) under its own flag
* (`--metadata-forms`). Folding it into the module beside them gave it a SECOND
* home, and the two homes then disagreed about who governed it — see
* {@link renderTranslationModule}.
*/
export function stackAuthoredSubtree(data: TranslationData): Omit<TranslationData, 'metadataForms'> {
const { metadataForms: _registryBaseline, ...authored } = data;
return authored;
}

/**
* Render a TranslationData skeleton as a TypeScript module body.
*
Expand All @@ -1846,9 +1862,46 @@ export function extractTranslations(config: any, opts: ExtractOptions = {}): Ext
*
* kind: 'objects' → `NonNullable<TranslationData['objects']>`
* kind: 'metadataForms' → `NonNullable<TranslationData['metadataForms']>`
* kind: 'full' → `TranslationData`
*
* `objectsOnly: true` (default) is a legacy alias for `kind: 'objects'`.
* kind: 'stack' → `Omit<TranslationData, 'metadataForms'>`
*
* ⚠️ The three are NOT three disjoint cells, and calling them a partition was
* imprecise enough to correct: `'objects'` is a SUB-SELECTION of `'stack'`, not
* a sibling of it. The invariant that actually holds — and the one this
* function exists to keep — is about a PAIR: whichever of `'objects'` /
* `'stack'` a run picks for the module it writes, that module and the
* `'metadataForms'` companion beside it are disjoint, and under `'stack'` the
* two together are the whole of what the extractor built. Measured on the
* fixture below: 3 + 773 = 776, which is the extractor's own count for that
* run — none dropped, none duplicated.
*
* `'stack'` was called `'full'` and rendered the whole `TranslationData`, which
* broke exactly that pairing — `metadataForms` landed in the stack module AND
* in its own companion.
*
* That is the #14894 defect, and it had two user-visible halves. Both were
* driven on a `defaultLocale: 'zh-CN'` stack (one object, one app) before this
* function changed:
*
* • `--no-metadata-forms` stopped suppressing the baseline the moment
* `--no-objects-only` was passed. The flag gated only the companion, while
* `'full'` inlined the same keys next door: `zh-CN.objects.generated.ts`
* came out holding 773 metadata-form leaves under an explicit
* `--no-metadata-forms`, against 2 object leaves and 1 app leaf. Those 773
* were ENGLISH — the default locale is filled from the source labels, and
* the registry authors them in English — so a non-English default locale
* shipped the platform's English Studio baseline inside its own bundle.
* • With `--metadata-forms` left ON, the same run emitted those 773 keys
* TWICE, once in each module.
*
* `--objects-only` and `--metadata-forms` are documented as independent, and
* they are: the first picks the stack module's sub-tree (`objects` alone, or
* everything the stack authors), the second decides whether the baseline is
* emitted at all. Neither has to win over the other, and this change invents no
* precedence between them — the overlap was in the emitter, never in the two
* meanings.
*
* `objectsOnly: true` (default) is a legacy alias for `kind: 'objects'`, and
* `objectsOnly: false` for `kind: 'stack'`.
*/
export function renderTranslationModule(
data: TranslationData,
Expand All @@ -1858,17 +1911,17 @@ export function renderTranslationModule(
/** Legacy: when true, emit only the `objects` sub-tree (typed accordingly). */
objectsOnly?: boolean;
/** Explicit sub-tree selector. Overrides `objectsOnly` when provided. */
kind?: 'objects' | 'metadataForms' | 'full';
kind?: 'objects' | 'metadataForms' | 'stack';
/** Header comment lines. */
header?: string[];
},
): string {
const kind: 'objects' | 'metadataForms' | 'full' =
options.kind ?? (options.objectsOnly === false ? 'full' : 'objects');
const kind: 'objects' | 'metadataForms' | 'stack' =
options.kind ?? (options.objectsOnly === false ? 'stack' : 'objects');
const defaultExport =
kind === 'metadataForms'
? `${camelize(options.locale)}MetadataForms`
: kind === 'full'
: kind === 'stack'
? `${camelize(options.locale)}Translations`
: `${camelize(options.locale)}Objects`;
const exportName = options.exportName ?? defaultExport;
Expand All @@ -1877,13 +1930,13 @@ export function renderTranslationModule(
? (data.metadataForms ?? {})
: kind === 'objects'
? (data.objects ?? {})
: data;
: stackAuthoredSubtree(data);
const typeSig =
kind === 'metadataForms'
? "NonNullable<TranslationData['metadataForms']>"
: kind === 'objects'
? "NonNullable<TranslationData['objects']>"
: 'TranslationData';
: "Omit<TranslationData, 'metadataForms'>";
const header = options.header ?? [
`Auto-generated by 'os i18n extract' for locale '${options.locale}'.`,
'Edit translations in place; re-run extract (with --merge) to fill new gaps.',
Expand Down
Loading
Loading