diff --git a/.changeset/25922-studio-i18n-table-retired.md b/.changeset/25922-studio-i18n-table-retired.md new file mode 100644 index 00000000000..38965f085a4 --- /dev/null +++ b/.changeset/25922-studio-i18n-table-retired.md @@ -0,0 +1,10 @@ +--- +'@object-ui/i18n': patch +--- + +docs(i18n): the `studio` namespace is the designer strings' only home + +Clause-②: no +Metadata-protocol: none + +- **Last slice of the move ruled on objectstack#25922** (6431032267, Q2 = A; kept by 6431234244). `@object-ui/app-shell`'s private designer table, empty since the previous slice, is deleted, and its `t()` / `tOptional()` read `@object-ui/i18n/namespaces/studio` alone. Every string reads the same as before in every language. The README and the namespace's doc comments now say so, and point at `src/__tests__/studio-namespace.test.ts` as the namespace's one parity check, since the paired-table gate `check:i18n-designer-parity` is retired with the table. No export or string changes. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1f78bc250d..bc037569db5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3539,9 +3539,6 @@ jobs: - name: Verify changed en strings were followed by the nine translations run: pnpm --dir ui run check:i18n-drift - - name: Verify the metadata-admin designer string tables stay paired - run: pnpm --dir ui run check:i18n-designer-parity - - name: Type-check the ui scripts run: pnpm --dir ui run type-check:scripts diff --git a/ui/package.json b/ui/package.json index 539b12d8589..62ede6f4c56 100644 --- a/ui/package.json +++ b/ui/package.json @@ -58,7 +58,6 @@ "check:i18n-keys": "node scripts/check-i18n-call-site-keys.mjs", "check:i18n-drift": "node scripts/check-i18n-en-drift.mjs", "check:i18n-dead-keys": "node scripts/check-i18n-dead-keys.mjs", - "check:i18n-designer-parity": "node scripts/check-i18n-designer-table-parity.mjs", "check:skills-paths": "node scripts/check-skills-paths.mjs", "check:skill-examples": "node scripts/check-skill-examples.mjs", "check:skill-eval-tokens": "node scripts/check-skill-eval-tokens.mjs", diff --git a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts index f7a8ace00dc..4a1b77bcbfd 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -44,24 +44,26 @@ * 6431234244) reopened the objectui#4662 carve-out: this table becomes the * `studio` namespace of `@object-ui/i18n` (`packages/i18n/src/namespaces/studio/`), * one lazy namespace carrying the existing `en` and `zh` text, every other - * language falling back to `en`. It moves in slices. Until the last one, a key - * lives either here or there, never both, and every lookup below asks this - * table first and the namespace second (`lookupString`), so no call site - * changes. `packages/i18n/README.md`, section "Scope — the Studio designer - * strings", is the conventions-side record; keep the two in step. + * language falling back to `en`. It moved in slices, and the last one (S7) + * deleted the engine-string table from this module: every designer string now + * answers from the namespace alone (`lookupString`), with no call site changed. + * `packages/i18n/README.md`, section "Scope — the Studio designer strings", is + * the conventions-side record; keep the two in step. * - * What has not changed for the keys still here: only `en` and `zh` exist, so - * the other eight shipped locales render English on these screens, and the - * pack gates do not read this module (`scripts/check-i18n-call-site-keys.mjs` - * skips it by declaration). `check:i18n-designer-parity` reads both homes. + * Only `en` and `zh` exist, so the other eight shipped locales render English + * on these screens, and the pack gates do not read the namespace + * (`scripts/check-i18n-call-site-keys.mjs` skips this module by declaration). + * The namespace's own parity is `packages/i18n/src/__tests__/studio-namespace.test.ts`; + * the type and domain label tables below keep theirs in + * `ui/scripts/__tests__/check-i18n-dead-keys.test.ts`. */ import { useObjectTranslation } from '@object-ui/i18n'; // The designer strings' new home. Its `en` catalogue is resident; its other // catalogues are fetched on demand, and `useMetadataLocale()` re-renders its // caller when one lands. ⚠️ `ui/scripts/check-i18n-dead-keys.mjs` reads the -// namespace's catalogues as part of this table BECAUSE this module imports it: -// drop the import and the gates stop reading them. +// namespace's catalogues as this module's engine strings BECAUSE this module +// imports it: drop the import and the gates stop reading them. import { studioString, useStudioCatalogue } from '@object-ui/i18n/namespaces/studio'; // Type-only (erased at build): the overlay-scope and lock-state label tables // below are keyed by their producer's own union rather than by hand — see @@ -191,16 +193,6 @@ const DOMAIN_LABELS_ZH: Record = { other: '其他', }; -const ENGINE_STRINGS_EN: Record = { - // Empty since #25922 slice S6b: every row lives in `@object-ui/i18n`'s - // `studio` namespace. Kept until slice S7 retires the table. -}; - -const ENGINE_STRINGS_ZH: Record = { - // Empty since #25922 slice S6b: every row lives in `@object-ui/i18n`'s - // `studio` namespace. Kept until slice S7 retires the table. -}; - /** Whether a locale resolves to Chinese (the only non-English catalog). */ export function isZhLocale(locale?: SupportedLocale | string): boolean { return (locale ?? '').toLowerCase().startsWith('zh'); @@ -515,27 +507,25 @@ export function flowFieldZh(type: string, id: string): FlowFieldZh | undefined { function pickTable( locale: SupportedLocale | string | undefined, -): { types: Record; domains: Record; strings: Record } { +): { types: Record; domains: Record } { const lower = (locale ?? '').toLowerCase(); if (lower.startsWith('zh')) { - return { types: TYPE_LABELS_ZH, domains: DOMAIN_LABELS_ZH, strings: ENGINE_STRINGS_ZH }; + return { types: TYPE_LABELS_ZH, domains: DOMAIN_LABELS_ZH }; } - return { types: TYPE_LABELS_EN, domains: DOMAIN_LABELS_EN, strings: ENGINE_STRINGS_EN }; + return { types: TYPE_LABELS_EN, domains: DOMAIN_LABELS_EN }; } /** - * The value of one designer string: this module's own table for the locale - * first, then the `studio` namespace in `@object-ui/i18n`, which the table is - * moving into slice by slice (objectstack#25922, ruling 6431032267 Q2 = A). - * A key lives in exactly one of the two homes — `check:i18n-designer-parity` - * fails on a key in both — so the order decides nothing but cost. + * The value of one designer string, from the `studio` namespace in + * `@object-ui/i18n` (objectstack#25922, ruling 6431032267 Q2 = A), which holds + * every row this module's own table used to. * * The namespace answers a language it has no catalogue for, and a catalogue - * still being fetched, from its `en` catalogue; this table keeps its own - * behaviour for the keys it still holds (a key its zh half lacks echoes back). + * still being fetched, from its `en` catalogue; a zh-only row (a flow node + * label, an enum value, a package form help text) is `undefined` outside zh. */ function lookupString(key: string, locale: SupportedLocale | string | undefined): string | undefined { - return pickTable(locale).strings[key] ?? studioString(key, locale); + return studioString(key, locale); } export function translateMetadataType( diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.studioNamespace-25922.test.ts b/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.studioNamespace-25922.test.ts index b8dc6c58bfa..9624116b9c7 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.studioNamespace-25922.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.studioNamespace-25922.test.ts @@ -1,19 +1,20 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * objectstack#25922 slices S2 to S6b — the designer rows that moved into + * objectstack#25922 slices S2 to S7 — the designer rows that moved into * `@object-ui/i18n`'s `studio` namespace (every `engine.inspector.*` row; * `engine.packages.*`, `engine.form.*`, `engine.edit.*`; all of * `engine.studio.*`; the data source pages, the field types, the metadata * preview panes, the flow designer, the rest of the Studio chrome and, last, * the translation preview), and * app-shell's `t()` / `tOptional()` still answer every one of them the way they - * did when the rows sat in its own table. + * did when the rows sat in its own table. Slice S7 deleted that table and the + * table-first lookup, so the namespace is the only home. * * What would break it: - * - a row copied back into the app-shell table with a different value — the - * table is asked first, so it would shadow the namespace silently; - * - the namespace fallback dropped from `lookupString` — every moved key + * - a lookup asked in front of the namespace again (a table brought back + * with a row whose value differs) — it would shadow the namespace silently; + * - the namespace dropped from `lookupString` — every moved key * would echo back as itself; * - a family emptied out of the namespace, which every per-key assertion * below would then pass over nothing. The floors are per FAMILY, by name, @@ -23,6 +24,9 @@ * `loadStudioCatalogue` (the module's public door), not a copy of their text. */ import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio'; import { t, tOptional } from '../i18n'; @@ -134,8 +138,8 @@ const FAMILY_FLOOR: Readonly> = Object.freeze({ * carry. The package form's help text reads its English from the producer's own * `.describe()` sentence; the flow node labels and hints and the flow enum * values read theirs from the engine descriptor or the value itself. Each is - * read through `tOptional` or a `lookupString` fallback (`ZH_ONLY_FAMILIES` in - * `ui/scripts/check-i18n-designer-table-parity.mjs` carries the reasons). + * read through `tOptional` or a `lookupString` fallback; `ZH_ONLY_REASONS` + * below carries the reasons. */ const ZH_ONLY_FLOOR: Readonly> = Object.freeze({ 'engine.packages.form.help.': 9, @@ -143,6 +147,40 @@ const ZH_ONLY_FLOOR: Readonly> = Object.freeze({ 'engine.enum.': 4, }); +/** + * Why each zh-only family has no `en` row, and the reader that says so. Moved + * here from the retired `check:i18n-designer-parity` gate with slice S7. A + * citation is `{ file, anchor }`, the anchor is TEXT and never a line number, + * and it must occur exactly once in its file or it stops locating anything. + */ +const ZH_ONLY_REASONS = Object.freeze([ + { + prefix: 'engine.flowNode.', + reason: + 'zh-only by design: for English the flow palette falls back to the engine descriptor’s own ' + + 'server-authoritative name/description, so an `en` row would OVERRIDE the server', + file: '../i18n.ts', + anchor: 'resolved by translateNodeLabel/Hint', + }, + { + prefix: 'engine.enum.type.', + reason: + 'zh-only by construction: translateEnumOption returns the raw value before it ever looks the ' + + 'key up for any non-zh locale, so an `en` row is unreachable', + file: '../i18n.ts', + anchor: 'export function translateEnumOption', + }, + { + prefix: 'engine.packages.form.help.', + reason: + 'zh-only on purpose: with no `en` row tOptional returns undefined, helpText drops out, and the row ' + + 'falls back to ManifestSchema’s .describe() in @objectstack/spec — an `en` row would copy that ' + + 'text into a second producer, against AGENTS.md #0.1', + file: '../package-schema.ts', + anchor: 'en-US deliberately has NO entry', + }, +]); + const familyKeys = (prefix: string): string[] => Object.keys(EN).filter((key) => key.startsWith(prefix)); describe('moved designer rows answer from the studio namespace (objectstack#25922)', () => { @@ -173,3 +211,45 @@ describe('moved designer rows answer from the studio namespace (objectstack#2592 expect(keys.filter((key) => tOptional(key, 'en-US') !== undefined)).toEqual([]); }); }); + +describe('the namespace is the only home (objectstack#25922 slice S7)', () => { + const here = path.dirname(fileURLToPath(import.meta.url)); + const zhOnly = Object.keys(ZH).filter((key) => !(key in EN)); + + it('every namespace key answers through t() and tOptional() with its catalogue value', () => { + // Every key, not a family sample: with the table gone nothing else may + // answer, so a lookup in front of the namespace shows up here by key. + expect(Object.keys(EN).length).toBeGreaterThan(3000); + const wrong = Object.keys(EN).flatMap((key) => { + const out: string[] = []; + const zh = ZH[key] ?? EN[key]; + for (const locale of ['en-US', 'en', 'de-DE', undefined]) { + if (t(key, locale) !== EN[key]) out.push(`t ${locale} ${key}`); + if (tOptional(key, locale) !== EN[key]) out.push(`tOptional ${locale} ${key}`); + } + for (const locale of ['zh-CN', 'zh', 'zh-TW']) { + if (t(key, locale) !== zh) out.push(`t ${locale} ${key}`); + if (tOptional(key, locale) !== zh) out.push(`tOptional ${locale} ${key}`); + } + return out; + }); + expect(wrong).toEqual([]); + }); + + it('the zh-only rows are the documented families, and answer in zh alone', () => { + // 54 at the move (9 package form help rows, 41 flow node rows, 4 flow enum + // values); all of them belong to a family with a reason below. + expect(zhOnly.length).toBeGreaterThanOrEqual(50); + expect(zhOnly.filter((key) => !ZH_ONLY_REASONS.some((f) => key.startsWith(f.prefix)))).toEqual([]); + const wrong = zhOnly.flatMap((key) => [ + ...['en-US', 'en', 'de-DE', undefined].filter((locale) => tOptional(key, locale) !== undefined).map((l) => `en-side ${l} ${key}`), + ...['zh-CN', 'zh', 'zh-TW'].filter((locale) => tOptional(key, locale) !== ZH[key]).map((l) => `zh-side ${l} ${key}`), + ]); + expect(wrong).toEqual([]); + }); + + it.each(ZH_ONLY_REASONS.map((f) => [f.prefix, f] as const))('%s: its citation anchor occurs exactly once', (_prefix, family) => { + const text = readFileSync(path.join(here, family.file), 'utf8'); + expect(text.split(family.anchor).length - 1).toBe(1); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts index 20ede01bd80..677aeb798cf 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts @@ -508,9 +508,9 @@ describe('placeholders that must NOT be translated (#3979)', () => { * own chrome key list rather than hand-copying it. * * Since objectstack#25922 slice S2 the prefix lives in the `studio` namespace - * of `@object-ui/i18n` (`packages/i18n/src/namespaces/studio/{en,zh}.ts`), so - * a locale's key set is read from BOTH homes — the app-shell table and the - * namespace catalogue — and a key is retired only when it has left both. + * of `@object-ui/i18n` (`packages/i18n/src/namespaces/studio/{en,zh}.ts`), and + * since slice S7 the app-shell table is gone, so a locale's key set is read + * from its namespace catalogue alone. * * Measured on this checkout: every one of the 171 `engine.inspector.pageBlock.*` * keys in each table is covered by one of those two, and no key is constructed @@ -565,13 +565,10 @@ interface EngineStringTable { name: string; /** Every key the table declares, in file order. */ keys: string[]; - /** 1-based line range of the object literal, for the disjointness guard. */ - from: number; - to: number; } /** - * The KEYS of one `ENGINE_STRINGS_*` table, read out of the source by LINE + * The KEYS of one `STUDIO_*` catalogue, read out of the source by LINE * RANGE rather than by scanning the whole file. * * Table membership is the whole question here: a key that MOVED from one locale @@ -592,7 +589,7 @@ function engineStringTable(source: string, name: string): EngineStringTable { const m = /^ {2}'((?:[^'\\]|\\.)*)':/.exec(lines[i]); if (m) keys.push(m[1]); } - return { name, keys, from: open + 1, to: close + 1 }; + return { name, keys }; } /** @@ -637,17 +634,8 @@ function shippedLiteralKeys(): Map { } const I18N_SOURCE = readFileSync(I18N_SOURCE_PATH, 'utf8'); -const EN_TABLE = engineStringTable(I18N_SOURCE, 'ENGINE_STRINGS_EN'); -const ZH_TABLE = engineStringTable(I18N_SOURCE, 'ENGINE_STRINGS_ZH'); -const EN_HOME = engineStringTable(readFileSync(STUDIO_HOME_PATH.en, 'utf8'), 'STUDIO_EN'); -const ZH_HOME = engineStringTable(readFileSync(STUDIO_HOME_PATH.zh, 'utf8'), 'STUDIO_ZH'); - -/** One locale's keys across both homes: the app-shell table and the namespace. */ -function bothHomes(table: EngineStringTable, home: EngineStringTable): EngineStringTable { - return { name: `${table.name} + ${home.name}`, keys: [...table.keys, ...home.keys], from: table.from, to: table.to }; -} -const EN_KEYS = bothHomes(EN_TABLE, EN_HOME); -const ZH_KEYS = bothHomes(ZH_TABLE, ZH_HOME); +const EN_KEYS = engineStringTable(readFileSync(STUDIO_HOME_PATH.en, 'utf8'), 'STUDIO_EN'); +const ZH_KEYS = engineStringTable(readFileSync(STUDIO_HOME_PATH.zh, 'utf8'), 'STUDIO_ZH'); /** The keys `SITES` implies — the complete BLOCK_CONFIG-derived vocabulary. */ const DERIVED = new Set(SITES.map((s) => s.derived)); @@ -673,26 +661,15 @@ function pageBlockKeys(table: EngineStringTable): string[] { } describe('a retired key leaves BOTH locale tables (objectui#8368)', () => { - it('reads two disjoint tables out of i18n.ts, both emptied into the namespace', () => { + it('reads both locale catalogues out of the studio namespace, the app-shell table retired', () => { // The extractor is a source parser, so its silent failure mode is finding // FEWER keys — which would make every assertion below vacuously green over - // an empty corpus. Ranges first: overlapping ones would mean the `};` - // scan ran past a table and both sets became the same set. - // The table drained slice by slice into the namespace (objectstack#25922) - // and is empty since slice S6b: a key back in it would shadow the - // namespace's copy. The 3,000-key floor, held over both homes together - // below, is what keeps an empty table from reading as a parser gone quiet. - expect(EN_TABLE.to).toBeLessThan(ZH_TABLE.from); - expect(EN_TABLE.keys).toEqual([]); - expect(ZH_TABLE.keys).toEqual([]); + // an empty corpus. The table drained slice by slice into the namespace + // (objectstack#25922) and slice S7 deleted it: i18n.ts declaring one again + // would be a second home this census cannot see. + expect(I18N_SOURCE).not.toMatch(/\bconst ENGINE_STRINGS_/); // No duplicates: a key declared twice would be a silent overwrite, and the // set arithmetic below would hide it. - expect(EN_TABLE.keys.length).toBe(new Set(EN_TABLE.keys).size); - expect(ZH_TABLE.keys.length).toBe(new Set(ZH_TABLE.keys).size); - // The namespace homes are read the same way, and a key in both homes of - // one locale would be counted twice below. - expect(EN_HOME.keys.length).toBeGreaterThan(150); - expect(ZH_HOME.keys.length).toBeGreaterThan(150); expect(EN_KEYS.keys.length).toBe(new Set(EN_KEYS.keys).size); expect(ZH_KEYS.keys.length).toBe(new Set(ZH_KEYS.keys).size); expect(EN_KEYS.keys.length).toBeGreaterThan(3000); diff --git a/ui/packages/app-shell/src/views/metadata-admin/zh-base-term.i18n-13131.test.ts b/ui/packages/app-shell/src/views/metadata-admin/zh-base-term.i18n-13131.test.ts index 6e3050d4187..39156e396ea 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/zh-base-term.i18n-13131.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/zh-base-term.i18n-13131.test.ts @@ -6,44 +6,25 @@ * renders that sense as 基座 (`engine.package.writableRequired`, * `engine.packages.detail.duplicated` / `adopted`), so the fixed rows reuse it. * - * The census walks every row of `ENGINE_STRINGS_ZH` as read from source (with - * comments masked by the shared helper), so a row added later is asked the same - * question. Regex: the whole Latin word `base`, case-insensitive, matched in the - * row VALUE only — keys, identifiers such as `baseObject` (no word boundary) - * and comments are out of scope. - * - * The rows that moved into `@object-ui/i18n`'s `studio` namespace - * (objectstack#25922) are walked too, from the namespace's own `zh` catalogue, - * so the census still covers the whole designer table wherever a row lives. + * The census walks every row of the designer strings' `zh` catalogue, so a row + * added later is asked the same question. Those rows live in `@object-ui/i18n`'s + * `studio` namespace since objectstack#25922 retired the app-shell table + * (slice S7), and the census reads the loaded catalogue itself. Regex: the + * whole Latin word `base`, case-insensitive, matched in the row VALUE only — + * keys and identifiers such as `baseObject` (no word boundary) are out of scope. */ import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -// @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) -import { maskComments } from '../../../../../scripts/js-comment-mask.mjs'; import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio'; import { t } from './i18n'; -const mask: (source: string) => string = maskComments; -const here = path.dirname(fileURLToPath(import.meta.url)); +/** The namespace's `zh` rows (objectstack#25922), resident before any case runs. */ +const studioZh = await loadStudioCatalogue('zh'); function zhRows(): Array<[string, string]> { - const source = mask(readFileSync(path.join(here, 'i18n.ts'), 'utf8')); - const start = source.indexOf('const ENGINE_STRINGS_ZH'); - expect(start).toBeGreaterThan(-1); - const end = source.indexOf('\n};', start); - const block = source.slice(start, end); - const rows: Array<[string, string]> = []; - const row = /'((?:[^'\\\n]|\\.)*)'\s*:\s*'((?:[^'\\\n]|\\.)*)'/g; - for (const m of block.matchAll(row)) rows.push([m[1], m[2]]); - return [...rows, ...Object.entries(studioZh)]; + return Object.entries(studioZh); } -/** The namespace's `zh` rows (objectstack#25922), resident before any case runs. */ -const studioZh = await loadStudioCatalogue('zh'); - const BARE_BASE = /\bbase\b/i; describe('zh "base" (writable package) is translated — objectui#13131', () => { @@ -53,9 +34,9 @@ describe('zh "base" (writable package) is translated — objectui#13131', () => expect(msg).toContain('基座'); }); - it('census: no ENGINE_STRINGS_ZH value carries the bare word "base"', () => { + it('census: no studio zh value carries the bare word "base"', () => { const rows = zhRows(); - // Positive control: the walk reaches the whole table, including the fixed row. + // Positive control: the walk reaches the whole catalogue, including the fixed row. expect(rows.length).toBeGreaterThan(3000); expect(rows.map(([k]) => k)).toContain('engine.studio.pkg.new'); const offenders = rows.filter(([, v]) => BARE_BASE.test(v)).map(([k]) => k); diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.environmentOverlayCopy-12171.test.ts b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.environmentOverlayCopy-12171.test.ts index bb367ea1bdd..a7d426b7aed 100644 --- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.environmentOverlayCopy-12171.test.ts +++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.environmentOverlayCopy-12171.test.ts @@ -14,8 +14,8 @@ * * ## The population * - * Every string table the designer module declares, read through the extractor - * `check:i18n-designer-parity` reads with (`readDesignerPairs`), then narrowed + * Every designer string table, read through their shared extractor + * (`readDesignerPairs` in `ui/scripts/check-i18n-dead-keys.mjs`), then narrowed * to the tables that carry these keys. No list of locales is kept here: a table * added for another language joins the population by itself. * @@ -35,7 +35,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; // @ts-expect-error — plain-JS gate script, intentionally untyped (`allowJs: false`) -import { readDesignerPairs } from '../../../../../scripts/check-i18n-designer-table-parity.mjs'; +import { readDesignerPairs } from '../../../../../scripts/check-i18n-dead-keys.mjs'; import { t, type SupportedLocale } from '../metadata-admin/i18n'; // The designer's `designer.*` / `perm.*` rows live in `@object-ui/i18n`'s `studio` // namespace, whose `zh` catalogue is fetched on demand (objectstack#25922). diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.railLabels.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.railLabels.test.tsx index 0f4463f3954..8c697436f1e 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.railLabels.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.railLabels.test.tsx @@ -43,7 +43,7 @@ import { SidebarProvider, badgeVariants } from '@object-ui/components'; import { NavigationRenderer } from '@object-ui/layout'; import type { NavigationItem } from '@object-ui/types'; // @ts-expect-error — plain-JS gate script, intentionally untyped (`allowJs: false`) -import { readDesignerPairs } from '../../../../../scripts/check-i18n-designer-table-parity.mjs'; +import { readDesignerPairs } from '../../../../../scripts/check-i18n-dead-keys.mjs'; /** The metadata client the open block installed; the mocks below read it. */ const studio = vi.hoisted(() => ({ client: undefined as unknown })); @@ -1304,8 +1304,8 @@ describe('StudioDesignSurface.railServedLabels-12688', () => { * ## The three pins * * 1. No zh value in the designer string tables contains 支柱. Read through - * the extractor `check:i18n-designer-parity` reads with - * (`readDesignerPairs`), so the population is the whole table rather than + * the designer tables' shared extractor (`readDesignerPairs` in + * `ui/scripts/check-i18n-dead-keys.mjs`), so the population is the whole table rather than * a list of keys this block would have to keep. Its lit control: the * extractor's zh value for `engine.studio.pillar.data` must be the one * `t()` serves, so an extractor that read nothing cannot pass for "no 支柱". diff --git a/ui/packages/i18n/README.md b/ui/packages/i18n/README.md index 6a340cd4d65..a8ba291fa1e 100644 --- a/ui/packages/i18n/README.md +++ b/ui/packages/i18n/README.md @@ -409,17 +409,17 @@ package's **`studio` namespace**, packs and not members of it, so the namespace is outside that test's subject rather than an exception to it. Its own parity — every `en` key has a `zh` row with the same `{placeholders}` — is held by - [`src/__tests__/studio-namespace.test.ts`](./src/__tests__/studio-namespace.test.ts) - and, while the move is in progress, by `pnpm check:i18n-designer-parity` - over both homes together. - -The move runs in slices. Until the last one a key lives in exactly one home — -the app-shell table or the namespace — and app-shell's lookup asks the table -first and the namespace second, so no call site changes. The call-site gate + [`src/__tests__/studio-namespace.test.ts`](./src/__tests__/studio-namespace.test.ts). + +The move ran in slices, and while it ran a key lived in exactly one home — the +app-shell table or the namespace — with app-shell's lookup asking the table +first and the namespace second, so no call site changed. The last slice +(objectstack#25922 S7) deleted the app-shell table and the table-first lookup, +and retired `check:i18n-designer-parity`, which paired the two homes. App-shell's +`t()` / `tOptional()` now resolve from the namespace alone. The call-site gate ([`scripts/check-i18n-call-site-keys.mjs`](../../scripts/check-i18n-call-site-keys.mjs)) still skips app-shell's translator **by declaration** (its `EXCLUDED_TRANSLATORS` -entry), so a second local table cannot appear silently. The last slice deletes -the app-shell table and retires `check:i18n-designer-parity` with it. +entry), so a second local table cannot appear silently. ## Links diff --git a/ui/packages/i18n/src/__tests__/studio-namespace.test.ts b/ui/packages/i18n/src/__tests__/studio-namespace.test.ts index e203e7e2511..2205cb91879 100644 --- a/ui/packages/i18n/src/__tests__/studio-namespace.test.ts +++ b/ui/packages/i18n/src/__tests__/studio-namespace.test.ts @@ -8,7 +8,8 @@ * own section of the parity suite: `all-locales-key-parity.test.ts` does * not read it — that suite iterates `builtInLocales`, the ten packs, and * these catalogues are not packs — and `check:i18n-designer-parity` - * retires with the app-shell table. + * retired with the app-shell table (objectstack#25922 slice S7), so this + * section is the only parity check the namespace has. * 2. NOT IN THE EAGER CATALOGUE. No namespace key is defined by * `locales/en.ts`, the console's one eager catalogue, so moving a key here * adds nothing to the first load's translation bytes. @@ -35,9 +36,9 @@ const PLACEHOLDER = /\{(\w+)\}/g; const placeholders = (value: string) => [...new Set(Array.from(value.matchAll(PLACEHOLDER), (m) => m[1]))].sort(); /** - * The `zh`-only families the designer table documents, each one-sided on - * purpose — `ZH_ONLY_FAMILIES` in `ui/scripts/check-i18n-designer-table-parity.mjs` - * carries the reason and the consumer for each. + * The `zh`-only families, each one-sided on purpose. `ZH_ONLY_REASONS` in + * app-shell's `inspectorStrings.studioNamespace-25922.test.ts` carries the + * reason and the consumer for each, and checks that its citation still locates it. */ const ZH_ONLY_FAMILIES = ['engine.flowNode.', 'engine.enum.type.', 'engine.packages.form.help.']; diff --git a/ui/packages/i18n/src/namespaces/studio/index.ts b/ui/packages/i18n/src/namespaces/studio/index.ts index 9ea24e2084b..ca0f4decef7 100644 --- a/ui/packages/i18n/src/namespaces/studio/index.ts +++ b/ui/packages/i18n/src/namespaces/studio/index.ts @@ -42,12 +42,13 @@ * ## Which gates read it * * `ui/scripts/check-i18n-dead-keys.mjs` (`collectDesignerKeys`) reads `./en.ts` - * and `./zh.ts` as part of the designer table, by AST, whenever app-shell's - * table module imports this namespace — the same edge the runtime follows. So - * `check:i18n-designer-parity` (en ⊆ zh, placeholder parity, a key in exactly - * one home), the designer population of `check:i18n-drift` and the dead-keys - * report all see the moved keys. `__tests__/studio-namespace.test.ts` holds the - * namespace's own parity, which outlives the designer table. + * and `./zh.ts` as app-shell's engine strings, by AST, whenever app-shell's + * string module imports this namespace — the same edge the runtime follows. So + * the designer population of `check:i18n-drift` and the dead-keys report see + * every key. `__tests__/studio-namespace.test.ts` holds the namespace's own + * parity (en ⊆ zh, placeholder parity, documented zh-only families), the job + * `check:i18n-designer-parity` shared until objectstack#25922 slice S7 retired + * the app-shell table and that gate with it. */ import { useSyncExternalStore } from 'react'; import { matchLanguageTag } from '../../locales/registry.js'; diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 9aaa1b909e9..4ad91aaa3e6 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -5,7 +5,7 @@ * * `./index.ts` imports this module only through its lazy loader, so a viewer * whose language is not Chinese never downloads it. A key this catalogue does - * not define renders the `en` row (`./en.ts`); `pnpm check:i18n-designer-parity` + * not define renders the `en` row (`./en.ts`); `../../__tests__/studio-namespace.test.ts` * holds every `en` key to a row here, with the same `{placeholders}`. * * ⛔ Keep the declaration `const STUDIO_ZH: Record = { … }` diff --git a/ui/scripts/__tests__/check-i18n-dead-keys.test.ts b/ui/scripts/__tests__/check-i18n-dead-keys.test.ts index d932011fe67..dabd3ba41bc 100644 --- a/ui/scripts/__tests__/check-i18n-dead-keys.test.ts +++ b/ui/scripts/__tests__/check-i18n-dead-keys.test.ts @@ -9,6 +9,7 @@ import { DESIGNER_NAMESPACE_HOMES, DESIGNER_NAMESPACE_SPECIFIER, DESIGNER_TABLE, + DESIGNER_TABLE_PAIRS, collectDesignerKeys, collectIndirectTemplateHeads, derivePackObjectImporters, @@ -17,6 +18,7 @@ import { propertyChainProbe, sweep, sweepDesignerTable, + readDesignerPairs, textFootprint, } from '../check-i18n-dead-keys.mjs'; import { analyze, collectEnKeys, readVocabulary } from '../check-i18n-call-site-keys.mjs'; @@ -712,8 +714,8 @@ describe('collectDesignerKeys()', () => { /** * The optional second argument (objectui#8834). Every option defaults to what * the dead-keys sweep already did, which is what lets this file stay the ONE - * reader of these tables — `check-i18n-designer-table-parity.mjs` and the - * designer population of `check-i18n-en-drift.mjs` both call in here rather + * reader of these tables — `readDesignerPairs` and the designer population + * of `check-i18n-en-drift.mjs` both call in here rather * than growing a second copy of the parse. `changeset-guard.yml:63-72` records * what the second copy of a reader costs in this repo. */ @@ -822,6 +824,21 @@ describe('collectDesignerKeys() — the namespace homes', () => { expect(tables.get('ENGINE_STRINGS_EN')!.has('designer.fx.moved')).toBe(false); }); + it('a table the module no longer declares is its namespace home alone (objectstack#25922 slice S7)', () => { + const TABLELESS = `import { studioString } from '${DESIGNER_NAMESPACE_SPECIFIER}';\nexport const kept = 1;\n`; + const { tables, values } = collectDesignerKeys(designerRoot({ [DESIGNER_TABLE]: TABLELESS, ...MOVED }), { + withValues: true, + }); + expect([...tables.get('ENGINE_STRINGS_EN')!]).toEqual(['designer.fx.moved']); + expect([...tables.get('ENGINE_STRINGS_ZH')!]).toEqual(['designer.fx.moved']); + expect(values!.get('ENGINE_STRINGS_EN')!.get('designer.fx.moved')).toBe('Moved'); + }); + + it('a module that neither declares the table nor imports the namespace still throws', () => { + const root = designerRoot({ [DESIGNER_TABLE]: 'export const kept = 1;\n', ...MOVED }); + expect(() => collectDesignerKeys(root)).toThrow(/ENGINE_STRINGS_EN/); + }); + it('throws when the table imports the namespace and a home cannot be read', () => { expect(() => collectDesignerKeys(designerRoot({ [DESIGNER_TABLE]: IMPORTING_TABLE }))).toThrow(/cannot be read/); }); @@ -2043,3 +2060,47 @@ export function Widget({ id }: { id: string }) { }); }); }); + +/** + * objectstack#25922 slice S7 — the pairing invariant the retired + * `check-i18n-designer-table-parity.mjs` enforced, held on the REAL tree for + * every designer pair (`DESIGNER_TABLE_PAIRS`): the engine strings, which now + * live only in the `studio` namespace, and the type and domain label tables, + * which stay in the app-shell module. Every `en` key has a `zh` row, and every + * shared row carries the same `{placeholders}`, read with `tFormat()`'s own + * pattern. The reverse direction (zh-only rows) is not a gap and is not judged: + * the namespace's documented zh-only families are + * `packages/i18n/src/__tests__/studio-namespace.test.ts`'s. + * + * Unlike the report-only sweep above, this is an enforcing pin by design, and it + * names no key and pins no count: its floors sit far under the real sizes, so + * they move for no ordinary string change, and they exist only so a reader that + * found nothing cannot pass. + */ +describe('the real designer pairs stay paired', () => { + const UI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const PLACEHOLDER = /\{(\w+)\}/g; + const placeholders = (value: string) => [...new Set(Array.from(value.matchAll(PLACEHOLDER), (m) => m[1]))].sort(); + const FLOOR: Record = { ENGINE_STRINGS: 1500, TYPE_LABELS: 20, DOMAIN_LABELS: 5 }; + const values = readDesignerPairs(UI_ROOT) as Map>; + + it('reads every pair in the list', () => { + expect(DESIGNER_TABLE_PAIRS.map((pair: { label: string }) => pair.label).sort()).toEqual(Object.keys(FLOOR).sort()); + }); + + it.each(DESIGNER_TABLE_PAIRS.map((pair: { label: string; en: string; zh: string }) => [pair.label, pair]))( + '%s: every en key has a zh row with the same {placeholders}', + (label, pair) => { + const en = values.get(pair.en)!; + const zh = values.get(pair.zh)!; + expect(en.size).toBeGreaterThanOrEqual(FLOOR[label]); + expect([...en.keys()].filter((key) => !zh.has(key))).toEqual([]); + expect( + [...en.entries()] + .filter(([key]) => zh.has(key)) + .filter(([key, value]) => placeholders(value).join(' ') !== placeholders(zh.get(key)!).join(' ')) + .map(([key]) => key), + ).toEqual([]); + }, + ); +}); diff --git a/ui/scripts/__tests__/check-i18n-designer-table-parity.test.ts b/ui/scripts/__tests__/check-i18n-designer-table-parity.test.ts deleted file mode 100644 index 1b34d63cca4..00000000000 --- a/ui/scripts/__tests__/check-i18n-designer-table-parity.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { afterAll, describe, expect, it } from 'vitest'; -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { - analysePairs, - DESIGNER_PAIR_CONSTS, - DESIGNER_TABLE, - placeholdersOf, - readDesignerPairs, - ZH_ONLY_FAMILIES, -} from '../check-i18n-designer-table-parity.mjs'; -import { globMatches, jobBlock, repoPath, rootCi, triggerBlock, uiFilterGlobs, uiRun, UI_JOBS, withoutComments } from './helpers/root-ci'; -import { DESIGNER_TABLE_CONSTS } from '../check-i18n-dead-keys.mjs'; - -/** - * objectui#8834 — the behaviour test for - * `scripts/check-i18n-designer-table-parity.mjs`. - * - * The gate answers a question no other i18n guard in this repo can: the - * metadata-admin designer's own module-local tables must stay paired. The two - * shipped i18n gates are blind to that file BY CONSTRUCTION and the dead-keys - * sweep, which does read it, asks about DEAD keys over the UNION of both halves - * — so a one-sided key is not a subject to it at all. - * - * ## What is pinned here, and what deliberately is not - * - * The ENFORCED legs are pinned twice: as pure functions over fixture tables, - * and end-to-end through the real CLI over a throwaway repo, so the exit code - * itself is observed and not inferred. A gate that has never been observed red - * is indistinguishable from one that cannot go red, which is the disease this - * card exists to cure. - * - * There is NO pin here on a real-repo key COUNT. Counts move whenever anyone - * adds a string, and a pin on one would red an unrelated PR — the same - * reasoning `check-i18n-dead-keys.test.ts` states for its own corpus. What IS - * asserted about the real tree is the VERDICT (this gate is green today) and - * the shape of the instrument, both of which are stable under routine authoring. - */ - -const tempRoots: string[] = []; -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const GATE = path.join(repoRoot, 'scripts/check-i18n-designer-table-parity.mjs'); - -afterAll(() => { - for (const root of tempRoots) fs.rmSync(root, { recursive: true, force: true }); -}); - -/** Materialises a throwaway repo holding just the designer table. */ -function repoWithTable(contents: string): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'check-i18n-designer-parity-')); - tempRoots.push(root); - const full = path.join(root, DESIGNER_TABLE); - fs.mkdirSync(path.dirname(full), { recursive: true }); - fs.writeFileSync(full, contents); - return root; -} - -/** `execFileSync` throws on a non-zero exit, and the exit code is the whole - * point here, so the throw is caught and unpacked rather than avoided. */ -function runGateExpectingAnyExit(root: string, minKeys = 1): { status: number; output: string } { - // `--min-keys` exactly once: the CLI's `argOf` takes the FIRST occurrence, so - // a second one is silently ignored — which is how the collapse-guard case - // below first passed while measuring nothing. - try { - const out = execFileSync(process.execPath, [GATE, '--root', root, '--min-keys', String(minKeys)], { - encoding: 'utf8', - cwd: repoRoot, - stdio: ['ignore', 'pipe', 'pipe'], - }); - return { status: 0, output: out }; - } catch (error) { - const failure = error as { status?: number; stdout?: string; stderr?: string }; - return { status: failure.status ?? -1, output: `${failure.stdout ?? ''}${failure.stderr ?? ''}` }; - } -} - -/** A fixture table carrying one row of every shape the legs must judge. - * `engine.fx.*` is used so no assertion here spells a key the real table - * declares. */ -const FIXTURE = ` -const ENGINE_STRINGS_EN: Record = { - 'engine.fx.shared': 'Shared', - 'engine.fx.withPlaceholder': 'Found {count} of {total}', -}; - -const ENGINE_STRINGS_ZH: Record = { - 'engine.fx.shared': '共享', - 'engine.fx.withPlaceholder': '共 {count} 项,合计 {total}', - 'engine.flowNode.probe.label': '族内单边键', - 'engine.fx.residue': '族外单边键', -}; - -const TYPE_LABELS_EN: Record = { object: 'Object' }; -const TYPE_LABELS_ZH: Record = { object: '对象' }; -const DOMAIN_LABELS_EN: Record = { data: 'Data' }; -const DOMAIN_LABELS_ZH: Record = { data: '数据' }; -`; - -const tablesOf = (contents: string) => readDesignerPairs(repoWithTable(contents)); - -describe('the enforced half — en is a subset of zh', () => { - it('is green when every en row has a zh row, one-sided zh rows notwithstanding', () => { - const result = analysePairs(tablesOf(FIXTURE)); - expect(result.missingZh).toEqual([]); - expect(result.placeholders).toEqual([]); - }); - - it('names an en key with no zh row, and says which table it is in', () => { - const result = analysePairs( - tablesOf(FIXTURE.replace(" 'engine.fx.shared': 'Shared',", " 'engine.fx.shared': 'Shared',\n 'engine.fx.enOnly': 'No zh row',")), - ); - expect(result.missingZh).toEqual([{ pair: 'ENGINE_STRINGS', key: 'engine.fx.enOnly' }]); - }); - - it('judges every pair in the list, not just the first', () => { - // The pair list is the gate's shape; a bug that read only pairs[0] would - // still pass every assertion above. - const result = analysePairs(tablesOf(FIXTURE.replace("const TYPE_LABELS_ZH: Record = { object: '对象' };", 'const TYPE_LABELS_ZH: Record = {};'))); - expect(result.missingZh).toEqual([{ pair: 'TYPE_LABELS', key: 'object' }]); - }); -}); - -describe('the enforced half — placeholder parity over shared keys', () => { - it('names the key AND the placeholder the zh value dropped', () => { - const result = analysePairs( - tablesOf(FIXTURE.replace("'共 {count} 项,合计 {total}'", "'共若干项,合计 {total}'")), - ); - expect(result.placeholders).toHaveLength(1); - expect(result.placeholders[0]).toMatchObject({ - pair: 'ENGINE_STRINGS', - key: 'engine.fx.withPlaceholder', - missing: ['count'], - extra: [], - }); - }); - - it('names a placeholder that only the zh value carries', () => { - const result = analysePairs(tablesOf(FIXTURE.replace("'Found {count} of {total}'", "'Found {count}'"))); - expect(result.placeholders[0]).toMatchObject({ missing: [], extra: ['total'] }); - }); - - it('does not care about order, repetition, or the surrounding sentence', () => { - // The values SHOULD differ — one side is English. Only the token SET is the - // subject, and a translation is free to reorder or repeat the tokens. - const result = analysePairs( - tablesOf(FIXTURE.replace("'共 {count} 项,合计 {total}'", "'{total} 中的 {count} 项({count} 已选)'")), - ); - expect(result.placeholders).toEqual([]); - }); - - it("reads placeholders with tFormat()'s own regex", () => { - // `\\w+` only: `{a-b}` and `{ count }` are not substituted at runtime, so - // reporting them would be reporting something the runtime never does. - expect(placeholdersOf('a {count} b {total} c {count}')).toEqual(['count', 'total']); - expect(placeholdersOf('{a-b} { count } {}')).toEqual([]); - }); -}); - -describe('the reported half — zh-only keys, minus the documented families', () => { - it('subtracts a zh-only key that falls inside a documented family', () => { - const result = analysePairs(tablesOf(FIXTURE)); - expect(result.familyCounts['engine.flowNode.']).toBe(1); - expect(result.unexplainedZhOnly.map((entry) => entry.key)).not.toContain('engine.flowNode.probe.label'); - }); - - it('lists a zh-only key that falls outside every family', () => { - const result = analysePairs(tablesOf(FIXTURE)); - expect(result.unexplainedZhOnly).toEqual([{ pair: 'ENGINE_STRINGS', key: 'engine.fx.residue' }]); - }); - - it('never lets the report change the verdict — both directions', () => { - // The whole reason the families may be PREFIXES. A prefix in a report costs - // one missing line if it is wrong; a prefix in an exemption ledger silently - // disables the gate for a whole family, the wide-head hazard - // check-i18n-dead-keys.mjs reasons about at MIN_HEAD_SEGMENTS. - const withResidue = analysePairs(tablesOf(FIXTURE)); - expect(withResidue.unexplainedZhOnly).toHaveLength(1); - expect(withResidue.missingZh).toEqual([]); - expect(runGateExpectingAnyExit(repoWithTable(FIXTURE)).status).toBe(0); - }); -}); - -describe('the documented zh-only families', () => { - it('every entry carries a reason and an in-file citation', () => { - // Ruled by objectui#8834: an entry with no citation is not admissible. The - // reason a family is one-sided is a fact about a CONSUMER, and a claim about - // a consumer that names no consumer is an assertion. - expect(ZH_ONLY_FAMILIES.length).toBeGreaterThan(0); - for (const family of ZH_ONLY_FAMILIES) { - expect(family.prefix, 'a family must be a prefix').toMatch(/\.$/); - expect(family.reason.trim().length, `${family.prefix} has no reason`).toBeGreaterThan(20); - expect(family.citation, `${family.prefix} has no citation`).toBeTruthy(); - expect(family.citation.file, `${family.prefix} cites no file`).toMatch(/^[\w./-]+$/); - expect( - family.citation.anchor.trim().length, - `${family.prefix} cites no anchor text`, - ).toBeGreaterThan(8); - // objectui#8875 clause 3: the citation used to be `path:line`, a STORED - // line number pointing into another file. It was one unrelated edit away - // from naming the wrong line, and nothing here ever followed it, so the - // drift would have been silent. An anchor is text, so it can be CHECKED — - // which the next assertion does. - expect( - `${family.citation.file} ${family.citation.anchor}`, - `${family.prefix} still cites a line address`, - ).not.toMatch(/\.[A-Za-z]+:\d+/); - } - }); - - it('every citation resolves: the file exists and the anchor text is in it, exactly once', () => { - for (const family of ZH_ONLY_FAMILIES) { - const full = path.join(repoRoot, family.citation.file); - expect(fs.existsSync(full), `${family.citation.file} names no file`).toBe(true); - - const source = fs.readFileSync(full, 'utf8'); - const occurrences = source.split(family.citation.anchor).length - 1; - expect( - occurrences, - `${family.prefix} cites "${family.citation.anchor}" in ${family.citation.file}, which ` + - `contains it ${occurrences} time(s). A citation that locates nothing — or several ` + - `things — is the failure objectui#8875 retired the line numbers for; it is not fixed ` + - `by writing a number back.`, - ).toBe(1); - } - - // Anti-vacuity: the same reader, on a string that is deliberately absent, - // must return zero. Without it a broken read would agree with every anchor. - const control = fs.readFileSync( - path.join(repoRoot, ZH_ONLY_FAMILIES[0].citation.file), - 'utf8', - ); - expect(control.includes('zzz-this-anchor-does-not-exist')).toBe(false); - }); - - it('every family still subtracts at least one real key', () => { - // A family that explains nothing is dead weight in a report that exists to - // be read. Deleting the entry is the fix, not raising a ceiling. - const result = analysePairs(readDesignerPairs(repoRoot)); - for (const family of ZH_ONLY_FAMILIES) { - expect(result.familyCounts[family.prefix], `${family.prefix} explains nothing`).toBeGreaterThan(0); - } - }); -}); - -describe('the pair list is the gate’s own, not the dead-keys sweep’s', () => { - it('does not reuse DESIGNER_TABLE_CONSTS', () => { - // Widening that constant to reach TYPE_LABELS_* / DOMAIN_LABELS_* would - // change what the DEAD-KEYS gate sweeps, as a side effect nobody asked for: - // its companion DESIGNER_KEY_ROOTS (`engine.`/`designer.`/`perm.`) does not - // cover bare identifier keys such as `object` and `data`. - expect(DESIGNER_PAIR_CONSTS).not.toEqual(DESIGNER_TABLE_CONSTS); - expect(DESIGNER_PAIR_CONSTS.length).toBeGreaterThan(DESIGNER_TABLE_CONSTS.length); - for (const name of DESIGNER_TABLE_CONSTS) expect(DESIGNER_PAIR_CONSTS).toContain(name); - }); - - it('names constants that really exist in the real table', () => { - const tables = readDesignerPairs(repoRoot); - for (const name of DESIGNER_PAIR_CONSTS) { - expect(tables.get(name), `${name} is not in ${DESIGNER_TABLE}`).toBeDefined(); - expect(tables.get(name)!.size).toBeGreaterThan(0); - } - }); -}); - -describe('the real tree', () => { - it('is green: every en row has a zh row and every shared row keeps its placeholders', () => { - const result = analysePairs(readDesignerPairs(repoRoot)); - expect(result.missingZh).toEqual([]); - expect(result.placeholders).toEqual([]); - }); - - it('has every zh-only key explained by a documented family', () => { - // Not a gate — the residue is report-only — but a green residue today is - // what makes a NEW entry in it mean something to a reader tomorrow. - const result = analysePairs(readDesignerPairs(repoRoot)); - expect(result.unexplainedZhOnly).toEqual([]); - }); -}); - -describe('the CLI, end to end', () => { - it('exits 0 and prints the subtraction when the tables are paired', () => { - const run = runGateExpectingAnyExit(repoWithTable(FIXTURE)); - expect(run.status).toBe(0); - expect(run.output).toContain('report only'); - expect(run.output).toContain('engine.flowNode.'); - }); - - it('exits 1 and names the en key that has no zh row', () => { - const root = repoWithTable(FIXTURE.replace(" 'engine.fx.shared': 'Shared',", " 'engine.fx.shared': 'Shared',\n 'engine.fx.enOnly': 'No zh row',")); - const run = runGateExpectingAnyExit(root); - expect(run.status).toBe(1); - expect(run.output).toContain('engine.fx.enOnly'); - }); - - it('exits 1 and names the dropped placeholder', () => { - const root = repoWithTable(FIXTURE.replace("'共 {count} 项,合计 {total}'", "'共若干项,合计 {total}'")); - const run = runGateExpectingAnyExit(root); - expect(run.status).toBe(1); - expect(run.output).toContain('engine.fx.withPlaceholder'); - expect(run.output).toContain('MISSING from zh: {count}'); - }); - - it('fails loudly rather than passing when the scan collapses', () => { - // Over an empty corpus every assertion above is trivially satisfied, so a - // refactor that stopped reading the tables would report a confident green. - // Same guard, same reason, as check-i18n-en-drift.mjs's --min-keys. - const run = runGateExpectingAnyExit(repoWithTable(FIXTURE), 9999); - expect(run.status).toBe(1); - expect(run.output).toContain('The scan collapsed'); - }); - - it('throws rather than sweeping an empty corpus when a table constant is gone', () => { - const root = repoWithTable(FIXTURE.replace('const ENGINE_STRINGS_ZH', 'const RENAMED_STRINGS_ZH')); - const run = runGateExpectingAnyExit(root); - expect(run.status).not.toBe(0); - expect(run.output).toContain('the extractor is stale'); - }); -}); - -describe('the gate is wired to run', () => { - const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); - // The workflow GitHub runs: objectstack's root ci.yml, whose `type-check-ui` - // job is objectui's `Type Check` (objectstack#25697). objectui's own ci.yml - // under `ui/.github/` runs nowhere (objectstack#25712). - const ci = rootCi(); - const job = jobBlock(ci, UI_JOBS.typeCheck); - - it('package.json exposes it as a named script', () => { - expect(pkg.scripts['check:i18n-designer-parity']).toBe('node scripts/check-i18n-designer-table-parity.mjs'); - }); - - it('ci.yml runs it after the install it needs (it imports typescript)', () => { - // The whole reason this is a NEW script rather than a leg of - // check-i18n-dead-keys.mjs: that one is invoked in no workflow at all, by - // design ("This is a REPORT, not a gate"), so a leg there could never be - // observed red in CI — the disease objectui#8834 exists to cure. - const install = job.indexOf('pnpm install --frozen-lockfile'); - const step = job.indexOf(`run: ${uiRun('check:i18n-designer-parity')}`); - expect(step, '`type-check-ui` does not run `pnpm --dir ui run check:i18n-designer-parity`').toBeGreaterThan(-1); - expect(install, '`type-check-ui` no longer installs dependencies').toBeGreaterThan(-1); - expect(step, 'the check runs before dependencies are installed').toBeGreaterThan(install); - }); - - it('is not hidden from the designer table by ci.yml path filters', () => { - // Same reasoning as check-i18n-en-drift.test.ts's twin assertion: the root - // ci.yml's triggers carry no path filter, and the `filter` job's `ui:` globs - // decide whether `type-check-ui` runs, so a glob set that missed the - // designer table or this gate would make it unreachable for exactly the - // PRs it judges. - expect(withoutComments(triggerBlock(ci)), 'a trigger grew a path filter').not.toMatch(/^\s+paths(-ignore)?:/m); - expect(job, '`type-check-ui` no longer reads the `ui` filter output').toContain( - "needs.filter.outputs.ui != 'false'", - ); - const globs = uiFilterGlobs(ci); - for (const file of [DESIGNER_TABLE, 'scripts/check-i18n-designer-table-parity.mjs'].map(repoPath)) { - expect(globs.some((glob) => globMatches(glob, file)), `no \`ui:\` filter glob covers ${file}`).toBe(true); - } - }); -}); diff --git a/ui/scripts/__tests__/check-i18n-en-drift.test.ts b/ui/scripts/__tests__/check-i18n-en-drift.test.ts index 8fde4b5cbbd..409f5afa2cd 100644 --- a/ui/scripts/__tests__/check-i18n-en-drift.test.ts +++ b/ui/scripts/__tests__/check-i18n-en-drift.test.ts @@ -24,7 +24,7 @@ import { designerPopulation, readDesignerTables, } from '../check-i18n-en-drift.mjs'; -import { DESIGNER_TABLE } from '../check-i18n-designer-table-parity.mjs'; +import { DESIGNER_TABLE } from '../check-i18n-dead-keys.mjs'; import { globMatches, jobBlock, repoPath, rootCi, triggerBlock, uiFilterGlobs, uiRun, UI_JOBS, withoutComments } from './helpers/root-ci'; /** @@ -662,7 +662,39 @@ describe('the designer table as a second population', () => { expect(result.counters.absentInPack).toBe(1); }); - it('leaves added and removed keys to check:i18n-designer-parity', () => { + // objectstack#25922 slice S7: the module declares no ENGINE_STRINGS_* at all, + // and the namespace catalogues it imports stand for both tables. + const tableless = (en: Record, zh: Record) => ({ + ...packFiles( + treesFor(() => ({ a: 'x' })), + "import { studioString } from '@object-ui/i18n/namespaces/studio';\n" + + designerSource({}, {}).replace(/const ENGINE_STRINGS_(EN|ZH)[^;]*;\n\n/g, ''), + ), + 'packages/i18n/src/namespaces/studio/en.ts': `const STUDIO_EN: Record = ${JSON.stringify(en)};\n`, + 'packages/i18n/src/namespaces/studio/zh.ts': `const STUDIO_ZH: Record = ${JSON.stringify(zh)};\n`, + }); + + it('a row that moved into the namespace unchanged, table and all, is not drift', () => { + const { root, shas } = repoWithCommits([ + withDesigner({ 'engine.fx.help': 'Up to 5 items' }, { 'engine.fx.help': '最多 5 项' }), + tableless({ 'engine.fx.help': 'Up to 5 items' }, { 'engine.fx.help': '最多 5 项' }), + ]); + const result = analyzeDesigner(root, { base: shas[0], head: shas[1] }); + expect(result.findings).toEqual([]); + expect(result.counters).toMatchObject({ addedEnKeys: 0, removedEnKeys: 0, changedEnKeys: 0 }); + }); + + it('still names an unfollowed en change once the table is gone', () => { + const { root, shas } = repoWithCommits([ + tableless({ 'engine.fx.help': 'Up to 5 items' }, { 'engine.fx.help': '最多 5 项' }), + tableless({ 'engine.fx.help': 'Up to 20 items' }, { 'engine.fx.help': '最多 5 项' }), + ]); + const result = analyzeDesigner(root, { base: shas[0], head: shas[1] }); + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ key: 'ENGINE_STRINGS.engine.fx.help', unfollowed: ['zh'] }); + }); + + it('leaves added and removed keys to the key-set parity pins', () => { const { root, shas } = repoWithCommits([ withDesigner({ 'engine.fx.a': 'A' }, { 'engine.fx.a': '甲' }), withDesigner({ 'engine.fx.b': 'B' }, { 'engine.fx.b': '乙' }), diff --git a/ui/scripts/check-i18n-dead-keys.mjs b/ui/scripts/check-i18n-dead-keys.mjs index 9d192e3e793..90ef65bf2c9 100644 --- a/ui/scripts/check-i18n-dead-keys.mjs +++ b/ui/scripts/check-i18n-dead-keys.mjs @@ -4,7 +4,8 @@ * * TWO corpora, one question, one report — `sweep()` over the ten locale packs * (objectui#4658), and `sweepDesignerTable()` over the metadata-admin - * designer's module-local `ENGINE_STRINGS_*` table (objectui#8388). They are + * designer's `ENGINE_STRINGS_*` strings (objectui#8388; read from the `studio` + * namespace since objectstack#25922 deleted the module-local table). They are * one script because they ask the same mirror question from the same side, not * because they share a walker: the pack half reads the call-site gate's own * AST pass, the designer half has its own, because no call-site walker in this @@ -1715,7 +1716,10 @@ export function sweep(root) { /** * The SECOND corpus this file sweeps: the metadata-admin designer's own * module-local string table, `ENGINE_STRINGS_EN` / `ENGINE_STRINGS_ZH` in - * `packages/app-shell/src/views/metadata-admin/i18n.ts`. + * `packages/app-shell/src/views/metadata-admin/i18n.ts`. Since objectstack#25922 + * slice S7 the module declares neither: both names stand for the `studio` + * namespace catalogues (`DESIGNER_NAMESPACE_HOMES` below), the one home the + * runtime reads. * * ## Why it needs its own leg at all — both i18n gates are blind BY CONSTRUCTION * @@ -1766,8 +1770,8 @@ export function sweep(root) { * 2. DYNAMIC HEAD — the static head of every template expression in the same * files whose head starts with one of the roots, INCLUDING the ones inside * the table's own module (`translateNodeLabel` and `translateEnumOption` - * index `pickTable(locale).strings[...]` directly, with no `t()` call for - * any call-site walker to see). Blind to: a head composed across modules, + * hand a template-built key to `lookupString(...)` directly, with no `t()` + * call for any call-site walker to see). Blind to: a head composed across modules, * or built by concatenation rather than a template literal. * 3. TEXT SAFETY NET — `textFootprint()`, the same whole-repo grep the pack * sweep uses, minus the property-chain probe. It splits the survivors into @@ -1791,17 +1795,46 @@ export const DESIGNER_TABLE = 'packages/app-shell/src/views/metadata-admin/i18n. /** The two table constants whose PROPERTY NAMES are the corpus. Their own * initializers are skipped by the reader walk: a key's definition line is not - * evidence that anything reads it. */ + * evidence that anything reads it. Since objectstack#25922 slice S7 the module + * declares neither: each NAMES the namespace catalogue that holds its keys + * (see {@link DESIGNER_NAMESPACE_HOMES}), so every reader keeps one spelling + * for "the designer's engine strings, en" and "…, zh". */ export const DESIGNER_TABLE_CONSTS = ['ENGINE_STRINGS_EN', 'ENGINE_STRINGS_ZH']; /** - * The table's SECOND home: the `studio` namespace of `@object-ui/i18n`, which - * the table moves into slice by slice (objectstack#25922, maintainer ruling + * The designer's paired string tables, as `{ label, en, zh }` — the population + * the designer leg of `check-i18n-en-drift.mjs` reads, and the one + * `__tests__/check-i18n-dead-keys.test.ts` holds to en ⊆ zh and placeholder + * parity on the real tree. ITS OWN list, deliberately not + * {@link DESIGNER_TABLE_CONSTS}: that constant is the dead-keys sweep's own + * corpus, and {@link DESIGNER_KEY_ROOTS} (`engine.` / `designer.` / `perm.`) + * does not cover `TYPE_LABELS_*` or `DOMAIN_LABELS_*` keys, which are bare + * identifiers such as `object` and `data`. Widening it to reach them would + * change what this file's sweep reads, as a side effect nobody asked for. + * + * Moved here from the retired `check-i18n-designer-table-parity.mjs` + * (objectstack#25922 slice S7), so the one reader of these tables also owns + * the pair list its callers share. + */ +export const DESIGNER_TABLE_PAIRS = Object.freeze([ + Object.freeze({ label: 'ENGINE_STRINGS', en: 'ENGINE_STRINGS_EN', zh: 'ENGINE_STRINGS_ZH' }), + Object.freeze({ label: 'TYPE_LABELS', en: 'TYPE_LABELS_EN', zh: 'TYPE_LABELS_ZH' }), + Object.freeze({ label: 'DOMAIN_LABELS', en: 'DOMAIN_LABELS_EN', zh: 'DOMAIN_LABELS_ZH' }), +]); + +/** Every constant {@link DESIGNER_TABLE_PAIRS} names, in declaration order. */ +export const DESIGNER_PAIR_CONSTS = DESIGNER_TABLE_PAIRS.flatMap((pair) => [pair.en, pair.zh]); + +/** + * The engine strings' home: the `studio` namespace of `@object-ui/i18n`, which + * the table moved into slice by slice (objectstack#25922, maintainer ruling * 6431032267 Q2 = A). Each entry names one namespace catalogue, the constant it - * declares, and the table constant it is a part of: its keys are read as - * members of that table, so every reader of this extractor — the dead-keys - * sweep, `check-i18n-designer-table-parity.mjs` and the designer population of - * `check-i18n-en-drift.mjs` — sees ONE table whichever home a key sits in. + * declares, and the table constant it stands for: its keys are read as members + * of that table, so every reader of this extractor — the dead-keys sweep, the + * designer population of `check-i18n-en-drift.mjs` and the pair-parity pin in + * this file's test — sees ONE table. Since slice S7 the table module declares no + * `ENGINE_STRINGS_*` literal at all, and a constant a home stands for is that + * home alone; a commit from before S7 still reads its table rows beside it. * * ⭐ The homes are read only when the table module IMPORTS * {@link DESIGNER_NAMESPACE_SPECIFIER}, because that import is the edge the @@ -1811,8 +1844,8 @@ export const DESIGNER_TABLE_CONSTS = ['ENGINE_STRINGS_EN', 'ENGINE_STRINGS_ZH']; * home; a table that imports the namespace and a home that cannot be read is * the stale-extractor throw. * - * ⛔ A key in BOTH homes throws. The runtime would serve the table's copy and - * the namespace's would be dead text a translator could edit to no effect. + * ⛔ A key in BOTH homes throws: a table literal brought back beside the + * namespace would be dead text a translator could edit to no effect. */ export const DESIGNER_NAMESPACE_SPECIFIER = '@object-ui/i18n/namespaces/studio'; @@ -1852,8 +1885,8 @@ function isDesignerKeyShape(text) { * ## The optional second argument (objectui#8834) * * This extractor is the ONLY reader of these tables, and it stays that way on - * purpose. `check-i18n-designer-table-parity.mjs` and the designer population - * of `check-i18n-en-drift.mjs` both call it rather than growing a second copy: + * purpose. {@link readDesignerPairs} and the designer population of + * `check-i18n-en-drift.mjs` both call it rather than growing a second copy: * `changeset-guard.yml:63-72` records what the second copy costs here — the * base-ref resolver's second copy, in `check-i18n-en-drift.mjs`, inherited a * real defect from the first draft and had to be fixed to match under @@ -1983,14 +2016,6 @@ export function collectDesignerKeys(root, options = {}) { if (withValues) values.set(name, texts); } - if (require) { - for (const name of consts) { - if (!tables.has(name)) { - throw new Error(`${label}: \`const ${name} = { … }\` not found — the extractor is stale`); - } - } - } - // The namespace homes, read only through the import the runtime follows — // see DESIGNER_NAMESPACE_SPECIFIER. const importsNamespace = source.statements.some( @@ -1999,6 +2024,26 @@ export function collectDesignerKeys(root, options = {}) { ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === DESIGNER_NAMESPACE_SPECIFIER, ); + // A table constant the module no longer declares (objectstack#25922 slice + // S7 deleted both) is its namespace home alone. Only through the import: a + // module that neither declares the constant nor imports the namespace is + // still the stale-extractor throw below. + if (importsNamespace) { + for (const home of DESIGNER_NAMESPACE_HOMES) { + if (!consts.includes(home.table) || tables.has(home.table)) continue; + tables.set(home.table, new Set()); + if (withValues) values.set(home.table, new Map()); + } + } + + if (require) { + for (const name of consts) { + if (!tables.has(name)) { + throw new Error(`${label}: \`const ${name} = { … }\` not found — the extractor is stale`); + } + } + } + if (importsNamespace) { for (const home of DESIGNER_NAMESPACE_HOMES) { if (!consts.includes(home.table) || !tables.has(home.table)) continue; @@ -2031,8 +2076,8 @@ export function collectDesignerKeys(root, options = {}) { if (tableKeys.has(key)) { throw new Error( `${JSON.stringify(key)} is in both ${label} (${home.table}) and ${home.file} — a designer ` + - 'key lives in exactly one home. Delete the copy that is not being served: the table ' + - 'answers first, so the namespace copy is the dead one unless the table row is the stray.', + 'key lives in exactly one home. The runtime reads only the namespace, so the table row ' + + 'is the stray: delete it.', ); } tableKeys.add(key); @@ -2046,6 +2091,32 @@ export function collectDesignerKeys(root, options = {}) { return { tables, corpus, values }; } +/** + * Both halves of every {@link DESIGNER_TABLE_PAIRS} pair, as + * `constant name -> (key -> value)`, through {@link collectDesignerKeys}. + * + * Moved here from the retired `check-i18n-designer-table-parity.mjs` + * (objectstack#25922 slice S7) with its options unchanged. + */ +export function readDesignerPairs( + root, + { source = null, label = DESIGNER_TABLE, require = true, readHome = null } = {}, +) { + const { values } = collectDesignerKeys(root, { + consts: DESIGNER_PAIR_CONSTS, + source, + label, + withValues: true, + require, + readHome, + }); + // Unreachable — `withValues` is requested right above. Asserted rather than + // coerced to an empty map: an empty map would compare nothing and pass, which + // is the one failure mode every reader of these tables is built to refuse. + if (values === null) throw new Error(`${label}: values were requested and not returned`); + return values; +} + /** * Everything in shipped source that could ask this table for a key. * diff --git a/ui/scripts/check-i18n-designer-table-parity.mjs b/ui/scripts/check-i18n-designer-table-parity.mjs deleted file mode 100644 index 245bc9c2979..00000000000 --- a/ui/scripts/check-i18n-designer-table-parity.mjs +++ /dev/null @@ -1,384 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * The metadata-admin designer's OWN string tables must stay paired (objectui#8834). - * - * Run: node scripts/check-i18n-designer-table-parity.mjs (also `pnpm check:i18n-designer-parity`) - * Exit: 0 = every `en` row has a `zh` row and every shared row carries the same - * `{placeholders}`, 1 = it does not - * - * ## The gap this closes - * - * `packages/app-shell/src/views/metadata-admin/i18n.ts` carries the Studio - * designer's own module-local string tables — ~3600 lines of user-visible text - * — and until this gate NOTHING checked that the two halves of a pair follow - * each other. The two shipped i18n gates are blind to it BY CONSTRUCTION, which - * is measured rather than assumed (objectui#8388, comment 5592768866, quoted in - * the header of `check-i18n-dead-keys.mjs`): - * - * - `check-i18n-call-site-keys.mjs` resolves call sites against the `en` - * PACK, and classifies this module `module-local table` by declaration and - * skips it outright. - * - `check-i18n-en-drift.mjs` read the TEN LOCALE PACKS. This table is not one - * of them. (objectui#8834 also gave that gate this table as a SEPARATE - * population — see below for why that leg lives there and not here.) - * - * `check-i18n-dead-keys.mjs` does sweep this table, but it asks about DEAD keys - * and its corpus is deliberately the UNION of both tables (`:674-676`), so a - * one-sided key is not a subject to it at all — it is just "a key of this - * bundle". And that script is a REPORT that runs in no workflow. So: add an - * English string, forget the Chinese one, and every gate in this repo is green. - * - * ## Why this is a SEPARATE script and not another leg of the dead-keys sweep - * - * `check:i18n-dead-keys` is invoked in NO file under `.github/workflows/` - * (measured, with the lit control that the same probe DOES find - * `check:i18n-keys` at `ci.yml:487` and `check:i18n-drift` at `:500`), and that - * is by design — its own header says "This is a REPORT, not a gate". A leg - * added there could never be observed red in CI, which is precisely the disease - * objectui#8834 exists to cure. - * - * ## Why the VALUE-drift leg is not here either - * - * "The `en` value changed and `zh` did not" is an EVENT in a diff, not a state - * of the tree, and `check-i18n-en-drift.mjs` already owns that mechanism and - * says so of itself: "What a gate can decide is the event". Its designer - * population, added by the same card, answers that half. Everything in THIS - * file is a whole-table state invariant, decidable on any single commit with no - * base to diff against. Each leg lands in the mechanism that owns it. - * - * ## What is enforced, and what is only reported - * - * ENFORCED — `en` is a SUBSET of `zh`, per pair. Every key the `en` table - * declares must have a `zh` row. This is the direction an author actually gets - * wrong: they add or rename an English string and the Chinese one does not - * follow. - * - * ENFORCED — placeholder parity over the SHARED keys, per pair. The two values - * must carry the same set of `{token}` names, read with `tFormat()`'s own regex - * (`i18n.ts`: `template.replace(/\{(\w+)\}/g, …)`). A translation that dropped - * `{count}` renders a sentence with a hole in it at runtime, and no key-shaped - * gate can see it. - * - * REPORTED, NEVER ENFORCED — `zh`-only keys, minus the three families below. - * The other direction is NOT symmetric here and must not be gated: - * `ENGINE_STRINGS_ZH` carries 55 keys `ENGINE_STRINGS_EN` deliberately lacks, - * and in all three families an `en` row would be a REGRESSION rather than a - * gap (see `ZH_ONLY_FAMILIES`). So the enforced half lands green with an EMPTY - * exemption ledger — there is no ledger a wrong entry could silently disable — - * and the residue is printed instead, so a NEW and unintentional one-sided - * `zh` family is still visible to a reader. - * - * ⚠️ The asymmetry between the two halves is the whole design: a wrong entry in - * the subtraction below costs one missing line of output; a wrong entry in a - * gate's exemption ledger silently disables the gate. - * - * ## What is NOT checked, deliberately - * - * Whether a value is "actually translated". One side is English and the other - * Chinese, so the values SHOULD differ; and two sides that are literally equal - * are routinely correct (proper nouns, API names such as `highlightFields`, - * `AI`). Any check that reaches for meaning is a translator wearing a regex — - * the same argument `check-i18n-en-drift.mjs` makes at length under - * "Typography is NOT exempt", from two shipped defects. - */ - -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { collectDesignerKeys, DESIGNER_TABLE } from './check-i18n-dead-keys.mjs'; -import { isEntrypoint } from './invoked-as.mjs'; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); - -export { DESIGNER_TABLE }; - -/** - * The paired tables this gate judges — ITS OWN list, deliberately not - * `DESIGNER_TABLE_CONSTS`. - * - * That constant is the dead-keys sweep's own corpus, and its companion - * `DESIGNER_KEY_ROOTS` (`engine.` / `designer.` / `perm.`) does not cover - * `TYPE_LABELS_*` or `DOMAIN_LABELS_*` keys, which are bare identifiers such as - * `object` and `data`. Widening it to reach them would change what THAT gate - * sweeps, as a side effect nobody asked for. - * - * A pair is `{ label, en, zh }` and adding one is a single line. Measured on - * this checkout when the list was seeded, through the shipped extractor with - * none of its stale-detection loosened: `ENGINE_STRINGS` 1657/1712, - * `TYPE_LABELS` 36/36, `DOMAIN_LABELS` 9/9 — 0 `en`-only keys and 0 placeholder - * mismatches across all three, so this gate arrives green on all three. - */ -export const DESIGNER_TABLE_PAIRS = [ - { label: 'ENGINE_STRINGS', en: 'ENGINE_STRINGS_EN', zh: 'ENGINE_STRINGS_ZH' }, - { label: 'TYPE_LABELS', en: 'TYPE_LABELS_EN', zh: 'TYPE_LABELS_ZH' }, - { label: 'DOMAIN_LABELS', en: 'DOMAIN_LABELS_EN', zh: 'DOMAIN_LABELS_ZH' }, -]; - -/** Every constant the pair list names, in declaration order. */ -export const DESIGNER_PAIR_CONSTS = DESIGNER_TABLE_PAIRS.flatMap((pair) => [pair.en, pair.zh]); - -/** - * The `zh`-only key families that are DELIBERATE, subtracted from the REPORT - * and — never — from the gate. - * - * Each entry carries its reason and its in-file citation, and both are printed - * on the same line as the family, so a reader of the output can check the claim - * without leaving it. An entry with no citation is not admissible: the reason a - * family is one-sided is a fact about a consumer, and a claim about a consumer - * that names no consumer is an assertion. - * - * ⛔ A citation is `{ file, anchor }` and the anchor is TEXT, ⛔ never a line - * number (objectui#8875 clause 3, on the maintainer's ruling of 2026-09-10: - * 跨文件的「某文件第几行」引用, 这种完全没必要吧,是否应该避免). These three - * addresses were stored literals pointing INTO another file, so every one of - * them was one unrelated edit away from naming the wrong line — silently, since - * nothing here ever followed them. An anchor is checked: this gate's test - * asserts the text is actually present in the file, which a line number never - * could be. ⚠️ The anchor must be a string that occurs exactly once, or the - * citation stops locating anything. - * - * These prefixes decide only what a REPORT prints. They can never make a - * failing gate pass, which is why a prefix is safe here and would not be in an - * exemption ledger — a prefix ledger swallows an entire family's future - * one-sided keys, the wide-head hazard `check-i18n-dead-keys.mjs` already - * reasons about at `MIN_HEAD_SEGMENTS`. - */ -export const ZH_ONLY_FAMILIES = [ - { - prefix: 'engine.flowNode.', - reason: - 'zh-only by design: for English the flow palette falls back to the engine descriptor’s own ' + - 'server-authoritative name/description, so an `en` row would OVERRIDE the server', - citation: { - file: 'packages/app-shell/src/views/metadata-admin/i18n.ts', - anchor: 'resolved by translateNodeLabel/Hint', - }, - }, - { - prefix: 'engine.enum.type.', - reason: - 'zh-only by construction: translateEnumOption returns the raw value before it ever touches the ' + - 'table for any non-zh locale (`if (!isZhLocale(locale)) return value;`), so an `en` row is unreachable', - citation: { - file: 'packages/app-shell/src/views/metadata-admin/i18n.ts', - anchor: 'export function translateEnumOption', - }, - }, - { - prefix: 'engine.packages.form.help.', - reason: - 'zh-only on purpose: with no `en` row tOptional returns undefined, helpText drops out, and the row ' + - 'falls back to ManifestSchema’s .describe() in @objectstack/spec — an `en` row would copy that ' + - 'text into a second producer, against AGENTS.md #0.1', - citation: { - file: 'packages/app-shell/src/views/metadata-admin/package-schema.ts', - anchor: 'en-US deliberately has NO entry', - }, - }, -]; - -/** - * `tFormat()`'s OWN placeholder regex, copied from `i18n.ts` verbatim: - * - * template.replace(/\{(\w+)\}/g, (_m, name) => name in vars ? … : `{${name}}`) - * - * Copied rather than approximated because the question this leg asks is - * "what will the runtime substitute", and only that expression answers it. A - * looser pattern would report tokens the runtime never substitutes; a tighter - * one would miss the ones it does. - */ -export const PLACEHOLDER_PATTERN = /\{(\w+)\}/g; - -/** The sorted, de-duplicated placeholder names in one value. */ -export function placeholdersOf(value) { - return [...new Set(Array.from(value.matchAll(PLACEHOLDER_PATTERN), (match) => match[1]))].sort(); -} - -/** - * Both halves of every pair, as `constant name -> (key -> value)`. - * - * Reads through the shipped extractor in `check-i18n-dead-keys.mjs` rather than - * re-implementing the parse: see that function's header for why this repo - * treats a second copy of a reader as a defect and not a convenience. - */ -export function readDesignerPairs( - root, - { source = null, label = DESIGNER_TABLE, require = true, readHome = null } = {}, -) { - const { values } = collectDesignerKeys(root, { - consts: DESIGNER_PAIR_CONSTS, - source, - label, - withValues: true, - require, - readHome, - }); - // Unreachable — `withValues` is requested right above. Asserted rather than - // coerced to an empty map: an empty map would compare nothing and pass, which - // is the one failure mode every gate in this family is built to refuse. - if (values === null) throw new Error(`${label}: values were requested and not returned`); - return values; -} - -/** @typedef {Map>} DesignerTables */ - -/** - * @typedef {{ pair: string, key: string }} MissingZh - * @typedef {{ pair: string, key: string, en: string[], zh: string[], missing: string[], extra: string[] }} PlaceholderMismatch - * @typedef {{ pair: string, key: string }} UnexplainedZhOnly - */ - -/** - * The two enforced invariants plus the reported residue, for one set of tables. - * - * @param {Map>} tables - * @returns {{ missingZh: MissingZh[], placeholders: PlaceholderMismatch[], - * unexplainedZhOnly: UnexplainedZhOnly[], counters: Record, - * familyCounts: Record, pairSizes: Array<{ pair: string, en: number, zh: number }> }} - */ -export function analysePairs(tables, pairs = DESIGNER_TABLE_PAIRS, families = ZH_ONLY_FAMILIES) { - /** @type {MissingZh[]} */ const missingZh = []; - /** @type {PlaceholderMismatch[]} */ const placeholders = []; - /** @type {UnexplainedZhOnly[]} */ const unexplainedZhOnly = []; - const counters = { enKeys: 0, zhKeys: 0, sharedKeys: 0, keysWithPlaceholders: 0, zhOnly: 0, explainedZhOnly: 0 }; - const familyCounts = Object.fromEntries(families.map((family) => [family.prefix, 0])); - const pairSizes = []; - - for (const pair of pairs) { - const en = tables.get(pair.en); - const zh = tables.get(pair.zh); - if (!en || !zh) continue; - pairSizes.push({ pair: pair.label, en: en.size, zh: zh.size }); - counters.enKeys += en.size; - counters.zhKeys += zh.size; - - for (const [key, enValue] of en) { - if (!zh.has(key)) { - missingZh.push({ pair: pair.label, key }); - continue; - } - counters.sharedKeys += 1; - const enTokens = placeholdersOf(enValue); - const zhTokens = placeholdersOf(zh.get(key)); - if (enTokens.length > 0 || zhTokens.length > 0) counters.keysWithPlaceholders += 1; - if (enTokens.join(' ') !== zhTokens.join(' ')) { - placeholders.push({ - pair: pair.label, - key, - en: enTokens, - zh: zhTokens, - missing: enTokens.filter((token) => !zhTokens.includes(token)), - extra: zhTokens.filter((token) => !enTokens.includes(token)), - }); - } - } - - for (const key of zh.keys()) { - if (en.has(key)) continue; - counters.zhOnly += 1; - const family = families.find((candidate) => key.startsWith(candidate.prefix)); - if (family) { - familyCounts[family.prefix] += 1; - counters.explainedZhOnly += 1; - continue; - } - unexplainedZhOnly.push({ pair: pair.label, key }); - } - } - - return { missingZh, placeholders, unexplainedZhOnly, counters, familyCounts, pairSizes }; -} - -if (isEntrypoint(import.meta.url)) { - const argOf = (name) => { - const index = process.argv.indexOf(name); - return index > -1 ? process.argv[index + 1] : null; - }; - const root = resolve(argOf('--root') ?? resolve(scriptDir, '..')); - - const tables = readDesignerPairs(root); - const result = analysePairs(tables); - - // The same collapse guard `check-i18n-en-drift.mjs` opens with, for the same - // reason: over an empty corpus every assertion below is trivially satisfied, - // so a refactor that stopped reading the tables would report a confident - // green. `--min-keys` exists because this gate's own tests run it over small - // fixture tables; CI never passes it, and the DEFAULT is the number that - // matters. It is a floor well under today's 1702, not a pin: adding strings - // is routine and must not need this file edited. - const minKeys = Number(argOf('--min-keys') ?? 1500); - if (result.counters.enKeys < minKeys) { - console.error( - `The scan collapsed: the en side of ${DESIGNER_TABLE_PAIRS.length} pair(s) parsed to ` + - `${result.counters.enKeys} keys, expected at least ${minKeys}. The extractor is broken, and an ` + - 'empty comparison would pass while asserting nothing.', - ); - process.exit(1); - } - - console.log( - `${DESIGNER_TABLE}: ${result.pairSizes.map((size) => `${size.pair} ${size.en} en / ${size.zh} zh`).join(', ')} ` + - `— ${result.counters.sharedKeys} shared key(s), ${result.counters.keysWithPlaceholders} of them ` + - `carrying a {placeholder}, ${result.counters.zhOnly} zh-only key(s) ` + - `(${result.counters.explainedZhOnly} in a documented family).`, - ); - - // The REPORT half. Printed on every run, red or green, and it never changes - // the exit code — its whole job is to make a NEW one-sided zh family visible - // to a reader without handing anyone a lever that can turn the gate off. - console.log('\nzh-only keys, by documented family (report only — these never affect the exit code):'); - for (const family of ZH_ONLY_FAMILIES) { - console.log( - ` ${result.familyCounts[family.prefix]} ${family.prefix} ${family.reason} ` + - `[${family.citation.file} -> ${family.citation.anchor}]`, - ); - } - if (result.unexplainedZhOnly.length === 0) { - console.log(' 0 no zh-only key falls outside the families above.'); - } else { - console.log( - ` ${result.unexplainedZhOnly.length} zh-only key(s) in NO documented family. Not a ` + - 'failure, and not necessarily wrong — but nothing in this repo yet explains why the en side is absent:', - ); - for (const finding of result.unexplainedZhOnly) console.log(` ${finding.pair}: ${finding.key}`); - } - - if (result.missingZh.length === 0 && result.placeholders.length === 0) { - console.log('\nEvery en row has a zh row, and every shared row carries the same placeholders.'); - process.exit(0); - } - - if (result.missingZh.length > 0) { - console.error(`\n${result.missingZh.length} en key(s) with no zh row:\n`); - for (const finding of result.missingZh) console.error(` ${finding.pair} ${finding.key}`); - console.error( - '\nAdd the zh row IN THIS PR. The Chinese designer falls back to echoing the raw key, so the\n' + - 'console renders `engine.…` at the user. The reverse direction (a zh row with no en row) is\n' + - 'NOT gated and is listed above instead — three families are one-sided on purpose.', - ); - } - - if (result.placeholders.length > 0) { - console.error(`\n${result.placeholders.length} shared key(s) whose two values carry different {placeholders}:\n`); - for (const finding of result.placeholders) { - console.error(` ${finding.pair} ${finding.key}`); - console.error(` en has: ${finding.en.length ? finding.en.map((name) => `{${name}}`).join(' ') : '(none)'}`); - console.error(` zh has: ${finding.zh.length ? finding.zh.map((name) => `{${name}}`).join(' ') : '(none)'}`); - if (finding.missing.length > 0) { - console.error(` MISSING from zh: ${finding.missing.map((name) => `{${name}}`).join(' ')}`); - } - if (finding.extra.length > 0) { - console.error(` only in zh: ${finding.extra.map((name) => `{${name}}`).join(' ')}`); - } - } - console.error( - '\ntFormat() substitutes by NAME, so a token one side lacks is a hole in the rendered sentence\n' + - '(the value keeps the literal `{token}`) or a variable that reaches nothing. Restore the token,\n' + - 'or rename it on both sides together.', - ); - } - - console.error('\nSee the header of scripts/check-i18n-designer-table-parity.mjs.'); - process.exit(1); -} diff --git a/ui/scripts/check-i18n-en-drift.mjs b/ui/scripts/check-i18n-en-drift.mjs index 1a90a73f973..08a5d713301 100644 --- a/ui/scripts/check-i18n-en-drift.mjs +++ b/ui/scripts/check-i18n-en-drift.mjs @@ -163,7 +163,7 @@ import { DESIGNER_TABLE, DESIGNER_TABLE_PAIRS, readDesignerPairs, -} from './check-i18n-designer-table-parity.mjs'; +} from './check-i18n-dead-keys.mjs'; import { isEntrypoint } from './invoked-as.mjs'; import { revPath } from './workspace-root.mjs'; @@ -624,14 +624,15 @@ export function treeExistsAt(root, ref) { * `after` pair, its own single follower, and its own ledger section instead; * `findDrift` is reused unchanged. * - * ## Why the 55 one-sided keys need no ledger here + * ## Why the 54 one-sided keys need no ledger here * - * `ENGINE_STRINGS_ZH` carries 55 keys `ENGINE_STRINGS_EN` deliberately lacks. + * `ENGINE_STRINGS_ZH` carries 54 keys `ENGINE_STRINGS_EN` deliberately lacks. * This gate never sees them: it iterates the `en` side, and its per-key rule — * "a pack that does not define the key is skipped for that key" — already * handles the reverse direction. Key-set asymmetry is not this gate's business, - * exactly as stated for the packs; `check-i18n-designer-table-parity.mjs` owns - * that state invariant. + * exactly as stated for the packs; the namespace's own parity + * (`packages/i18n/src/__tests__/studio-namespace.test.ts`) and the pair-parity + * pin in `__tests__/check-i18n-dead-keys.test.ts` own that state invariant. */ /** The designer table's one follower. `en` leads, `zh` follows. */ @@ -817,7 +818,7 @@ if (invokedDirectly) { console.log( `Compared ${DESIGNER_TABLE} over the same range: ${designer.counters.changedEnKeys} en value(s) ` + `changed (${designer.counters.addedEnKeys} key(s) added, ${designer.counters.removedEnKeys} removed ` + - `— those are check:i18n-designer-parity's), ${designer.counters.followedPacks} zh value(s) followed, ` + + `— key-set parity is the designer pair-parity pin's), ${designer.counters.followedPacks} zh value(s) followed, ` + `${designer.counters.absentInPack} key(s) skipped because the zh table does not define them, ` + `${designerSplit.waived.length} finding(s) waived by the ledger` + (designer.baseFileMissing