diff --git a/.changeset/retire-common-search-key-4392.md b/.changeset/retire-common-search-key-4392.md new file mode 100644 index 000000000..17f50d349 --- /dev/null +++ b/.changeset/retire-common-search-key-4392.md @@ -0,0 +1,43 @@ +--- +'@object-ui/i18n': minor +'@object-ui/fields': patch +--- + +i18n: retire the reader-less `common.search` key from all ten locale packs + +`common.search` (`Search`, no ellipsis) had exactly one consumer: `LookupField` +built its dialog placeholder by concatenating the key with three ASCII full +stops. objectui#4375 / PR #4391 retired that concatenation — the placeholder is +the reused `table.search` pack value (`Search…`, one U+2026 glyph), which is what +brought it under objectui#3878's glyph pin. That left `common.search` with zero +readers repo-wide while it still existed in all ten packs. + +Re-verified before deleting, repo-wide: no `t()` call site in any package or app, +no MDX or JSON reference, and the one dynamic template-literal reader of the +`common` namespace takes a two-member union parameter (`'openChat' | +'closeChat'`) that cannot resolve to it. No user-visible string changes — this key never rendered. + +The dormant copy in `@object-ui/fields`' no-provider fallback table +(`useFieldTranslation.ts`'s `FIELD_DEFAULTS`) goes with it. That table is a +module-local `Record` read only when no `LocalizationProvider` +is mounted; it is not exported, so removing an entry no reader asks for changes +no rendered output and narrows no public type. Hence patch for that package, +while the pack change is a minor: deleting a key from `en` narrows the exported +`TranslationKeys` type (`typeof en`), so code indexing `TranslationKeys` at +`common.search` stops type-checking. Same grading, for the same reason, as +objectui#4145's `report.editor.*` retirement. No runtime consumer existed to +break. + +Retiring a key from `common` was the ruled decision on objectui#4392 rather than +keeping it as vocabulary: nothing pins a dormant key's meaning, so its next +reader inherits an unreviewed contract, and a dormant key beside a live +`table.search` is where a second dialect gets started. The objectui#4328 +dead-surface family has consistently chosen removal for zero-consumer surfaces. + +The neighbouring `common.select` (minted one commit earlier by objectui#4386 / +PR #4397) is a different key and is untouched. + +A negative pin (`packages/i18n/src/__tests__/common-search-retired-4392.test.ts`) +fails if the key returns to any pack, if any package reads or re-declares it, or +if a dynamic `common.*` reader grows a `search` member — every existing i18n gate +runs call site to key, and none of them can see a key with no call site. diff --git a/packages/fields/src/complex-widgets.test.tsx b/packages/fields/src/complex-widgets.test.tsx index 7dc766c61..40135ecd5 100644 --- a/packages/fields/src/complex-widgets.test.tsx +++ b/packages/fields/src/complex-widgets.test.tsx @@ -192,11 +192,15 @@ describe('Complex & Relationship Widgets', () => { }); // Type in search. U+2026 now, and it comes from a PACK VALUE: - // objectui#4375 retired the `t('common.search') + '...'` - // concatenation this used to pin, so the placeholder is `table.search` + // objectui#4375 retired the `common.search` + '...' concatenation + // this used to pin, so the placeholder is `table.search` // — read through `useFieldTranslation`, whose no-provider defaults map // serves the `en` byte `Search…` in this test. It therefore moves with // the pack from here on, and is covered by objectui#3878's glyph pin. + // `common.search` itself is retired from the ten packs and from this + // package's defaults map (objectui#4392); naming it bare rather than + // spelling a t() call around it is what keeps that retirement's + // revival pin readable — see common-search-retired-4392.test.ts. await act(async () => { fireEvent.change(screen.getByPlaceholderText('Search…'), { target: { value: 'acme' }, diff --git a/packages/fields/src/widgets/useFieldTranslation.ts b/packages/fields/src/widgets/useFieldTranslation.ts index 8efe9030b..5b2b629e1 100644 --- a/packages/fields/src/widgets/useFieldTranslation.ts +++ b/packages/fields/src/widgets/useFieldTranslation.ts @@ -7,7 +7,6 @@ import { createSafeTranslation } from '@object-ui/i18n'; const FIELD_DEFAULTS: Record = { 'common.selectOption': 'Select an option', 'common.select': 'Select…', - 'common.search': 'Search', 'common.loading': 'Loading…', 'common.noResults': 'No results found', 'common.cancel': 'Cancel', diff --git a/packages/i18n/src/__tests__/common-search-retired-4392.test.ts b/packages/i18n/src/__tests__/common-search-retired-4392.test.ts new file mode 100644 index 000000000..b246113ac --- /dev/null +++ b/packages/i18n/src/__tests__/common-search-retired-4392.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `common.search` is retired from all ten locale packs, and must stay that way + * (objectui#4392). + * + * ## What was removed and why + * + * One key — `common.search` (`Search`, no ellipsis) — in each of the ten packs. + * Its only consumer was `LookupField`, which built its dialog placeholder by + * concatenating `common.search` with three ASCII full stops. objectui#4375 / + * PR #4391 retired that concatenation: the placeholder is now the reused + * `table.search` pack value (`Search…`, one U+2026 glyph), which is what let + * objectui#3878's glyph pin cover it. That left `common.search` with no reader + * anywhere in the repo, in any package or app. + * + * The ruling on objectui#4392 was RETIRE rather than keep-as-vocabulary: nothing + * pins a dormant key's meaning, so its next reader inherits an unreviewed + * contract, and a dormant key is exactly where a future author reaches first and + * re-creates a second dialect alongside `table.search`. Same reasoning as the + * objectui#4328 dead-surface family. + * + * ## Why this pin is NEGATIVE, and why it is needed at all + * + * Every i18n gate in this repo runs **call site → key**, never key → call site + * (the mechanism objectui#4145 wrote down for `report.editor.*`, unchanged): + * + * - `scripts/check-i18n-call-site-keys.mjs` asks whether each call site's key + * resolves in `en`. A key with no call site is never visited. + * - `all-locales-key-parity.test.ts` compares the ten packs' key SETS to each + * other. One dead key present in all ten packs is exactly what it wants. + * - `scripts/check-i18n-en-drift.mjs` only fires when an `en` value CHANGES. + * This value was static. + * + * So a single key can return to all ten packs with every gate green. Restoring + * `common.search` to any pack must go red here. + * + * Reverse-verified: restoring the key to ONE pack turns two independent gates + * red — this pin (naming the pack) and `all-locales-key-parity`'s "en defines + * every key the other packs define"; restoring it to all ten leaves parity green + * and only this pin red, which is the case parity structurally cannot see. + * + * ## What this file does NOT claim + * + * `common` itself is a live namespace with ~45 keys and many readers. Only the + * one leaf went. The sibling assertions below exist so a green here cannot be + * bought by deleting the neighbourhood — in particular `common.select`, minted + * one commit earlier by objectui#4386 / PR #4397 and a different key entirely. + */ +import { describe, it, expect } from 'vitest'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { builtInLocales } from '../locales/index'; + +// Derived from the map rather than left as `string[]`: `Object.keys` erases +// which keys it enumerated, so `builtInLocales[lang]` below would be an +// implicit-`any` index into a `const` map (TS7053). Same convention as +// `report-editor-retired-4145.test.ts` next door. +type LocaleCode = keyof typeof builtInLocales; +const LANGS = Object.keys(builtInLocales) as LocaleCode[]; + +const at = (pack: unknown, path: string): unknown => + path.split('.').reduce((n, k) => (n as Record | undefined)?.[k], pack); + +/** The retired leaf, named rather than counted. */ +const RETIRED = 'search'; + +/** + * The keys the deletion swept AROUND, in the packs' own order. `create` and + * `filter` are the immediate neighbours of the removed line; `select` is the + * near-homograph the dispatch for this retirement warned about. + */ +const SURVIVING_NEIGHBOURS = ['create', 'filter', 'select'] as const; + +/** This file names the retired key in call shape, so it excludes itself below. */ +const SELF = fileURLToPath(import.meta.url); + +describe('`common.search` is retired from the ten packs (objectui#4392)', () => { + it('covers all ten packs and a live `common` root (guards the loops from emptying)', () => { + // Guards the premise the rest of the file rests on. A pin that iterates an + // empty pack list, or asserts absence inside a namespace that itself + // vanished, is green for the wrong reason. + expect(LANGS).toHaveLength(10); + for (const lang of LANGS) { + const common = at(builtInLocales[lang], 'common'); + expect(common, `${lang} lost the common root`).toBeDefined(); + expect(Object.keys(common as Record).length, lang).toBeGreaterThan(30); + } + }); + + it('no pack defines `common.search`, in any of the ten packs', () => { + const revived: string[] = []; + for (const lang of LANGS) { + if (at(builtInLocales[lang], `common.${RETIRED}`) !== undefined) { + revived.push(`${lang} :: common.${RETIRED}`); + } + } + // Named, not counted: the failure message has to say WHICH pack, because a + // half-reverted retirement is repaired pack by pack. + expect( + revived, + '`common.search` is back in a locale pack. It is retired (objectui#4392): ' + + 'the search placeholder every widget uses is `table.search`, which is ' + + 'already translated in all ten packs and carries the U+2026 glyph ' + + 'objectui#3878 pinned. Read that key instead of restoring this one.', + ).toEqual([]); + }); + + it('the deletion swept around its neighbours — they are real translations in all ten packs', () => { + // A green "the key is gone" is worth nothing if the line above or below it + // went too. `select` is here by name because it is one letter of intent away + // from the retired key and was minted the commit before this retirement. + for (const lang of LANGS) { + for (const key of SURVIVING_NEIGHBOURS) { + const value = at(builtInLocales[lang], `common.${key}`); + expect(typeof value, `${lang} :: common.${key}`).toBe('string'); + expect((value as string).length, `${lang} :: common.${key}`).toBeGreaterThan(0); + } + } + // A sample across writing systems, so "all ten packs kept the keys" cannot + // be satisfied by ten copies of the English string. + expect(at(builtInLocales.en, 'common.select')).toBe('Select…'); + expect(at(builtInLocales.zh, 'common.select')).toBe('选择…'); + expect(at(builtInLocales.ru, 'common.select')).toBe('Выбрать…'); + expect(at(builtInLocales.ar, 'common.select')).toBe('اختر…'); + expect(at(builtInLocales.en, 'common.filter')).toBe('Filter'); + expect(at(builtInLocales.zh, 'common.filter')).toBe('筛选'); + }); + + it('no package or app reads `common.search`, and no fallback table re-declares it', () => { + // Repo-wide rather than root-scoped: `common.*` is the shared vocabulary + // namespace, so unlike a feature namespace there is no short list of trees + // that could plausibly reach for it. + // + // Why pin the READER when `check:i18n-keys` already fails on a `t()` key no + // pack defines: that gate reports it as "key missing from `en`", and the + // obvious repair for a missing key is to backfill it — which is exactly the + // move that revives the retired key. This assertion is where the reason + // lives, so the next author reads "this key is retired" instead of "the + // packs are behind". + // + // Two offence shapes, both deliberately narrow so that PROSE naming the key + // stays legal (this file's own header does it, and so does the note in + // `packages/fields/src/complex-widgets.test.tsx`): + // + // 1. a call-shaped read — an identifier, `(`, then the quoted key. Bare + // `common.search` in a comment has no call around it and is fine. + // 2. a no-provider fallback table entry — the quoted key in KEY position, + // i.e. followed by `:`. That is the shape the dormant copy in + // `useFieldTranslation.ts` had, and no pack gate can see it: those + // tables are plain `Record` literals in consuming + // packages, read only when no `LocalizationProvider` is mounted. + // + // Comments are NOT stripped before this runs — same trade objectui#4145 + // made: a comment stripper mishandling `//` inside a string literal drops + // real code from the scan, and a false negative is the one direction a + // revival gate must not fail in. + const CALL_SHAPED = /\b[A-Za-z_$][\w$]*\(\s*['"`]common\.search['"`]/; + const TABLE_ENTRY = /['"`]common\.search['"`]\s*:/; + + const roots = ['packages', 'apps']; + const offenders: string[] = []; + + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist') continue; + walk(full); + continue; + } + if (!/\.tsx?$/.test(entry.name)) continue; + // This file spells the key in call shape (`at(pack, 'common.search')`) + // to assert its absence, and would otherwise score itself as the + // offence. Resolved from `import.meta.url` so a rename cannot silently + // turn the exclusion into a scan of nothing. + if (full === SELF) continue; + const src = readFileSync(full, 'utf8'); + if (CALL_SHAPED.test(src)) offenders.push(`${full} :: reads common.search`); + if (TABLE_ENTRY.test(src)) offenders.push(`${full} :: declares common.search`); + } + }; + + for (const root of roots) { + const abs = join(process.cwd(), root); + expect(existsSync(abs), `scan root missing: ${abs}`).toBe(true); + walk(abs); + } + + expect( + offenders, + '`common.search` is being read or re-declared. The key is retired ' + + '(objectui#4392) — do NOT backfill it into the packs. A search ' + + 'placeholder should read `table.search`; a fallback table should ' + + 'declare `table.search` for the same reason. If this hit is ' + + 'DOCUMENTATION, name the key on its own rather than spelling a t() ' + + 'call around it; comments are intentionally not stripped here.', + ).toEqual([]); + }); + + it('the dynamic `common.${…}` reader cannot reach the retired key', () => { + // The one reader that composes a `common.*` key at runtime is + // `useChatbotLabel`, whose parameter is a two-member union type. A type + // union is not visible to the scan above, so it is asserted here by shape + // rather than by path: every file that composes a dynamic `common.*` key + // must not carry a bare `search` string token that could widen into it. + // + // Path-free on purpose — `packages/plugin-chatbot` has in-flight owners + // (objectui#4383), and a pin naming its files would go red on an unrelated + // rename instead of on the thing it guards. + const DYNAMIC = /\b[A-Za-z_$][\w$]*\(\s*`common\.\$\{/; + const BARE_SEARCH_TOKEN = /(['"`])search\1/; + + const risky: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist') continue; + walk(full); + continue; + } + if (!/\.tsx?$/.test(entry.name)) continue; + if (full === SELF) continue; + const src = readFileSync(full, 'utf8'); + if (DYNAMIC.test(src) && BARE_SEARCH_TOKEN.test(src)) { + risky.push(`${full} :: dynamic common.\${…} alongside a 'search' key token`); + } + } + }; + for (const root of ['packages', 'apps']) walk(join(process.cwd(), root)); + + expect( + risky, + 'A file composes `common.${…}` at runtime AND names a bare `search` key ' + + 'token, so it may resolve the retired `common.search` (objectui#4392). ' + + 'Point that slot at `table.search` instead.', + ).toEqual([]); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index e7518cdd2..319986783 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -76,7 +76,6 @@ const ar = { delete: "حذف", edit: "تعديل", create: "إنشاء", - search: "بحث", filter: "تصفية", reset: "إعادة تعيين", confirm: "تأكيد", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index caf58e6f4..13f18cd3b 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -72,7 +72,6 @@ const de = { delete: "Löschen", edit: "Bearbeiten", create: "Erstellen", - search: "Suchen", filter: "Filtern", reset: "Zurücksetzen", confirm: "Bestätigen", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index a5da98c8f..0d3a60f3e 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -84,7 +84,6 @@ const en = { delete: 'Delete', edit: 'Edit', create: 'Create', - search: 'Search', filter: 'Filter', reset: 'Reset', confirm: 'Confirm', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index c918fc656..8039bfd4d 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -71,7 +71,6 @@ const es = { delete: "Eliminar", edit: "Editar", create: "Crear", - search: "Buscar", filter: "Filtrar", reset: "Restablecer", confirm: "Confirmar", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index d40488b25..7ea6cc0e2 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -72,7 +72,6 @@ const fr = { delete: "Supprimer", edit: "Modifier", create: "Créer", - search: "Rechercher", filter: "Filtrer", reset: "Réinitialiser", confirm: "Confirmer", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 0b5fb1293..fd0ec6b30 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -72,7 +72,6 @@ const ja = { delete: "削除", edit: "編集", create: "作成", - search: "検索", filter: "フィルター", reset: "リセット", confirm: "確認", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 02a1a953c..2b531f364 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -72,7 +72,6 @@ const ko = { delete: "삭제", edit: "편집", create: "생성", - search: "검색", filter: "필터", reset: "초기화", confirm: "확인", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 5752b61c0..2693426a6 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -71,7 +71,6 @@ const pt = { delete: "Excluir", edit: "Editar", create: "Criar", - search: "Pesquisar", filter: "Filtrar", reset: "Redefinir", confirm: "Confirmar", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index f8b8c5296..cee831bb1 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -78,7 +78,6 @@ const ru = { delete: "Удалить", edit: "Редактировать", create: "Создать", - search: "Поиск", filter: "Фильтр", reset: "Сбросить", confirm: "Подтвердить", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index acd976195..36f07472a 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -77,7 +77,6 @@ const zh = { delete: '删除', edit: '编辑', create: '新建', - search: '搜索', filter: '筛选', reset: '重置', confirm: '确认',