From 5da3bfea1e6bca0078cf559ce9e6d5c40ad89ce5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 01:43:37 +0000 Subject: [PATCH 1/4] fix(cli): honour `--no-metadata-forms` whatever `--objects-only` is set to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `os i18n extract --no-metadata-forms` 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 Studio metadata-form baseline included — and `--no-objects-only` selected it. So the two flags stopped being independent as soon as the second was passed, in both directions: * `--no-metadata-forms --no-objects-only` suppressed the companion and wrote the same keys into `.objects.generated.ts`. Driven on a one-object, one-app stack with `i18n.defaultLocale: 'zh-CN'`: 776 leaves emitted, 773 of them the baseline the flag had just switched off. Those 773 are English — the default locale is filled from the source labels — 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 per module. The renderer's three modes are now a partition of one locale's generated leaves: `'full'` becomes `'stack'` and omits `metadataForms`, so every leaf has exactly one module it can land in and `--metadata-forms` is the only control over the baseline. Neither flag's documented meaning changes and no precedence is invented between them — the overlap was in the emitter. `--json` carries the same payload the files carry, for the reason its own help gives. No bundle in this repository moves: all nine extract configs run under the default `--objects-only`, whose module is byte-for-byte unchanged. The regression pin spawns the real CLI and takes a group census of the bytes it wrote. The sibling pin that mirrors the emit rule and checks file NAMES was green throughout: the file set was right in every combination, and only the content of one file was wrong. Fixes #14894 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- ...xtract-metadata-forms-flag-independence.md | 16 ++ packages/cli/src/commands/i18n/extract.ts | 32 +++- packages/cli/src/utils/i18n-extract.ts | 62 ++++++- ...8n-extract-metadata-forms-flag.e2e.test.ts | 169 ++++++++++++++++++ packages/cli/test/i18n-extract.test.ts | 93 ++++++++++ 5 files changed, 358 insertions(+), 14 deletions(-) create mode 100644 .changeset/i18n-extract-metadata-forms-flag-independence.md create mode 100644 packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts 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..2660c07be2 --- /dev/null +++ b/.changeset/i18n-extract-metadata-forms-flag-independence.md @@ -0,0 +1,16 @@ +--- +"@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. Both 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. The renderer's three modes are now a partition of one locale's generated leaves (`'full'` is renamed `'stack'` and omits `metadataForms`), so every leaf has exactly one module it can land in. `--json`, documented as "output JSON instead of writing files", carries the same payload the files do; the baseline's size is still reported there, in `metadataFormsCounts`. + +**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. + +The regression pin spawns the real CLI and takes a group census of the bytes it wrote. 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 of one file was wrong. diff --git a/packages/cli/src/commands/i18n/extract.ts b/packages/cli/src/commands/i18n/extract.ts index 28a77c4739..e1802bb278 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,19 @@ 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, + // The same payload the files carry, for the same reason (#14894): + // `--json` is documented as "output JSON instead of writing files", + // so a sub-tree that cannot reach a bundle file must not reach this + // either. `metadataForms` never could under `--objects-only`, and + // reached this payload under `--no-objects-only` only through the + // same `kind: 'full'` fold the emitter has stopped doing — its size + // is still reported, in `metadataFormsCounts`. + bundles: Object.fromEntries( + localesEmitted.map((l) => [ + l, + objectsOnly ? (result.bundles[l].objects ?? {}) : stackAuthoredSubtree(result.bundles[l]), + ]), + ), duration: timer.elapsed(), }); return; diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 420ac867df..6899355f09 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,37 @@ 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 a PARTITION of one locale's generated leaves: every leaf has + * exactly one module it can land in. `'stack'` was called `'full'` and rendered + * the whole `TranslationData`, which broke the partition — `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 +1902,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 +1921,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..be4f2f3beb --- /dev/null +++ b/packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts @@ -0,0 +1,169 @@ +// 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 { mkdtempSync, mkdirSync, 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 lives under `packages/cli` rather than in `tmpdir` because + * `bundle-require` resolves the config's own `@objectstack/spec` import from + * the config file's directory — from outside the workspace there is no + * `node_modules` to find it in. + */ +const FIXTURE_DIR = join(HERE, '.tmp-i18n-14894'); +const CONFIG = join(FIXTURE_DIR, 'stack.config.ts'); + +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(() => { + mkdirSync(FIXTURE_DIR, { recursive: true }); + writeFileSync(CONFIG, CONFIG_SOURCE, 'utf8'); + outRoot = mkdtempSync(join(tmpdir(), 'os-i18n-14894-')); +}); + +afterAll(() => { + rmSync(FIXTURE_DIR, { 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; +} + +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']); + }); + + it('--json carries the same payload the files would have carried', () => { + // `--json` is documented as "output JSON instead of writing files", so the + // sub-tree the emitter refuses to write must not arrive here either. + const stdout = execFileSync( + TSX, + [CLI, 'i18n', 'extract', CONFIG, '--locales=zh-CN', '--json', '--no-objects-only', '--no-metadata-forms'], + { encoding: 'utf8', env: childEnv(), timeout: 180_000 }, + ); + const payload = JSON.parse(stdout) as { + bundles: Record>; + metadataFormsCounts: Record; + }; + + expect(Object.keys(payload.bundles['zh-CN']).sort()).toEqual(['apps', 'objects']); + // Suppressed from the payload, still reported as a count — the operator can + // still see how big the baseline they opted out of is. + expect(payload.metadataFormsCounts['zh-CN']).toBeGreaterThan(100); + }); + // 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..e72d03d5a2 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,95 @@ describe('renderTranslationModule', () => { expect(ts).toContain('"foo-bar":'); }); }); + +/** + * The three module kinds PARTITION one locale's generated leaves (#14894). + * + * Every leaf has exactly one module it can land in, and the pins below are + * written as a census — which groups are present, and how many leaves — rather + * than as a substring search, because the defect this closes was a whole group + * being 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 partition (#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('leaves no leaf without a home and gives no leaf two', () => { + 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); + }); +}); From ac8add2e4f42e3d38e2813b7a165e8e0e8111878 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 03:02:46 +0000 Subject: [PATCH 2/4] fix(cli): give the metadata-form baseline a `--json` home under the companion's own predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the first commit found the `--json` face left worse than it was found. That commit stopped the `kind: 'full'` fold everywhere, including here, and this face has no second file for the baseline to move to — so it was dropped outright. Driven on the same one-object, one-app `defaultLocale: 'zh-CN'` fixture, `--json --no-objects-only` with `--metadata-forms` ON and with `--no-metadata-forms` returned payloads equal in every field but `duration`: 3 leaves in `bundles`, no baseline in either, and `metadataFormsCounts` reporting 773 in both. On that face the flag decided NOTHING — the mirror image of the defect this card reports, and it falsified three sentences the PR and changeset had already made. The payload now mirrors the FILE SET: `bundles` is the stack module and a new `metadataForms` map is the companion, keyed by the locales whose companion would be written and gated by `emitsMetadataForms(locale)` — the SAME predicate, deliberately not a second one. The map is always emitted; an empty one reads as "no baseline in this run", where a missing key would be indistinguishable from an older CLI. The `--json` pin drove `--no-metadata-forms` only, so it was green for both states of a flag that did nothing. It now drives BOTH and asserts the axis: the two payloads must differ once `duration` is dropped, the flag-ON payload must carry the baseline under its own key with a leaf count matching `metadataFormsCounts`, and the flag-OFF payload must carry no baseline at all. Also corrected, in the renderer's docs and the unit pins: the three kinds are NOT three disjoint cells. `'objects'` is a sub-selection of `'stack'`. The invariant that holds is about the PAIR a run emits — the module it writes and the companion beside it are disjoint, and under `'stack'` the two together are everything the extractor built (3 + 773 = 776, the extractor's own count). Part of #14894 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- ...xtract-metadata-forms-flag-independence.md | 10 ++- packages/cli/src/commands/i18n/extract.ts | 35 ++++++-- packages/cli/src/utils/i18n-extract.ts | 17 +++- ...8n-extract-metadata-forms-flag.e2e.test.ts | 86 +++++++++++++++---- packages/cli/test/i18n-extract.test.ts | 20 +++-- 5 files changed, 133 insertions(+), 35 deletions(-) diff --git a/.changeset/i18n-extract-metadata-forms-flag-independence.md b/.changeset/i18n-extract-metadata-forms-flag-independence.md index 2660c07be2..2bbbc865e7 100644 --- a/.changeset/i18n-extract-metadata-forms-flag-independence.md +++ b/.changeset/i18n-extract-metadata-forms-flag-independence.md @@ -9,8 +9,14 @@ The flag gated only the `.metadata-forms.generated.ts` companion. The st - **`--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. Both 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. The renderer's three modes are now a partition of one locale's generated leaves (`'full'` is renamed `'stack'` and omits `metadataForms`), so every leaf has exactly one module it can land in. `--json`, documented as "output JSON instead of writing files", carries the same payload the files do; the baseline's size is still reported there, in `metadataFormsCounts`. +`--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. -The regression pin spawns the real CLI and takes a group census of the bytes it wrote. 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 of one file was wrong. +**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 e1802bb278..5c5e3c50c0 100644 --- a/packages/cli/src/commands/i18n/extract.ts +++ b/packages/cli/src/commands/i18n/extract.ts @@ -263,19 +263,42 @@ export default class I18nExtract extends Command { totalExpected: result.totalExpected, counts: result.counts, metadataFormsCounts, - // The same payload the files carry, for the same reason (#14894): // `--json` is documented as "output JSON instead of writing files", - // so a sub-tree that cannot reach a bundle file must not reach this - // either. `metadataForms` never could under `--objects-only`, and - // reached this payload under `--no-objects-only` only through the - // same `kind: 'full'` fold the emitter has stopped doing — its size - // is still reported, in `metadataFormsCounts`. + // 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 6899355f09..1a44765627 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -1864,10 +1864,19 @@ export function stackAuthoredSubtree(data: TranslationData): Omit` * kind: 'stack' → `Omit` * - * The three are a PARTITION of one locale's generated leaves: every leaf has - * exactly one module it can land in. `'stack'` was called `'full'` and rendered - * the whole `TranslationData`, which broke the partition — `metadataForms` - * landed in the stack module AND in its own companion. + * ⚠️ 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 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 index be4f2f3beb..3dbb7bf102 100644 --- a/packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts +++ b/packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts @@ -113,6 +113,17 @@ 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']); @@ -145,23 +156,68 @@ describe('os i18n extract — the metadata-form baseline has exactly one home (# expect(groups(on.dir, 'zh-CN.objects.generated.ts')).toEqual(['kpi_metric']); }); - it('--json carries the same payload the files would have carried', () => { - // `--json` is documented as "output JSON instead of writing files", so the - // sub-tree the emitter refuses to write must not arrive here either. - const stdout = execFileSync( - TSX, - [CLI, 'i18n', 'extract', CONFIG, '--locales=zh-CN', '--json', '--no-objects-only', '--no-metadata-forms'], - { encoding: 'utf8', env: childEnv(), timeout: 180_000 }, - ); - const payload = JSON.parse(stdout) as { - bundles: Record>; - metadataFormsCounts: Record; + /** + * ⚠️ 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; + }; }; - expect(Object.keys(payload.bundles['zh-CN']).sort()).toEqual(['apps', 'objects']); - // Suppressed from the payload, still reported as a count — the operator can - // still see how big the baseline they opted out of is. - expect(payload.metadataFormsCounts['zh-CN']).toBeGreaterThan(100); + 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 diff --git a/packages/cli/test/i18n-extract.test.ts b/packages/cli/test/i18n-extract.test.ts index e72d03d5a2..78d91e0406 100644 --- a/packages/cli/test/i18n-extract.test.ts +++ b/packages/cli/test/i18n-extract.test.ts @@ -408,20 +408,24 @@ describe('renderTranslationModule', () => { }); /** - * The three module kinds PARTITION one locale's generated leaves (#14894). + * 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). * - * Every leaf has exactly one module it can land in, and the pins below are - * written as a census — which groups are present, and how many leaves — rather - * than as a substring search, because the defect this closes was a whole group - * being present in TWO modules at once and a substring pin cannot see a - * duplicate. + * ⚠️ 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 partition (#14894)', () => { +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' } }, @@ -463,7 +467,7 @@ describe('renderTranslationModule sub-tree partition (#14894)', () => { ); }); - it('leaves no leaf without a home and gives no leaf two', () => { + 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' }); From 6298b30163d30f6fcba589b28729725e95744ab9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 04:33:40 +0000 Subject: [PATCH 3/4] test(cli): move the i18n-extract e2e fixture root out of the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placement only — no reviewed source moves. The fixture root was `packages/cli/test/.tmp-i18n-14894`, created by the test at run time. Nothing tracked ignores `.tmp-*` (the repo's rules cover `tmp/` and `*.tmp`), so `dispatch-gates --self-test` failed its "every in-tree directory this tree's sources create is covered by a tracked ignore rule, or is tracked itself" case, quoting this file. The repair is not a bespoke ignore rule for one test: it is not creating the directory in the tree at all. Both roots are now `mkdtempSync` under the system temp dir, which is where this file's `--out` root already was. That forces one adjustment, because the two are the same fact: `bundle-require` writes its bundled module NEXT TO the config, and Node resolves the config's bare specifiers from THAT directory — so the fixture's `defineStack` import cannot survive the move. Measured: `Cannot find package '@objectstack/spec' imported from /tmp/…/stack.config.bundled_….mjs`. The config is now a plain object default export, which is what the command consumes anyway (`normalizeStackInput` on whatever is exported), and every reading is unchanged — 3 stack leaves, 773 baseline leaves, empty under `--no-metadata-forms`. ⚠️ Recorded in the file rather than glossed: `defineStack` used to validate the fixture at load and a plain object is not validated the same way. Measured on this tree, dropping `type` from the field is refused under `defineStack` ("Invalid field type ''") and accepted silently without it. The exact leaf counts asserted below are the remaining guard. Part of #14894 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- ...8n-extract-metadata-forms-flag.e2e.test.ts | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) 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 index 3dbb7bf102..51fc84b9d1 100644 --- a/packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts +++ b/packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts @@ -47,7 +47,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readdirSync, readFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync, readdirSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -58,35 +58,55 @@ const CLI = resolve(HERE, '../bin/run-dev.js'); const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); /** - * The fixture lives under `packages/cli` rather than in `tmpdir` because - * `bundle-require` resolves the config's own `@objectstack/spec` import from - * the config file's directory — from outside the workspace there is no - * `node_modules` to find it in. + * Both roots live in the SYSTEM TEMP DIR, and neither is under the repo. + * + * An earlier revision put the fixture at `packages/cli/test/.tmp-i18n-14894`. + * Nothing tracked ignores `.tmp-*` — the repo's rules cover `tmp/` and `*.tmp` + * — so `dispatch-gates --self-test` failed its "every in-tree directory this + * tree's sources create is covered by a tracked ignore rule, or is tracked + * itself" case, naming this file. ⛔ The repair for that is NOT a bespoke + * ignore rule for one test: it is not creating the directory in the tree. + * + * ⚠️ Which is why the config below imports NOTHING. `bundle-require` writes its + * bundled module NEXT TO the config and Node then resolves the config's bare + * specifiers from THAT directory, so a `defineStack` import out here fails — + * measured: `Cannot find package '@objectstack/spec' imported from + * /tmp/…/stack.config.bundled_….mjs`. A plain object default export is what + * `os i18n extract` actually consumes (it calls `normalizeStackInput` on + * whatever the config exports), and the readings are identical either way. + * + * ⚠️ The cost, stated rather than hidden: `defineStack` used to validate this + * fixture at load, and a plain object is not validated the same way. Measured + * on the same tree — dropping `type` from the field is REFUSED under + * `defineStack` ("Invalid field type ''") and accepted silently without it. The + * assertions below are the remaining guard, and they are exact leaf counts, so + * a fixture that stopped describing this stack moves them; but a malformed + * fixture that happens to keep the counts would now pass. Worth knowing before + * anyone grows this fixture. */ -const FIXTURE_DIR = join(HERE, '.tmp-i18n-14894'); -const CONFIG = join(FIXTURE_DIR, 'stack.config.ts'); +let fixtureRoot: string; +let CONFIG: string; const CONFIG_SOURCE = [ - "import { defineStack } from '@objectstack/spec';", - '', - 'export default defineStack({', + 'export default {', " 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(() => { - mkdirSync(FIXTURE_DIR, { recursive: true }); + fixtureRoot = mkdtempSync(join(tmpdir(), 'os-i18n-14894-fixture-')); + CONFIG = join(fixtureRoot, 'stack.config.ts'); writeFileSync(CONFIG, CONFIG_SOURCE, 'utf8'); outRoot = mkdtempSync(join(tmpdir(), 'os-i18n-14894-')); }); afterAll(() => { - rmSync(FIXTURE_DIR, { recursive: true, force: true }); + rmSync(fixtureRoot, { recursive: true, force: true }); rmSync(outRoot, { recursive: true, force: true }); }); From af9fd95004c96b0018bc17a1f117d3314c5809b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 05:07:49 +0000 Subject: [PATCH 4/4] test(cli): restore `defineStack` in the e2e fixture, under this package's ignored `tmp/` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round moved the fixture root out of the tree and, to make that move work, dropped the fixture's `defineStack` import. That was not placement: it removed load-time validation, so the standing content PASS could not extend onto it. This restores the import and keeps the root out of the un-ignored location the gate objected to. The fixture goes under `packages/cli/tmp/`; only the `--out` root stays in the system temp dir, because only the fixture has to RESOLVE anything. `bundle-require` writes its bundled module next to the config and Node resolves the config's bare specifiers from THAT directory, so a system-tmpdir project cannot see `@objectstack/spec` (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`. `serve-no-artifact.e2e.test.ts` carries the same constraint and the same answer, in a comment that says so; four other suites here share the root. Both premises were checked rather than inherited: * `packages/cli/tmp` is covered by a TRACKED rule — `git check-ignore -v packages/cli/tmp/x` answers `.gitignore:55:tmp/`, and `.gitignore` is tracked. No new ignore rule is added; a bespoke rule for one test is the repair that was ruled out. * every sibling on that root removes only its own `mkdtemp` subdirectory and never the shared root, so concurrent suites coexist. `afterAll` here does the same, and says so. The alternative — a `node_modules` symlink into the system temp dir — was also driven and is safe on node v22.22.2 (`rmSync` recursive deletes the link and leaves the target intact, canary file included). It is not used: the repo already has a plainer answer to this exact constraint, with five users. Readings unchanged with the import restored: 3 stack leaves (`objects`, `apps`), 773 baseline leaves under `--metadata-forms`, none under `--no-metadata-forms`. And the validation is measurably back — dropping `type` from the field is refused again, exit 1, "defineStack validation failed (1 issue): objects.0.fields.name.type: Invalid field type ''", where the plain-object fixture accepted it at exit 0. Part of #14894 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- ...8n-extract-metadata-forms-flag.e2e.test.ts | 59 +++++++++++-------- 1 file changed, 33 insertions(+), 26 deletions(-) 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 index 51fc84b9d1..c9fdbb1c7c 100644 --- a/packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts +++ b/packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts @@ -47,7 +47,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync, readdirSync, readFileSync } from 'node:fs'; +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'; @@ -58,54 +58,61 @@ const CLI = resolve(HERE, '../bin/run-dev.js'); const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); /** - * Both roots live in the SYSTEM TEMP DIR, and neither is under the repo. + * 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. * - * An earlier revision put the fixture at `packages/cli/test/.tmp-i18n-14894`. - * Nothing tracked ignores `.tmp-*` — the repo's rules cover `tmp/` and `*.tmp` - * — so `dispatch-gates --self-test` failed its "every in-tree directory this - * tree's sources create is covered by a tracked ignore rule, or is tracked - * itself" case, naming this file. ⛔ The repair for that is NOT a bespoke - * ignore rule for one test: it is not creating the directory in the tree. + * 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. * - * ⚠️ Which is why the config below imports NOTHING. `bundle-require` writes its - * bundled module NEXT TO the config and Node then resolves the config's bare - * specifiers from THAT directory, so a `defineStack` import out here fails — - * measured: `Cannot find package '@objectstack/spec' imported from - * /tmp/…/stack.config.bundled_….mjs`. A plain object default export is what - * `os i18n extract` actually consumes (it calls `normalizeStackInput` on - * whatever the config exports), and the readings are identical either way. + * ⚠️ 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/`. * - * ⚠️ The cost, stated rather than hidden: `defineStack` used to validate this - * fixture at load, and a plain object is not validated the same way. Measured - * on the same tree — dropping `type` from the field is REFUSED under - * `defineStack` ("Invalid field type ''") and accepted silently without it. The - * assertions below are the remaining guard, and they are exact leaf counts, so - * a fixture that stopped describing this stack moves them; but a malformed - * fixture that happens to keep the counts would now pass. Worth knowing before - * anyone grows this fixture. + * ⚠️ `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 = [ - 'export default {', + "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(() => { - fixtureRoot = mkdtempSync(join(tmpdir(), 'os-i18n-14894-fixture-')); + 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 }); });