diff --git a/.changeset/i18n-extract-metadata-forms-flag-independence.md b/.changeset/i18n-extract-metadata-forms-flag-independence.md new file mode 100644 index 0000000000..2bbbc865e7 --- /dev/null +++ b/.changeset/i18n-extract-metadata-forms-flag-independence.md @@ -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 `.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 `.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 `.objects.generated.ts`: its export keeps its name and narrows from `TranslationData` to `Omit`, 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. diff --git a/packages/cli/src/commands/i18n/extract.ts b/packages/cli/src/commands/i18n/extract.ts index 28a77c4739..5c5e3c50c0 100644 --- a/packages/cli/src/commands/i18n/extract.ts +++ b/packages/cli/src/commands/i18n/extract.ts @@ -21,6 +21,7 @@ import { extractTranslations, renderTranslationModule, renderSourceHashModule, + stackAuthoredSubtree, parseSourceHashModule, narrowToCommittedSections, type FillStrategy, @@ -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 .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 .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, }), @@ -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; @@ -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; diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 420ac867df..1a44765627 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -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 (`.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 { + const { metadataForms: _registryBaseline, ...authored } = data; + return authored; +} + /** * Render a TranslationData skeleton as a TypeScript module body. * @@ -1846,9 +1862,46 @@ export function extractTranslations(config: any, opts: ExtractOptions = {}): Ext * * kind: 'objects' → `NonNullable` * kind: 'metadataForms' → `NonNullable` - * kind: 'full' → `TranslationData` - * - * `objectsOnly: true` (default) is a legacy alias for `kind: 'objects'`. + * kind: 'stack' → `Omit` + * + * ⚠️ 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, @@ -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; @@ -1877,13 +1930,13 @@ export function renderTranslationModule( ? (data.metadataForms ?? {}) : kind === 'objects' ? (data.objects ?? {}) - : data; + : stackAuthoredSubtree(data); const typeSig = kind === 'metadataForms' ? "NonNullable" : kind === 'objects' ? "NonNullable" - : 'TranslationData'; + : "Omit"; 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.', diff --git a/packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts b/packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts new file mode 100644 index 0000000000..c9fdbb1c7c --- /dev/null +++ b/packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts @@ -0,0 +1,252 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `--no-metadata-forms` is honoured whatever `--objects-only` is set to, and + * the Studio baseline lands in exactly one module either way (#14894). + * + * ## Why this drives the real CLI instead of mirroring the rule + * + * The sibling pin `i18n-extract-emitted-files.test.ts` re-implements the emit + * rule in the test and checks the FILE NAMES it produces. That shape is what + * let this defect through: the file set was right in every combination — the + * two flags picked the right two files — while the CONTENT of one of them was + * wrong, and a mirror of the rule cannot see the content because it never + * renders anything. So this file spawns `bin/run-dev.js` and reads the bytes + * the command actually wrote. + * + * ## What was wrong + * + * `--metadata-forms` gated only `.metadata-forms.generated.ts`, while + * the renderer's `kind: 'full'` (now `'stack'`) folded the same `metadataForms` + * sub-tree into `.objects.generated.ts` whenever `--no-objects-only` + * was passed. Measured on the fixture below before the fix: + * + * • `--no-metadata-forms --no-objects-only` → one file, 776 leaves, of which + * 773 were the metadata-form baseline the flag had just switched off. The + * stack's own surface was 3 leaves (one object label, one field label, one + * app label). + * • `--no-objects-only` alone → the same 773 keys in BOTH modules. + * + * The fixture's `defaultLocale` is `zh-CN` on purpose: the default locale is + * filled from the source labels, and the metadata-form registry authors those + * in English, so this is the shape from the report — a zh-CN bundle carrying + * the platform's English Studio strings. + * + * ## What is asserted, and what is deliberately not + * + * The assertions are a GROUP CENSUS of each emitted module (which top-level + * groups, and how many leaves), not a substring search. A duplicate group is + * invisible to `toContain`, and a duplicate is half of what this closes. + * + * ⛔ Not asserted: the exact baseline key count. It is registry-driven and + * moves with every `*.form.ts` in `packages/spec`; pinning it here would make + * this file fail for reasons that have nothing to do with the flags. What is + * pinned is the INVARIANT — the baseline is in the companion or nowhere, and + * never in two places at once. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readdirSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { childEnv } from './helpers/serve-process.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +/** + * The fixture goes under this package's git-ignored `tmp/`; the `--out` root + * stays in the system temp dir. The two are placed differently because only one + * of them has to RESOLVE anything. + * + * The config calls `defineStack`, so it must reach `@objectstack/spec`. + * `bundle-require` writes its bundled module NEXT TO the config and Node + * resolves the config's bare specifiers from THAT directory, so a project in + * the system temp dir cannot see the package — driven: `Cannot find package + * '@objectstack/spec' imported from /tmp/…/stack.config.bundled_….mjs`. Under + * `packages/cli/tmp/` the lookup walks up into this package's real + * `node_modules`, exactly as a user's project does. `serve-no-artifact.e2e` + * carries the same constraint and the same answer for the same reason; four + * other suites in this directory share the root. + * + * ⚠️ It is `tmp/` specifically, and NOT a directory named for this test: + * `dispatch-gates --self-test` asserts that every in-tree directory this tree's + * sources create is covered by a tracked ignore rule or tracked itself. An + * earlier revision used `packages/cli/test/.tmp-i18n-14894`, which nothing + * tracked ignores (the rules are `*.tmp` and `tmp/`), and that case failed + * naming this file. ⛔ The repair is never a bespoke ignore rule for one test. + * `git check-ignore -v packages/cli/tmp/x` answers `.gitignore:55:tmp/`. + * + * ⚠️ `afterAll` removes THIS suite's `mkdtemp` directory and never the shared + * `tmp/` root — the five suites using it run concurrently, and a root-level + * `rmSync` here would delete a sibling's fixture mid-run. + */ +const CLI_PACKAGE_ROOT = resolve(HERE, '..'); + +let fixtureRoot: string; +let CONFIG: string; + +const CONFIG_SOURCE = [ + "import { defineStack } from '@objectstack/spec';", + '', + 'export default defineStack({', + " i18n: { defaultLocale: 'zh-CN', supportedLocales: ['zh-CN'] },", + " objects: [{ name: 'kpi_metric', label: 'Metric', fields: { name: { type: 'text', label: 'Name' } } }],", + " apps: [{ name: 'kpi', label: 'KPI Console' }],", + '});', + '', +].join('\n'); + +let outRoot: string; + +beforeAll(() => { + const sharedRoot = join(CLI_PACKAGE_ROOT, 'tmp'); + mkdirSync(sharedRoot, { recursive: true }); + fixtureRoot = mkdtempSync(join(sharedRoot, 'os-i18n-14894-fixture-')); + CONFIG = join(fixtureRoot, 'stack.config.ts'); + writeFileSync(CONFIG, CONFIG_SOURCE, 'utf8'); + outRoot = mkdtempSync(join(tmpdir(), 'os-i18n-14894-')); +}); + +afterAll(() => { + // This suite's own directory only. ⛔ Never `sharedRoot`. + rmSync(fixtureRoot, { recursive: true, force: true }); + rmSync(outRoot, { recursive: true, force: true }); +}); + +/** Run the real command; returns stdout and the files it left in `--out`. */ +function extract(name: string, flags: string[]): { stdout: string; files: string[]; dir: string } { + const dir = join(outRoot, name); + const stdout = execFileSync(TSX, [CLI, 'i18n', 'extract', CONFIG, '--locales=zh-CN', ...flags, `--out=${dir}`], { + encoding: 'utf8', + env: childEnv(), + timeout: 180_000, + }); + return { stdout, files: readdirSync(dir).sort(), dir }; +} + +/** Top-level groups of a generated module's exported const, in emit order. */ +function groups(dir: string, file: string): string[] { + const src = readFileSync(join(dir, file), 'utf8'); + const body = src.slice(src.indexOf(' = {')); + return [...body.matchAll(/^ {2}("[^"]+"|[A-Za-z_$][\w$]*):/gm)].map((m) => m[1].replace(/"/g, '')); +} + +/** Leaf strings in a generated module — every `key: "value"` the emitter wrote. */ +function leaves(dir: string, file: string): number { + return (readFileSync(join(dir, file), 'utf8').match(/: "/g) ?? []).length; +} + +/** Leaf strings in a JSON payload sub-tree — the `--json` counterpart of {@link leaves}. */ +function countLeaves(value: unknown): number { + if (!value || typeof value !== 'object') return 0; + let n = 0; + for (const v of Object.values(value as Record)) { + if (typeof v === 'string') n += 1; + else if (v && typeof v === 'object') n += countLeaves(v); + } + return n; +} + +describe('os i18n extract — the metadata-form baseline has exactly one home (#14894)', () => { + it('honours --no-metadata-forms under --no-objects-only (the reported defect)', () => { + const run = extract('no-mf-no-oo', ['--no-metadata-forms', '--no-objects-only']); + + expect(run.files).toEqual(['zh-CN.objects.generated.ts']); + // The stack's own surface, and nothing else. Before the fix this module + // also carried a `metadataForms` group of 773 English leaves. + expect(groups(run.dir, 'zh-CN.objects.generated.ts')).toEqual(['objects', 'apps']); + expect(leaves(run.dir, 'zh-CN.objects.generated.ts')).toBe(3); + }); + + it('keeps the baseline out of the stack module even with --metadata-forms ON', () => { + const run = extract('mf-no-oo', ['--no-objects-only']); + + expect(run.files).toEqual(['zh-CN.metadata-forms.generated.ts', 'zh-CN.objects.generated.ts']); + expect(groups(run.dir, 'zh-CN.objects.generated.ts')).toEqual(['objects', 'apps']); + expect(leaves(run.dir, 'zh-CN.objects.generated.ts')).toBe(3); + // The baseline is emitted — in its own module, once. Before the fix these + // same keys were in both files. + expect(leaves(run.dir, 'zh-CN.metadata-forms.generated.ts')).toBeGreaterThan(100); + }); + + it('is unchanged under the default --objects-only, in both flag positions', () => { + const off = extract('no-mf-oo', ['--no-metadata-forms']); + expect(off.files).toEqual(['zh-CN.objects.generated.ts']); + expect(groups(off.dir, 'zh-CN.objects.generated.ts')).toEqual(['kpi_metric']); + + const on = extract('mf-oo', []); + expect(on.files).toEqual(['zh-CN.metadata-forms.generated.ts', 'zh-CN.objects.generated.ts']); + expect(groups(on.dir, 'zh-CN.objects.generated.ts')).toEqual(['kpi_metric']); + }); + + /** + * ⚠️ Driven in BOTH flag states, and that is the whole point of this case. + * + * The first version of this pin exercised `--no-metadata-forms` only. A pin + * that drives one state of a flag cannot detect that the flag does NOTHING, + * and this file's own subject is a flag that did nothing — so the pin had, on + * the `--json` face, exactly the blind spot the fix was about. It was not + * hypothetical: review measured `--json --no-objects-only` with the flag ON + * and with `--no-metadata-forms` returning payloads equal in every field but + * `duration`, and this case as first written was green for both. + * + * So the assertion is on the AXIS: the two states must differ, and each must + * be what the file face would have written for the same flags. + */ + it('--json mirrors the file set in BOTH flag states', () => { + const runJson = (flags: string[]) => { + const stdout = execFileSync( + TSX, + [CLI, 'i18n', 'extract', CONFIG, '--locales=zh-CN', '--json', '--no-objects-only', ...flags], + { encoding: 'utf8', env: childEnv(), timeout: 180_000 }, + ); + return JSON.parse(stdout) as { + bundles: Record>; + metadataForms: Record>; + metadataFormsCounts: Record; + duration: number; + }; + }; + + const on = runJson([]); + const off = runJson(['--no-metadata-forms']); + + // The stack module's payload is the same either way — this flag does not + // touch it. Both carry the app key, which is why `--no-objects-only` exists. + for (const payload of [on, off]) { + expect(Object.keys(payload.bundles['zh-CN']).sort()).toEqual(['apps', 'objects']); + // Suppressed or not, the operator can still see how big the baseline is. + expect(payload.metadataFormsCounts['zh-CN']).toBeGreaterThan(100); + } + + // Flag ON: the baseline is HERE, under its own key — the JSON counterpart + // of the companion file, keyed by the same locales. + expect(Object.keys(on.metadataForms)).toEqual(['zh-CN']); + expect(countLeaves(on.metadataForms['zh-CN'])).toBeGreaterThan(100); + expect(countLeaves(on.metadataForms['zh-CN'])).toBe(on.metadataFormsCounts['zh-CN']); + + // Flag OFF: no baseline anywhere in the payload, which is what the operator + // asked for — and the file run for the same flags writes no companion. + expect(on.metadataForms['zh-CN']).toBeDefined(); + expect(off.metadataForms['zh-CN']).toBeUndefined(); + expect(Object.keys(off.metadataForms)).toEqual([]); + + // The axis itself: the flag must MOVE the payload. `duration` is wall clock + // and is dropped, so it cannot manufacture a difference that is not there — + // it was the only field separating these two before the fix. + const withoutDuration = (p: Record) => { + const { duration: _elapsed, ...rest } = p; + return rest; + }; + expect(withoutDuration(on as unknown as Record)).not.toEqual( + withoutDuration(off as unknown as Record), + ); + }); + // Each case spawns the CLI through `tsx`; measured at ~6 s per run on a + // shared box, well over vitest's 5 s default. Same instrument and same + // generous ceiling as the sibling CLI-spawning pins in this directory. +}, 900_000); diff --git a/packages/cli/test/i18n-extract.test.ts b/packages/cli/test/i18n-extract.test.ts index a36c14e4de..78d91e0406 100644 --- a/packages/cli/test/i18n-extract.test.ts +++ b/packages/cli/test/i18n-extract.test.ts @@ -5,6 +5,7 @@ import { collectExpectedEntries, extractTranslations, renderTranslationModule, + stackAuthoredSubtree, } from '../src/utils/i18n-extract.js'; const config: any = { @@ -405,3 +406,99 @@ describe('renderTranslationModule', () => { expect(ts).toContain('"foo-bar":'); }); }); + +/** + * The module a run WRITES and the baseline companion beside it are disjoint, + * and under `kind: 'stack'` the two together are everything the extractor built + * (#14894). + * + * ⚠️ Not "the three kinds partition the leaves", which is how this block was + * first written and is wrong: `'objects'` is a SUB-SELECTION of `'stack'`, not + * a sibling cell. The invariant is about the PAIR a run emits. + * + * The pins are a census — which groups, how many leaves — rather than a + * substring search, because the defect this closes was one group present in TWO + * modules at once, and a substring pin cannot see a duplicate. + * + * Driven before the fix, `kind: 'stack'` (then `'full'`) rendered `data` + * itself: the stack module carried the `metadataForms` baseline as well, so + * `--no-metadata-forms` suppressed one copy while the other was written next to + * it, and with the flag on the baseline was emitted twice. + */ +describe('renderTranslationModule sub-tree selection (#14894)', () => { + /** A locale's data with all three groups populated, none of them empty. */ + const data = { + objects: { crm_account: { label: 'Account' } }, + apps: { kpi: { label: 'KPI Console' } }, + metadataForms: { object: { label: 'Object', sections: { basics: { label: 'Basics' } } } }, + } as any; + + /** Top-level groups of the emitted const, read off the rendered module. */ + const groups = (ts: string): string[] => + [...ts.slice(ts.indexOf(' = {')).matchAll(/^ {2}("[^"]+"|[A-Za-z_$][\w$]*):/gm)].map((m) => + m[1].replace(/"/g, ''), + ); + + it("kind 'objects' carries the objects sub-tree alone", () => { + const ts = renderTranslationModule(data, { locale: 'zh-CN', kind: 'objects' }); + expect(groups(ts)).toEqual(['crm_account']); + expect(ts).toContain("export const zhCNObjects: NonNullable"); + }); + + it("kind 'stack' carries everything the stack authors — and NOT the baseline", () => { + const ts = renderTranslationModule(data, { locale: 'zh-CN', kind: 'stack' }); + expect(groups(ts)).toEqual(['objects', 'apps']); + expect(groups(ts)).not.toContain('metadataForms'); + expect(ts).toContain("export const zhCNTranslations: Omit"); + // The English source text of the baseline is what a non-English default + // locale was shipping; it must not be anywhere in this module. + expect(ts).not.toContain('Basics'); + }); + + it("kind 'metadataForms' carries the baseline alone", () => { + const ts = renderTranslationModule(data, { locale: 'zh-CN', kind: 'metadataForms' }); + expect(groups(ts)).toEqual(['object']); + expect(ts).toContain("export const zhCNMetadataForms: NonNullable"); + }); + + it("objectsOnly: false is the legacy alias for kind 'stack', not for the whole bundle", () => { + expect(renderTranslationModule(data, { locale: 'zh-CN', objectsOnly: false })).toBe( + renderTranslationModule(data, { locale: 'zh-CN', kind: 'stack' }), + ); + }); + + it('emits every leaf exactly once across the pair a stack run writes', () => { + const leaves = (ts: string) => (ts.match(/: "/g) ?? []).length; + const stack = renderTranslationModule(data, { locale: 'zh-CN', kind: 'stack' }); + const forms = renderTranslationModule(data, { locale: 'zh-CN', kind: 'metadataForms' }); + // 2 stack leaves (one object label, one app label) + 2 baseline leaves. + expect(leaves(stack)).toBe(2); + expect(leaves(forms)).toBe(2); + expect(leaves(stack) + leaves(forms)).toBe(4); + }); +}); + +/** + * `stackAuthoredSubtree` is a pure omission and nothing else (#14894) — the + * caller passes the extractor's own bundle object, so a version of it that + * mutated its argument would corrupt the data the metadata-forms module is + * rendered from a few lines later in the same run. + */ +describe('stackAuthoredSubtree', () => { + it('drops metadataForms and keeps every other group, without mutating the input', () => { + const input = { + objects: { a: { label: 'A' } }, + apps: { b: { label: 'B' } }, + metadataForms: { object: { label: 'Object' } }, + } as any; + const out = stackAuthoredSubtree(input); + expect(Object.keys(out).sort()).toEqual(['apps', 'objects']); + expect(out.objects).toBe(input.objects); + expect(Object.keys(input).sort()).toEqual(['apps', 'metadataForms', 'objects']); + }); + + it('is a no-op on data that never had a baseline', () => { + const input = { objects: { a: { label: 'A' } } } as any; + expect(stackAuthoredSubtree(input)).toEqual(input); + }); +});