diff --git a/.changeset/30285-i18n-walk-in-lint.md b/.changeset/30285-i18n-walk-in-lint.md new file mode 100644 index 00000000000..c75945cef15 --- /dev/null +++ b/.changeset/30285-i18n-walk-in-lint.md @@ -0,0 +1,16 @@ +--- +'@objectstack/lint': patch +'@objectstack/cli': patch +'@objectstack/spec': patch +--- + +refactor(lint): the i18n translatable-key walk moves from the CLI into `@objectstack/lint` + +Clause-②: no +Metadata-protocol: none + +`collectExpectedEntries` — the one definition of which translation keys a stack can carry, each with the source text it translates — now lives in `@objectstack/lint` (`src/i18n-walk.ts`) and is exported from the package root with `authorWarnedTranslationGroups`, `humanizeFieldPath` and the `ExpectedEntry` / `ExpectedEntryOptions` types. It moved so a browser can run the same walk the CLI runs: Studio's translation editor builds its source-to-translation table from it. `os i18n extract` and `os lint`'s translation coverage keep reading it unchanged, through the CLI's extractor module, which keeps bundle assembly and serialization and re-exports the walk. + +- No key, no skeleton and no coverage verdict changes: the walk's code moved as is. +- In a browser the walk cannot read the liveness ledger from disk, so its default leaves no translation group out. That is the same set the CLI leaves out today, because the ledger warns on no translation group; a browser caller that must honour a warned group passes `warnedGroups`. +- `@objectstack/spec`: the `action` and `view` liveness ledgers cite the walk at its new path. diff --git a/packages/cli/src/commands/i18n/extract.ts b/packages/cli/src/commands/i18n/extract.ts index 25e99a6a5bf..3ea4beed200 100644 --- a/packages/cli/src/commands/i18n/extract.ts +++ b/packages/cli/src/commands/i18n/extract.ts @@ -30,7 +30,7 @@ import { countTranslationLeaves, type FillStrategy, type TranslationModuleKind, -} from '../../utils/i18n-extract.js'; +} from '../../utils/i18n-bundle-extract.js'; import { authoringRuleUnionStack } from '../../utils/stack-collections.js'; const FILL_STRATEGIES: FillStrategy[] = ['empty', 'default', 'todo']; diff --git a/packages/cli/src/utils/i18n-bundle-extract.ts b/packages/cli/src/utils/i18n-bundle-extract.ts new file mode 100644 index 00000000000..5f80500c4e3 --- /dev/null +++ b/packages/cli/src/utils/i18n-bundle-extract.ts @@ -0,0 +1,631 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * I18n Extractor + * + * Produces ready-to-edit `TranslationData` skeletons for every requested + * locale from a normalized stack config, pre-populated with the source labels + * from the schema for the default locale, and serializes them as the committed + * `*.generated.ts` modules `os i18n extract` writes. + * + * What is translatable at all is not decided here: the walk, + * `collectExpectedEntries`, lives in `@objectstack/lint` + * (`packages/lint/src/i18n-walk.ts`), so the CLI, the coverage gate and the + * Studio translation editor in a browser all read one walk. Its public names + * are re-exported below, so the CLI keeps one import path for both halves. + */ + +import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; +import { collectExpectedEntries, type ExpectedEntry, type ExpectedEntryOptions } from '@objectstack/lint'; +import { collectFilledFromHashes } from '@objectstack/platform-objects/apps'; + +export { authorWarnedTranslationGroups, collectExpectedEntries, humanizeFieldPath } from '@objectstack/lint'; +export type { ExpectedEntry, ExpectedEntryOptions } from '@objectstack/lint'; + +// ─── Public types ────────────────────────────────────────────────────── + +export type FillStrategy = 'empty' | 'default' | 'todo'; + +export interface ExtractOptions extends ExpectedEntryOptions { + /** Default locale (filled with source values). Defaults to `'en'`. */ + defaultLocale?: string; + /** Locales to emit. Defaults to `[defaultLocale]`. */ + locales?: string[]; + /** + * How to populate values for non-default locales: + * - `'empty'` → empty string (default) + * - `'default'` → copy source value verbatim + * - `'todo'` → source value with a `[TODO] ` prefix + */ + fill?: FillStrategy; + /** + * Regex filter applied against `objectName` / `appName` / `dashboard` / view + * / action identifiers. When provided, only matching entries are emitted. + */ + filter?: RegExp; + /** + * When true, entries that already exist in any of the stack's + * `translations` bundles for a given locale are *omitted* for that locale. + * This makes extract idempotent — re-running only fills the gaps. + */ + mergeExisting?: boolean; + /** + * The `.source-hashes.generated.ts` tables already committed beside + * the bundles, keyed by locale. + * + * This is the mechanism's ONLY memory (commit 09b4f4e4e, ruling #12069 Option A): a leaf that + * is a byte copy of a source revision keeps its record across runs, which is + * what makes the drift detectable after the source moves. Passing nothing + * makes the run behave like a first extract — every record is re-derived from + * the tree, so leaves that already drifted stay legacy-trusted rather than + * being reported. + */ + previousSourceHashes?: Record>; +} + +export interface ExtractResult { + /** Locale → TranslationData skeleton (only the entries we emitted). */ + bundles: Record; + /** + * Locale → the number of leaves in `bundles[locale]`: the whole skeleton + * built for that locale, every section of it. + * + * ⛔ NOT the number of keys any one emitted FILE holds. Which sections of the + * skeleton become committed modules is the caller's decision — `os i18n + * extract` makes it from `--objects-only` and `--metadata-forms` — so under + * the default `--objects-only` this number is far larger than the module the + * command writes: the registry-driven `metadataForms` baseline alone is ~773 + * keys against a one-object stack's 2 (#16121). A caller reporting a count + * for a file takes it off that file's own payload + * ({@link translationModulePayload}), never off this field. + * + * Counted off the built tree, never off the walk that built it. The walk + * counter this replaces could not disagree with anything: it incremented once + * per entry, unconditionally, so it equalled {@link ExtractResult.totalExpected} + * for every locale on every config and no observation could have shown it + * wrong. A leaf count of the bundle CAN fail — it moves the moment `setDeep` + * collapses two entries onto one path. + */ + counts: Record; + /** Total expected entries before per-locale merge filtering. */ + totalExpected: number; + /** + * Per translated locale, the digest of the source revision each GENERATED + * leaf is still a byte copy of — the content of + * `.source-hashes.generated.ts`. + * + * Computed by `collectFilledFromHashes` in + * `@objectstack/platform-objects/apps`, the module maintainer ruling #8765 + * Option B put the mechanism in; the extractor supplies the tree, the + * previous records and THE SECTIONS THIS RUN BUILT, and owns none of the + * rule. The default locale gets no entry: it is the source, not a copy of + * one. + * + * Covers every section the run generated, not just the two a default + * `--objects-only` run happens to emit (#16872). A `--no-objects-only` run + * fills `apps` / `dashboards` / `pages` from the source too, and before this + * those leaves were recorded by nobody and judged by nobody. + */ + sourceHashes: Record>; +} + +// ─── Filter + bundle assembly ────────────────────────────────────────── + +function passesFilter(entry: ExpectedEntry, filter?: RegExp): boolean { + if (!filter) return true; + if (entry.objectName && filter.test(entry.objectName)) return true; + if (entry.appName && filter.test(entry.appName)) return true; + if (entry.metadataType && filter.test(entry.metadataType)) return true; + if (entry.flowName && filter.test(entry.flowName)) return true; + // Allow matching against the joined path so users can target e.g. ^dashboards\.system_ + return filter.test(entry.path.join('.')); +} + +function setDeep(data: TranslationData, path: string[], value: string): void { + let cur: any = data; + for (let i = 0; i < path.length - 1; i++) { + const seg = path[i]; + if (typeof cur[seg] !== 'object' || cur[seg] === null) cur[seg] = {}; + cur = cur[seg]; + } + cur[path[path.length - 1]] = value; +} + +function lookupDeep(data: TranslationData | undefined, path: string[]): string | undefined { + let cur: any = data; + for (const seg of path) { + if (!cur || typeof cur !== 'object') return undefined; + cur = cur[seg]; + } + return typeof cur === 'string' && cur.length > 0 ? cur : undefined; +} + +function mergeBundles(bundles: TranslationBundle[]): Record { + const out: Record = {}; + for (const bundle of bundles) { + if (!bundle || typeof bundle !== 'object') continue; + for (const [locale, data] of Object.entries(bundle)) { + if (!data || typeof data !== 'object') continue; + out[locale] = deepMerge(out[locale] ?? {}, data as TranslationData); + } + } + return out; +} + +function deepMerge>(target: T, source: T): T { + for (const [k, v] of Object.entries(source)) { + if (v && typeof v === 'object' && !Array.isArray(v)) { + target[k as keyof T] = deepMerge(((target as any)[k] ?? {}) as any, v as any); + } else { + (target as any)[k] = v; + } + } + return target; +} + +/** + * Build per-locale skeleton bundles from a normalized stack config. + */ +export function extractTranslations(config: any, opts: ExtractOptions = {}): ExtractResult { + const defaultLocale = opts.defaultLocale ?? 'en'; + const locales = opts.locales && opts.locales.length > 0 + ? Array.from(new Set([defaultLocale, ...opts.locales])) + : [defaultLocale]; + const fill: FillStrategy = opts.fill ?? 'empty'; + + // Seed-less entries exist only so the coverage gate can spot a bundle that + // authors a key the metadata never writes inline — there is nothing to + // scaffold from, so they never reach a generated skeleton. + // `warnedGroups` rides through: a group the ledger warns authors for + // authoring must not be scaffolded either. Scaffolding it would hand the + // author a skeleton whose every filled-in key draws a warning — the same + // collision the coverage gate has, arriving by a different door (#11624). + const allEntries = collectExpectedEntries(config, { warnedGroups: opts.warnedGroups }) + .filter((e) => e.sourceValue !== undefined); + const entries = allEntries.filter((e) => passesFilter(e, opts.filter)); + + const existingBundles: TranslationBundle[] = Array.isArray(config?.translations) ? config.translations : []; + const existing = mergeBundles(existingBundles); + + const bundles: Record = {}; + const counts: Record = {}; + + for (const locale of locales) { + const data: TranslationData = {}; + for (const entry of entries) { + // Guaranteed by the seed-less filter above; narrows for the branches below. + const seed = entry.sourceValue ?? ''; + let value: string | undefined; + // If a translation already exists for this locale, carry it through + // verbatim so the generated file remains a complete, self-contained + // bundle (not just the missing-key delta). Set --no-merge to skip + // baselines entirely. + // + // The default locale is deliberately NOT merged (#8543): it is a copy of + // the source, not a translation, so "never overwrite an existing entry" + // protects the wrong thing there — an author edits a field description, + // the regeneration keeps the stale entry, and the served text drifts from + // the source silently while the drift gate reports OK (measured at 53 + // stale entries across 6 packages when this branch ran for every locale). + // The seed IS the source text for the default locale (line below), so it + // always wins; translated locales keep merge semantics exactly as before. + if (opts.mergeExisting !== false && locale !== defaultLocale) { + const existingValue = lookupDeep(existing[locale], entry.path); + if (existingValue !== undefined && existingValue !== '') { + value = String(existingValue); + } + } + if (value === undefined) { + if (locale === defaultLocale) { + value = seed; + } else if (fill === 'default') { + value = seed; + } else if (fill === 'todo') { + value = `[TODO] ${seed}`; + } else { + value = ''; + } + } + setDeep(data, entry.path, value); + } + bundles[locale] = data; + // Off the tree that was just built, never off the walk that built it — see + // {@link ExtractResult.counts} (#16121). + counts[locale] = countTranslationLeaves(data); + } + + const sourceHashes: Record> = {}; + const sourceBundle = bundles[defaultLocale]; + /** + * The population the provenance rule walks is THIS RUN'S — the sections it + * actually built — not `GENERATED_SECTIONS`, the default a caller that does + * not know inherits (#16872). + * + * Every leaf in `bundles[locale]` got there through this extractor, which is + * the discriminator the rule is written against: `--fill=default` copies it + * from the source. Under the default `--objects-only` the sections built are + * `objects` and `metadataForms`, so this is byte-identical to the previous + * behaviour; under `--no-objects-only` it is the difference between a filled + * `apps` leaf having provenance and having none. + * + * Read off the BUILT tree rather than off the config, for the reason #16121 + * records about counts: a number or a name derived independently of the bytes + * it describes is a number nothing can check. WHICH of these sections becomes + * a committed bundle stays a separate, later decision that + * `narrowToCommittedSections` already derives from the emitted module list — + * this layer supplies the tree and owns none of the rule. + */ + for (const locale of locales) { + if (locale === defaultLocale) continue; + sourceHashes[locale] = collectFilledFromHashes( + bundles[locale], + sourceBundle, + opts.previousSourceHashes?.[locale], + Object.keys(bundles[locale] ?? {}), + ); + } + + return { bundles, counts, totalExpected: entries.length, sourceHashes }; +} + +// ─── Serialization ───────────────────────────────────────────────────── + +/** + * One locale's translations MINUS the registry-driven `metadataForms` + * baseline — everything the stack itself authors (#14894). + * + * The baseline is not part of any stack's authored surface: it is derived from + * the platform's metadata-form registry, is identical for every stack, and has + * its own module (`.metadata-forms.generated.ts`) under its own flag + * (`--metadata-forms`). Folding it into the module beside them gave it a SECOND + * home, and the two homes then disagreed about who governed it — see + * {@link renderTranslationModule}. + */ +export function stackAuthoredSubtree(data: TranslationData): Omit { + const { metadataForms: _registryBaseline, ...authored } = data; + return authored; +} + +/** Sub-tree selector shared by {@link renderTranslationModule} and its callers. */ +export type TranslationModuleKind = 'objects' | 'metadataForms' | 'stack'; + +/** + * The sub-tree a module of the given {@link TranslationModuleKind} holds — the + * ONE place that decides what each emitted module contains. + * + * Exported so that a count reported for a module is taken off the SAME tree the + * module is rendered from. #16121 was the other arrangement: the command + * reported a number derived independently of the bytes it wrote, so nothing + * could compare the two and the printed count was wrong by two orders of + * magnitude with every gate green. A caller now counts + * `countTranslationLeaves(translationModulePayload(data, kind))` and renders + * `renderTranslationModule(data, { kind })` — one selector, so the number and + * the bytes agree by construction, for a `kind` added later as much as for + * these three. + */ +export function translationModulePayload( + data: TranslationData, + kind: TranslationModuleKind, +): + | NonNullable + | NonNullable + | Omit { + return kind === 'metadataForms' + ? (data.metadataForms ?? {}) + : kind === 'objects' + ? (data.objects ?? {}) + : stackAuthoredSubtree(data); +} + +/** + * The `TranslationData` SECTIONS a module of the given kind COVERS — the first + * dotted segment its leaves carry, which is the identity + * {@link narrowToCommittedSections} narrows a provenance table by. + * + * Beside {@link translationModulePayload} and switched on the same `kind`, so + * "what a module holds" and "which sections it commits" are ONE decision. The + * caller was two literals — `committed.push('objects')` and + * `committed.push('metadataForms')`, chosen by which modules were emitted — and + * a literal cannot follow the payload: under `kind: 'stack'` the module holds + * every group the stack authors (`objects`, `apps`, `dashboards`, ...) while the + * caller named exactly one of them. Replacing that list with a longer list of + * literals only moves the day it goes wrong to the next group added. + * + * ⚠️ The sections are NOT simply the payload's own top-level keys, and reading + * them off it would be WRONG for two of the three kinds. `'objects'` and + * `'metadataForms'` select the sub-tree ROOTED AT one section, so those keys are + * object and form names (`kpi_metric`) — deriving the section list from them + * would commit `['kpi_metric']` and narrow away every `objects.*` record, which + * is the one path this repository's single `--source-hashes` config is on. + * `'stack'` selects a `TranslationData`-shaped subtree, so THERE the top-level + * keys are sections — every group the stack authors, today's and any added + * later, with nothing here to update. + * + * The switch is exhaustive on purpose: a fourth kind that selects one section + * needs no edit (`[kind]` already names it), and a fourth AGGREGATE kind fails + * to compile here rather than silently committing its own name as a section. + */ +export function translationModuleSections( + data: TranslationData, + kind: TranslationModuleKind, +): string[] { + switch (kind) { + case 'stack': + return Object.keys(stackAuthoredSubtree(data)); + case 'objects': + case 'metadataForms': + // The selector's own name IS the section it roots at. + return [kind]; + default: { + const exhaustive: never = kind; + return exhaustive; + } + } +} + +/** + * String leaves under a nested translation tree. + * + * The structural measure every key count in this mechanism is taken with — the + * extractor's per-locale {@link ExtractResult.counts}, and each number + * `os i18n extract` prints beside a module. One implementation so that two + * counts of the same tree can never be two different numbers. + */ +export function countTranslationLeaves(node: unknown): number { + if (!node || typeof node !== 'object') return 0; + let n = 0; + for (const value of Object.values(node as Record)) { + if (typeof value === 'string') n += 1; + else if (value && typeof value === 'object') n += countTranslationLeaves(value); + } + return n; +} + +/** + * Render a TranslationData skeleton as a TypeScript module body. + * + * The module exports a single named const (``) typed against + * the chosen sub-tree of `TranslationData`: + * + * kind: 'objects' → `NonNullable` + * kind: 'metadataForms' → `NonNullable` + * kind: 'stack' → `Omit` + * + * ⚠️ The three are NOT three disjoint cells, and calling them a partition was + * imprecise enough to correct: `'objects'` is a SUB-SELECTION of `'stack'`, not + * a sibling of it. The invariant that actually holds — and the one this + * function exists to keep — is about a PAIR: whichever of `'objects'` / + * `'stack'` a run picks for the module it writes, that module and the + * `'metadataForms'` companion beside it are disjoint, and under `'stack'` the + * two together are the whole of what the extractor built. Measured on the + * fixture below: 3 + 773 = 776, which is the extractor's own count for that + * run — none dropped, none duplicated. + * + * `'stack'` was called `'full'` and rendered the whole `TranslationData`, which + * broke exactly that pairing — `metadataForms` landed in the stack module AND + * in its own companion. + * + * That is the #14894 defect, and it had two user-visible halves. Both were + * driven on a `defaultLocale: 'zh-CN'` stack (one object, one app) before this + * function changed: + * + * • `--no-metadata-forms` stopped suppressing the baseline the moment + * `--no-objects-only` was passed. The flag gated only the companion, while + * `'full'` inlined the same keys next door: `zh-CN.objects.generated.ts` + * came out holding 773 metadata-form leaves under an explicit + * `--no-metadata-forms`, against 2 object leaves and 1 app leaf. Those 773 + * were ENGLISH — the default locale is filled from the source labels, and + * the registry authors them in English — so a non-English default locale + * shipped the platform's English Studio baseline inside its own bundle. + * • With `--metadata-forms` left ON, the same run emitted those 773 keys + * TWICE, once in each module. + * + * `--objects-only` and `--metadata-forms` are documented as independent, and + * they are: the first picks the stack module's sub-tree (`objects` alone, or + * everything the stack authors), the second decides whether the baseline is + * emitted at all. Neither has to win over the other, and this change invents no + * precedence between them — the overlap was in the emitter, never in the two + * meanings. + * + * `objectsOnly: true` (default) is a legacy alias for `kind: 'objects'`, and + * `objectsOnly: false` for `kind: 'stack'`. + */ +export function renderTranslationModule( + data: TranslationData, + options: { + locale: string; + exportName?: string; + /** Legacy: when true, emit only the `objects` sub-tree (typed accordingly). */ + objectsOnly?: boolean; + /** Explicit sub-tree selector. Overrides `objectsOnly` when provided. */ + kind?: 'objects' | 'metadataForms' | 'stack'; + /** Header comment lines. */ + header?: string[]; + }, +): string { + const kind: TranslationModuleKind = + options.kind ?? (options.objectsOnly === false ? 'stack' : 'objects'); + const defaultExport = + kind === 'metadataForms' + ? `${camelize(options.locale)}MetadataForms` + : kind === 'stack' + ? `${camelize(options.locale)}Translations` + : `${camelize(options.locale)}Objects`; + const exportName = options.exportName ?? defaultExport; + const payload = translationModulePayload(data, kind); + const typeSig = + kind === 'metadataForms' + ? "NonNullable" + : kind === 'objects' + ? "NonNullable" + : "Omit"; + const header = options.header ?? [ + `Auto-generated by 'os i18n extract' for locale '${options.locale}'.`, + 'Edit translations in place; re-run extract (with --merge) to fill new gaps.', + 'Do not hand-edit the structure — only the leaf string values.', + 'Merge only fills gaps: correcting a source label/description does not', + 'push the correction into a leaf here that already holds a translation —', + 'a present-but-stale string is not a gap, so it is left as-is. Re-translate', + 'it by hand when its source changes; nothing here or in `os i18n check`', + 'tells a leaf a translator updated on purpose from one nobody has looked', + 'at since the source moved.', + ]; + + const lines: string[] = []; + lines.push('// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.'); + lines.push(''); + lines.push('/**'); + for (const h of header) lines.push(` * ${h}`); + lines.push(' */'); + lines.push(''); + lines.push("import type { TranslationData } from '@objectstack/spec/system';"); + lines.push(''); + lines.push(`export const ${exportName}: ${typeSig} = ${stringifyTs(payload, 0)};`); + lines.push(''); + return lines.join('\n'); +} + +/** + * Render one locale's generated source-hash table as a TypeScript module body — + * the `.source-hashes.generated.ts` companion. + * + * Deliberately types the export STRUCTURALLY (`Readonly>`) instead of importing `SourceHashes`. The companion is written into + * whichever package owns the bundles, and only one of those packages can spell + * the type with a relative import; an import path guessed per package is a + * portability bug waiting for the second package to use this. The structural + * type is what `SourceHashes` is defined as, so nothing is lost. + * + * Keys are emitted sorted, and every key is quoted (they are dotted paths, so + * `formatKey` would quote them anyway). Both are load-bearing for `--check`: + * the comparison is byte-for-byte, so a table that reordered with the walk + * would fail on a tree that is in fact in sync. + */ +export function renderSourceHashModule( + hashes: Record, + options: { locale: string; exportName?: string }, +): string { + const exportName = options.exportName ?? `${camelize(options.locale)}GeneratedSourceHashes`; + const keys = Object.keys(hashes).sort(); + const lines: string[] = []; + lines.push('// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.'); + lines.push(''); + lines.push('/**'); + lines.push(` * Auto-generated by 'os i18n extract' for locale '${options.locale}'. Do not hand-edit.`); + lines.push(' *'); + lines.push(" * Each entry is the digest of the SOURCE REVISION that this locale's leaf at"); + lines.push(' * that path is still a byte copy of — provenance for the generated half of the'); + lines.push(' * bundles (commit 09b4f4e4e): a leaf whose digest no longer matches its source is stale and serves the source text instead.'); + lines.push(' *'); + lines.push(' * An entry exists only while the leaf IS such a copy. Re-translate the leaf in'); + lines.push(' * `.objects.generated.ts` and the next extract drops its entry by'); + lines.push(' * itself — the table makes no claim about text a translator wrote. A path with'); + lines.push(' * no entry is LEGACY-TRUSTED and never reported stale.'); + lines.push(' *'); + lines.push(' * ⚠️ Do not "fix" a staleness report by editing this file. Refreshing a digest'); + lines.push(' * records that the current text was copied from the current source, which is'); + lines.push(' * the false claim the mechanism exists to detect. Fix the TRANSLATION.'); + lines.push(' */'); + lines.push(''); + lines.push(`export const ${exportName}: Readonly> = {`); + for (const key of keys) lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(hashes[key])},`); + lines.push('};'); + lines.push(''); + return lines.join('\n'); +} + +/** + * Narrow a provenance table to the generated sections a run actually COMMITS + * (#12559). + * + * {@link extractTranslations} computes the table over every generated section it + * built — `objects` and `metadataForms` both — because the rule that fills it + * (`collectFilledFromHashes`) is a statement about generated leaves, not about + * files. Which of those sections becomes a committed bundle is the command + * layer's decision, and the two must agree: a record describes the leaf sitting + * in a bundle beside it, so a record for a section this package does not commit + * describes nothing that exists there. + * + * The mismatch is measured, not hypothetical. A package that owns only its own + * objects passes `--no-metadata-forms`, and the emitter's own note says why: + * "without it, `--check` demands a baseline copy the package deliberately does + * not commit". Its `metadataForms` subtree is built anyway, and — having no + * entry in that package's merge baseline — arrives as a fresh `--fill=default` + * copy of `en`, so EVERY leaf of it satisfies `value === currentSource` and is + * recorded. Measured on `@objectstack/plugin-audit` while rolling the companion + * out in #12559: 763 records, of which **2** were its own objects and 761 were + * digests of the Studio metadata-form baseline `@objectstack/platform-objects` + * owns — unreadable in that package (no `metadataForms` bundle exists there for + * them to be about) and re-written in all three of its companions every time an + * unrelated `*.form.ts` in `packages/spec` moved. That is the cross-package + * coupling ADR-0029 D8 and each package's `bundle-ownership.test.ts` keep out of + * the committed bundles; the companion is not exempt from it. + * + * A section is named by a leaf path's FIRST dotted segment — the same identity + * `collectGeneratedLeaves` seeds its walk with, so the two cannot disagree about + * what section a path belongs to. Callers pass the sections they are committing; + * this function invents none, so a set that commits both (`platform-objects` is + * the one today) keeps every record it had. + */ +export function narrowToCommittedSections( + hashes: Record, + committedSections: Iterable, +): Record { + const committed = new Set(committedSections); + return Object.fromEntries( + Object.entries(hashes).filter(([leafPath]) => committed.has(leafPath.split('.', 1)[0])), + ); +} + +/** + * Read a committed `.source-hashes.generated.ts` back into a table. + * + * The module body is written by {@link renderSourceHashModule}, which quotes + * every key and every value, so the object literal is already valid JSON — the + * parse needs no TypeScript and no evaluation. A file that does not parse is a + * hard `undefined` (treated as "no previous records", i.e. everything + * legacy-trusted) rather than a guess: inventing records from a file we cannot + * read is how a mechanism starts asserting provenance it does not have. + */ +export function parseSourceHashModule(source: string): Record | undefined { + const marker = source.indexOf('export const'); + const open = marker < 0 ? -1 : source.indexOf('= {', marker); + if (open < 0) return undefined; + const literal = source.slice(open + 2).replace(/;\s*$/, ''); + try { + const parsed = JSON.parse(literal.replace(/,(\s*})/g, '$1')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; + for (const value of Object.values(parsed)) if (typeof value !== 'string') return undefined; + return parsed as Record; + } catch { + return undefined; + } +} + +function camelize(locale: string): string { + // 'zh-CN' → 'zhCN', 'ja-JP' → 'jaJP', 'es-ES' → 'esES' + return locale.replace(/-(.)/g, (_m, c) => c.toUpperCase()); +} + +function stringifyTs(value: unknown, indent: number): string { + const pad = ' '.repeat(indent); + const pad2 = ' '.repeat(indent + 1); + if (value === null) return 'null'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) { + if (value.length === 0) return '[]'; + return '[\n' + value.map((v) => pad2 + stringifyTs(v, indent + 1)).join(',\n') + `\n${pad}]`; + } + if (typeof value === 'object') { + const entries = Object.entries(value as Record); + if (entries.length === 0) return '{}'; + return '{\n' + entries.map(([k, v]) => `${pad2}${formatKey(k)}: ${stringifyTs(v, indent + 1)}`).join(',\n') + `\n${pad}}`; + } + return JSON.stringify(value); +} + +const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +function formatKey(key: string): string { + return IDENT_RE.test(key) ? key : JSON.stringify(key); +} diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index 42fb1d66635..dffa3317905 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -7,8 +7,8 @@ * actually attached to it, and reports the ones that are missing or empty. * * The expected set is **not** computed here. It comes from - * {@link collectExpectedEntries} in `i18n-extract.ts` — the same walker that - * scaffolds bundles for `os i18n extract`. That sharing is the point: this + * {@link collectExpectedEntries} in `@objectstack/lint` (`packages/lint/src/i18n-walk.ts`) + * — the same walker that scaffolds bundles for `os i18n extract`. That sharing is the point: this * detector used to keep its own parallel walk, and the two drifted until whole * declared surfaces were extractable but ungated — most visibly the action * labels declared *inline on an object* (`sys_approval_request`'s @@ -44,7 +44,7 @@ import { deepMerge } from '@objectstack/core'; import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; -import { collectExpectedEntries, type ExpectedEntry } from './i18n-extract.js'; +import { collectExpectedEntries, type ExpectedEntry } from './i18n-bundle-extract.js'; export type CoverageSeverity = 'error' | 'warning'; diff --git a/packages/cli/test/i18n-block-inline-title-walk.test.ts b/packages/cli/test/i18n-block-inline-title-walk.test.ts index 9aa4812fe5b..78b0480f9b0 100644 --- a/packages/cli/test/i18n-block-inline-title-walk.test.ts +++ b/packages/cli/test/i18n-block-inline-title-walk.test.ts @@ -13,7 +13,7 @@ // them in English. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { TranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-book-walk.test.ts b/packages/cli/test/i18n-book-walk.test.ts index d352c245df4..c1c6fe68e07 100644 --- a/packages/cli/test/i18n-book-walk.test.ts +++ b/packages/cli/test/i18n-book-walk.test.ts @@ -8,7 +8,7 @@ // measure a debt of zero while the sidebar shipped in the authoring language. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { TranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-bulk-action-coverage.test.ts b/packages/cli/test/i18n-bulk-action-coverage.test.ts index 85560f2a51c..b57e42cf909 100644 --- a/packages/cli/test/i18n-bulk-action-coverage.test.ts +++ b/packages/cli/test/i18n-bulk-action-coverage.test.ts @@ -19,7 +19,7 @@ // `helpText` here would scaffold keys `.strict()` rejects. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-bundle-extract-exports.pin.test.ts b/packages/cli/test/i18n-bundle-extract-exports.pin.test.ts new file mode 100644 index 00000000000..3533cd68041 --- /dev/null +++ b/packages/cli/test/i18n-bundle-extract-exports.pin.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The translatable-key walk moved to `@objectstack/lint` (`i18n-walk.ts`) so a + * browser can run it; the CLI's extractor module kept everything else and + * re-exports the walk. This pins that the module still offers exactly what + * `utils/i18n-extract.ts` offered before the move, and that the walk names are + * lint's own functions, not a second copy that could drift from them. + */ + +import { describe, it, expect } from 'vitest'; +import * as lint from '@objectstack/lint'; +import * as bundleExtract from '../src/utils/i18n-bundle-extract.js'; +import type { + ExpectedEntry, + ExpectedEntryOptions, + ExtractOptions, + ExtractResult, + FillStrategy, + TranslationModuleKind, +} from '../src/utils/i18n-bundle-extract.js'; + +/** The runtime exports of `packages/cli/src/utils/i18n-extract.ts` before the move. */ +const RUNTIME_EXPORTS_BEFORE_THE_MOVE = [ + 'authorWarnedTranslationGroups', + 'collectExpectedEntries', + 'countTranslationLeaves', + 'extractTranslations', + 'humanizeFieldPath', + 'narrowToCommittedSections', + 'parseSourceHashModule', + 'renderSourceHashModule', + 'renderTranslationModule', + 'stackAuthoredSubtree', + 'translationModulePayload', + 'translationModuleSections', +]; + +// The type exports, held by type-check: removing one fails `typecheck`. +type TypeExportsBeforeTheMove = [ + ExpectedEntry, + ExpectedEntryOptions, + ExtractOptions, + ExtractResult, + FillStrategy, + TranslationModuleKind, +]; + +describe('the CLI extractor module after the walk moved to @objectstack/lint', () => { + it('exports exactly the runtime names it exported before', () => { + expect(Object.keys(bundleExtract).sort()).toEqual(RUNTIME_EXPORTS_BEFORE_THE_MOVE); + }); + + it("re-exports lint's walk functions themselves", () => { + expect(bundleExtract.collectExpectedEntries).toBe(lint.collectExpectedEntries); + expect(bundleExtract.authorWarnedTranslationGroups).toBe(lint.authorWarnedTranslationGroups); + expect(bundleExtract.humanizeFieldPath).toBe(lint.humanizeFieldPath); + }); + + it('still offers its type exports, with the extract options extending the walk options', () => { + const none: TypeExportsBeforeTheMove | undefined = undefined; + const options: ExtractOptions = { warnedGroups: new Set() }; + const walkOptions: ExpectedEntryOptions = options; + expect(none).toBeUndefined(); + expect(walkOptions.warnedGroups?.size).toBe(0); + }); +}); diff --git a/packages/cli/test/i18n-contributed-nav-coverage.test.ts b/packages/cli/test/i18n-contributed-nav-coverage.test.ts index 5b68671ff89..4c776fe0e49 100644 --- a/packages/cli/test/i18n-contributed-nav-coverage.test.ts +++ b/packages/cli/test/i18n-contributed-nav-coverage.test.ts @@ -22,7 +22,7 @@ import { describe, it, expect } from 'vitest'; import { composeStacks, defineStack } from '@objectstack/spec'; import { TRANSLATION_TARGET_UNKNOWN, validateTranslationReferences } from '@objectstack/lint'; -import { collectExpectedEntries } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { authoringRuleUnionStack } from '../src/utils/stack-collections.js'; diff --git a/packages/cli/test/i18n-dataset-coverage.test.ts b/packages/cli/test/i18n-dataset-coverage.test.ts index de9cf099e29..a2be5a8d949 100644 --- a/packages/cli/test/i18n-dataset-coverage.test.ts +++ b/packages/cli/test/i18n-dataset-coverage.test.ts @@ -20,7 +20,7 @@ // otherwise be unaddressable. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { TranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-declared-surface-gate.test.ts b/packages/cli/test/i18n-declared-surface-gate.test.ts index bb45724a0eb..98715067220 100644 --- a/packages/cli/test/i18n-declared-surface-gate.test.ts +++ b/packages/cli/test/i18n-declared-surface-gate.test.ts @@ -19,7 +19,7 @@ import { describe, it, expect } from 'vitest'; import { computeI18nCoverage } from '../src/utils/i18n-coverage'; -import { collectExpectedEntries } from '../src/utils/i18n-extract'; +import { collectExpectedEntries } from '../src/utils/i18n-bundle-extract'; /** Only the user's own metadata; the platform metadata-form baseline is folded away by `os lint`. */ const userIssues = (report: { issues: Array<{ source: string }> }) => diff --git a/packages/cli/test/i18n-duplicate-demand.test.ts b/packages/cli/test/i18n-duplicate-demand.test.ts index fe78bb0ca46..a37b9066eba 100644 --- a/packages/cli/test/i18n-duplicate-demand.test.ts +++ b/packages/cli/test/i18n-duplicate-demand.test.ts @@ -38,7 +38,7 @@ */ import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; /** Repeated paths in a walk, as `[path, occurrences]`, occurrences > 1 only. */ diff --git a/packages/cli/test/i18n-extract-action-cancel-text.test.ts b/packages/cli/test/i18n-extract-action-cancel-text.test.ts index 6385c8a3e5b..a725eb19139 100644 --- a/packages/cli/test/i18n-extract-action-cancel-text.test.ts +++ b/packages/cli/test/i18n-extract-action-cancel-text.test.ts @@ -15,7 +15,7 @@ // passing alone does not prove the addresses agree. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract'; import { computeI18nCoverage } from '../src/utils/i18n-coverage'; import { ActionSchema } from '@objectstack/spec/ui'; import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-extract-action-copy-envelope-27948.test.ts b/packages/cli/test/i18n-extract-action-copy-envelope-27948.test.ts index 82c149d3fa7..23b7f432c70 100644 --- a/packages/cli/test/i18n-extract-action-copy-envelope-27948.test.ts +++ b/packages/cli/test/i18n-extract-action-copy-envelope-27948.test.ts @@ -13,7 +13,7 @@ * `actions[]`), the regression `i18n-walk-output-parity` exists for. */ import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries } from '../src/utils/i18n-bundle-extract.js'; const envelope = (source: string) => ({ dialect: 'template', source }); diff --git a/packages/cli/test/i18n-extract-action-description.test.ts b/packages/cli/test/i18n-extract-action-description.test.ts index 3eb29dce16c..ee7516b144c 100644 --- a/packages/cli/test/i18n-extract-action-description.test.ts +++ b/packages/cli/test/i18n-extract-action-description.test.ts @@ -14,7 +14,7 @@ // its own does not prove the addresses agree. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract'; import { computeI18nCoverage } from '../src/utils/i18n-coverage'; // Subpath entrypoints — the root barrel re-exports only a curated set, and // neither of these two is in it. diff --git a/packages/cli/test/i18n-extract-action-disabled-reason.test.ts b/packages/cli/test/i18n-extract-action-disabled-reason.test.ts index c8f34da7497..576ffbd9757 100644 --- a/packages/cli/test/i18n-extract-action-disabled-reason.test.ts +++ b/packages/cli/test/i18n-extract-action-disabled-reason.test.ts @@ -12,7 +12,7 @@ // addresses agree. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract'; import { computeI18nCoverage } from '../src/utils/i18n-coverage'; import { ActionSchema } from '@objectstack/spec/ui'; import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-extract-action-submit-text.test.ts b/packages/cli/test/i18n-extract-action-submit-text.test.ts index 940522f7747..28538f1ed1d 100644 --- a/packages/cli/test/i18n-extract-action-submit-text.test.ts +++ b/packages/cli/test/i18n-extract-action-submit-text.test.ts @@ -14,7 +14,7 @@ // half passing alone does not prove the addresses agree. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract'; import { computeI18nCoverage } from '../src/utils/i18n-coverage'; import { ActionSchema } from '@objectstack/spec/ui'; import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-extract-emitted-files.test.ts b/packages/cli/test/i18n-extract-emitted-files.test.ts index 29bbe1038ea..0d9d4d8488b 100644 --- a/packages/cli/test/i18n-extract-emitted-files.test.ts +++ b/packages/cli/test/i18n-extract-emitted-files.test.ts @@ -14,7 +14,7 @@ // metadata-forms module is written at all. import { describe, it, expect } from 'vitest'; -import { extractTranslations } from '../src/utils/i18n-extract'; +import { extractTranslations } from '../src/utils/i18n-bundle-extract'; /** * Mirror of the emit rule in `src/commands/i18n/extract.ts`. diff --git a/packages/cli/test/i18n-extract-generated-apps-leaf-provenance.test.ts b/packages/cli/test/i18n-extract-generated-apps-leaf-provenance.test.ts index e0b8a6046f4..98f9f4ee846 100644 --- a/packages/cli/test/i18n-extract-generated-apps-leaf-provenance.test.ts +++ b/packages/cli/test/i18n-extract-generated-apps-leaf-provenance.test.ts @@ -38,7 +38,7 @@ import { extractTranslations, narrowToCommittedSections, translationModuleSections, -} from '../src/utils/i18n-extract.js'; +} from '../src/utils/i18n-bundle-extract.js'; import { GENERATED_SECTIONS, HAND_AUTHORED_SECTIONS, diff --git a/packages/cli/test/i18n-extract-inline-help-text.test.ts b/packages/cli/test/i18n-extract-inline-help-text.test.ts index 73ec3234cd6..e6d114aa508 100644 --- a/packages/cli/test/i18n-extract-inline-help-text.test.ts +++ b/packages/cli/test/i18n-extract-inline-help-text.test.ts @@ -7,7 +7,7 @@ // `help` into one locale had the DEFAULT locale reported missing. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries } from '../src/utils/i18n-extract'; +import { collectExpectedEntries } from '../src/utils/i18n-bundle-extract'; import { computeI18nCoverage } from '../src/utils/i18n-coverage'; const HELP_KEY = 'objects.kpi.fields.score.help'; diff --git a/packages/cli/test/i18n-extract-outcome-messages.test.ts b/packages/cli/test/i18n-extract-outcome-messages.test.ts index 31bcbfca440..f929db378fe 100644 --- a/packages/cli/test/i18n-extract-outcome-messages.test.ts +++ b/packages/cli/test/i18n-extract-outcome-messages.test.ts @@ -15,7 +15,7 @@ * but never seeded, the rule every other action copy key follows. */ import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; const config: any = { objects: [ diff --git a/packages/cli/test/i18n-extract-source-hashes.test.ts b/packages/cli/test/i18n-extract-source-hashes.test.ts index 06b63744a81..2b04cd66dc2 100644 --- a/packages/cli/test/i18n-extract-source-hashes.test.ts +++ b/packages/cli/test/i18n-extract-source-hashes.test.ts @@ -18,7 +18,7 @@ import { narrowToCommittedSections, translationModulePayload, translationModuleSections, -} from '../src/utils/i18n-extract.js'; +} from '../src/utils/i18n-bundle-extract.js'; const stack = (help: string) => ({ objects: [ diff --git a/packages/cli/test/i18n-extract-view-key-identity.test.ts b/packages/cli/test/i18n-extract-view-key-identity.test.ts index f60f30c74c4..5a28b81edec 100644 --- a/packages/cli/test/i18n-extract-view-key-identity.test.ts +++ b/packages/cli/test/i18n-extract-view-key-identity.test.ts @@ -17,7 +17,7 @@ // the string is the thing under discussion. import { describe, it, expect } from 'vitest'; import { expandViewContainer } from '@objectstack/spec/ui'; -import { collectExpectedEntries } from '../src/utils/i18n-extract'; +import { collectExpectedEntries } from '../src/utils/i18n-bundle-extract'; /** Every `_views` key the extractor scaffolds for `object`, in emit order. */ function extractorViewKeys(object: string, container: any): string[] { diff --git a/packages/cli/test/i18n-extract.test.ts b/packages/cli/test/i18n-extract.test.ts index 5e9468311fa..a8c1e513b22 100644 --- a/packages/cli/test/i18n-extract.test.ts +++ b/packages/cli/test/i18n-extract.test.ts @@ -6,7 +6,7 @@ import { extractTranslations, renderTranslationModule, stackAuthoredSubtree, -} from '../src/utils/i18n-extract.js'; +} from '../src/utils/i18n-bundle-extract.js'; const config: any = { objects: [ diff --git a/packages/cli/test/i18n-flow-approval-coverage.test.ts b/packages/cli/test/i18n-flow-approval-coverage.test.ts index 1811db7c2fc..089f1402995 100644 --- a/packages/cli/test/i18n-flow-approval-coverage.test.ts +++ b/packages/cli/test/i18n-flow-approval-coverage.test.ts @@ -23,7 +23,7 @@ vi.mock('@objectstack/lint', async (importOriginal) => ({ authorWarnedProperties: () => new Set(), })); -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { TranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-flow-liveness-gate.test.ts b/packages/cli/test/i18n-flow-liveness-gate.test.ts index a2d68e60e85..581be86bf16 100644 --- a/packages/cli/test/i18n-flow-liveness-gate.test.ts +++ b/packages/cli/test/i18n-flow-liveness-gate.test.ts @@ -53,7 +53,7 @@ import { authorWarnedTranslationGroups, collectExpectedEntries, extractTranslations, -} from '../src/utils/i18n-extract.js'; +} from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { authorWarnedProperties, lintLivenessProperties } from '@objectstack/lint'; diff --git a/packages/cli/test/i18n-flow-refusal-coverage.test.ts b/packages/cli/test/i18n-flow-refusal-coverage.test.ts index 3f74ccc47bd..8a9583689f9 100644 --- a/packages/cli/test/i18n-flow-refusal-coverage.test.ts +++ b/packages/cli/test/i18n-flow-refusal-coverage.test.ts @@ -20,7 +20,7 @@ vi.mock('@objectstack/lint', async (importOriginal) => ({ authorWarnedProperties: () => new Set(), })); -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { TranslationDataSchema, flowRefusalMessageKey } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-flow-save-notice-coverage.test.ts b/packages/cli/test/i18n-flow-save-notice-coverage.test.ts index bb43e237ab6..c66f0768bed 100644 --- a/packages/cli/test/i18n-flow-save-notice-coverage.test.ts +++ b/packages/cli/test/i18n-flow-save-notice-coverage.test.ts @@ -18,7 +18,7 @@ vi.mock('@objectstack/lint', async (importOriginal) => ({ authorWarnedProperties: () => new Set(), })); -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { TranslationDataSchema, flowNoticeMessageKey } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-flow-screen-coverage.test.ts b/packages/cli/test/i18n-flow-screen-coverage.test.ts index c592480b9ef..9816af272c7 100644 --- a/packages/cli/test/i18n-flow-screen-coverage.test.ts +++ b/packages/cli/test/i18n-flow-screen-coverage.test.ts @@ -59,7 +59,7 @@ vi.mock('@objectstack/lint', async (importOriginal) => ({ authorWarnedProperties: () => new Set(), })); -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage, type CoverageIssue, type CoverageReport } from '../src/utils/i18n-coverage.js'; import { FLOW_SCREEN_COPY_KEYS, diff --git a/packages/cli/test/i18n-flow-toast-option-coverage.test.ts b/packages/cli/test/i18n-flow-toast-option-coverage.test.ts index 8c2eab092f8..9e001a8853e 100644 --- a/packages/cli/test/i18n-flow-toast-option-coverage.test.ts +++ b/packages/cli/test/i18n-flow-toast-option-coverage.test.ts @@ -19,7 +19,7 @@ vi.mock('@objectstack/lint', async (importOriginal) => ({ authorWarnedProperties: () => new Set(), })); -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { TranslationDataSchema, translateFlow } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-form-section-entry-coverage.test.ts b/packages/cli/test/i18n-form-section-entry-coverage.test.ts index 77275142568..3807c9082ba 100644 --- a/packages/cli/test/i18n-form-section-entry-coverage.test.ts +++ b/packages/cli/test/i18n-form-section-entry-coverage.test.ts @@ -9,7 +9,7 @@ // that leaves one untranslated. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract'; import { computeI18nCoverage } from '../src/utils/i18n-coverage'; import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-form-thank-you-coverage.test.ts b/packages/cli/test/i18n-form-thank-you-coverage.test.ts index 045930da1e8..177ff7488ad 100644 --- a/packages/cli/test/i18n-form-thank-you-coverage.test.ts +++ b/packages/cli/test/i18n-form-thank-you-coverage.test.ts @@ -10,7 +10,7 @@ // a locale that leaves an authored panel untranslated. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract'; import { computeI18nCoverage } from '../src/utils/i18n-coverage'; import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-form-title-coverage.test.ts b/packages/cli/test/i18n-form-title-coverage.test.ts index 3c108cb5c5c..51fe7aa6ca2 100644 --- a/packages/cli/test/i18n-form-title-coverage.test.ts +++ b/packages/cli/test/i18n-form-title-coverage.test.ts @@ -8,7 +8,7 @@ // leaves an authored title untranslated. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract'; import { computeI18nCoverage } from '../src/utils/i18n-coverage'; import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-hook-refusal-coverage.test.ts b/packages/cli/test/i18n-hook-refusal-coverage.test.ts index 1441cd43c5a..8c7430e46e6 100644 --- a/packages/cli/test/i18n-hook-refusal-coverage.test.ts +++ b/packages/cli/test/i18n-hook-refusal-coverage.test.ts @@ -21,7 +21,7 @@ vi.mock('@objectstack/lint', async (importOriginal) => ({ authorWarnedProperties: () => new Set(), })); -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { TranslationDataSchema, hookRefusalMessageKey } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-inline-locale-map-coverage.test.ts b/packages/cli/test/i18n-inline-locale-map-coverage.test.ts index 9438b61ea7f..59e8a022322 100644 --- a/packages/cli/test/i18n-inline-locale-map-coverage.test.ts +++ b/packages/cli/test/i18n-inline-locale-map-coverage.test.ts @@ -33,7 +33,7 @@ import { describe, it, expect } from 'vitest'; import { resolveI18nLabel } from '@objectstack/spec/ui'; import { computeI18nCoverage, inlineLocaleText } from '../src/utils/i18n-coverage.js'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; const LOCALES = ['en', 'zh-CN', 'ja-JP']; diff --git a/packages/cli/test/i18n-multi-bundle-merge.test.ts b/packages/cli/test/i18n-multi-bundle-merge.test.ts index 5aaacc2022f..5779041bb47 100644 --- a/packages/cli/test/i18n-multi-bundle-merge.test.ts +++ b/packages/cli/test/i18n-multi-bundle-merge.test.ts @@ -30,7 +30,7 @@ import { describe, it, expect } from 'vitest'; import { composeStacks, defineStack } from '@objectstack/spec'; import { createMemoryI18n } from '@objectstack/core'; -import { collectExpectedEntries } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage, mergeTranslationBundles } from '../src/utils/i18n-coverage.js'; import { authoringRuleUnionStack } from '../src/utils/stack-collections.js'; diff --git a/packages/cli/test/i18n-page-form-thank-you-coverage.test.ts b/packages/cli/test/i18n-page-form-thank-you-coverage.test.ts index 801b096609d..f4f87bbc4e7 100644 --- a/packages/cli/test/i18n-page-form-thank-you-coverage.test.ts +++ b/packages/cli/test/i18n-page-form-thank-you-coverage.test.ts @@ -9,7 +9,7 @@ // that leaves an authored panel untranslated. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract'; import { computeI18nCoverage } from '../src/utils/i18n-coverage'; import { TranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-page-tab-item-coverage.test.ts b/packages/cli/test/i18n-page-tab-item-coverage.test.ts index a2444715afe..5c950b8cc8e 100644 --- a/packages/cli/test/i18n-page-tab-item-coverage.test.ts +++ b/packages/cli/test/i18n-page-tab-item-coverage.test.ts @@ -15,7 +15,7 @@ // #14749 ruling). import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract'; import { computeI18nCoverage } from '../src/utils/i18n-coverage'; import { TranslationDataSchema, translatePage } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-picklist-walk.test.ts b/packages/cli/test/i18n-picklist-walk.test.ts index eb249cd3499..44e473c912b 100644 --- a/packages/cli/test/i18n-picklist-walk.test.ts +++ b/packages/cli/test/i18n-picklist-walk.test.ts @@ -10,7 +10,7 @@ // English (`check:i18n-walk-parity`). import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { TranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-related-list-title-walk.test.ts b/packages/cli/test/i18n-related-list-title-walk.test.ts index 99067949026..5f66804a86d 100644 --- a/packages/cli/test/i18n-related-list-title-walk.test.ts +++ b/packages/cli/test/i18n-related-list-title-walk.test.ts @@ -8,7 +8,7 @@ // debt of zero while a zh-CN user read it in English. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { TranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-report-walk.test.ts b/packages/cli/test/i18n-report-walk.test.ts index c4cf5719e8f..029f57ffd6e 100644 --- a/packages/cli/test/i18n-report-walk.test.ts +++ b/packages/cli/test/i18n-report-walk.test.ts @@ -9,7 +9,7 @@ // listed in the authoring language. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { TranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-section-coverage.test.ts b/packages/cli/test/i18n-section-coverage.test.ts index b2402d48a0e..cea9d5801fe 100644 --- a/packages/cli/test/i18n-section-coverage.test.ts +++ b/packages/cli/test/i18n-section-coverage.test.ts @@ -25,7 +25,7 @@ // that a shallow walk sees nothing. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract'; import { computeI18nCoverage } from '../src/utils/i18n-coverage'; import { foldCoverageIssues } from '../src/commands/lint'; import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-security-catalog-walk.test.ts b/packages/cli/test/i18n-security-catalog-walk.test.ts index 4e26faf9ea7..005d1d9e894 100644 --- a/packages/cli/test/i18n-security-catalog-walk.test.ts +++ b/packages/cli/test/i18n-security-catalog-walk.test.ts @@ -10,7 +10,7 @@ // debt of zero while the labels shipped in the authoring language. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { TranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-tab-coverage.test.ts b/packages/cli/test/i18n-tab-coverage.test.ts index 418bbec941a..bbc1edd90fb 100644 --- a/packages/cli/test/i18n-tab-coverage.test.ts +++ b/packages/cli/test/i18n-tab-coverage.test.ts @@ -30,7 +30,7 @@ // their object. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract'; import { computeI18nCoverage } from '../src/utils/i18n-coverage'; import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-validation-message-coverage.test.ts b/packages/cli/test/i18n-validation-message-coverage.test.ts index e52c3efd9ee..d7f5f6ab6dd 100644 --- a/packages/cli/test/i18n-validation-message-coverage.test.ts +++ b/packages/cli/test/i18n-validation-message-coverage.test.ts @@ -16,7 +16,7 @@ // their own `name` and are addressed by it. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; diff --git a/packages/cli/test/i18n-walk-output-parity.test.ts b/packages/cli/test/i18n-walk-output-parity.test.ts index 0b7391c9d58..daf74b10b15 100644 --- a/packages/cli/test/i18n-walk-output-parity.test.ts +++ b/packages/cli/test/i18n-walk-output-parity.test.ts @@ -65,7 +65,7 @@ import { collectExpectedEntries, extractTranslations, stackAuthoredSubtree, -} from '../src/utils/i18n-extract.js'; +} from '../src/utils/i18n-bundle-extract.js'; import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; const LOCALE = 'zh-CN'; diff --git a/packages/cli/test/normalized-call-sites.test.ts b/packages/cli/test/normalized-call-sites.test.ts index 2e46991e00c..eae7645621d 100644 --- a/packages/cli/test/normalized-call-sites.test.ts +++ b/packages/cli/test/normalized-call-sites.test.ts @@ -514,7 +514,7 @@ const READS: Readonly> = { ], why: 'both callers hand it the union (#22238).', }, - 'utils/i18n-extract.ts :: config': { + 'utils/i18n-bundle-extract.ts :: config': { reads: 'resolved', evidence: [{ in: 'commands/i18n/extract.ts', code: 'extractTranslations(authoringRuleUnionStack(normalized' }], why: 'its caller hands it the union (#22238).', diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index 8ffdee8d5e5..2c653ab58f0 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -27,7 +27,7 @@ import { CONNECT_AGENT_UI_BUNDLE } from '@objectstack/mcp'; import { SetupAppTranslations } from '@objectstack/platform-objects'; import * as PlatformPages from '@objectstack/platform-objects/pages'; import { PAGE_COMPONENT_COPY_KEYS, translatePage, walkAddressedPageComponents } from '@objectstack/spec/system'; -import { collectExpectedEntries } from '../src/utils/i18n-extract.js'; +import { collectExpectedEntries } from '../src/utils/i18n-bundle-extract.js'; /** * The pages exactly as the plugins register them with the kernel. The diff --git a/packages/lint/src/i18n-walk.test.ts b/packages/lint/src/i18n-walk.test.ts new file mode 100644 index 00000000000..99832643a81 --- /dev/null +++ b/packages/lint/src/i18n-walk.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The translatable-key walk, from this package: the keys a small stack yields, + * and the one input a browser cannot read (the liveness ledger) leaving out + * the same groups there as in the CLI. The walk's surface is pinned in depth by + * the CLI's i18n suites, which reach it through its re-export. + */ + +import { describe, it, expect } from 'vitest'; +import { authorWarnedTranslationGroups, collectExpectedEntries } from './i18n-walk.js'; + +const STACK = { + objects: [ + { + name: 'repair_ticket', + label: 'Repair Ticket', + fields: { + priority: { + type: 'select', + label: 'Priority', + options: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' }, + ], + }, + }, + }, + ], + apps: [ + { + name: 'service', + label: 'Service', + navigation: [{ id: 'nav_tickets', type: 'object', objectName: 'repair_ticket', label: 'Tickets' }], + }, + ], +}; + +describe('collectExpectedEntries', () => { + const entries = collectExpectedEntries(STACK, { warnedGroups: new Set() }); + const byKey = new Map(entries.map((e) => [e.path.join('.'), e])); + + it('yields the object, field, option, app and navigation keys, each with its source text', () => { + expect(byKey.get('objects.repair_ticket.label')?.sourceValue).toBe('Repair Ticket'); + expect(byKey.get('objects.repair_ticket.fields.priority.label')?.sourceValue).toBe('Priority'); + expect(byKey.get('objects.repair_ticket.fields.priority.options.low')?.sourceValue).toBe('Low'); + expect(byKey.get('objects.repair_ticket.fields.priority.options.high')?.sourceValue).toBe('High'); + expect(byKey.get('apps.service.label')?.sourceValue).toBe('Service'); + expect(byKey.get('apps.service.navigation.nav_tickets.label')?.sourceValue).toBe('Tickets'); + }); + + it('names what each key was harvested from', () => { + expect(byKey.get('objects.repair_ticket.label')?.source).toBe('object'); + expect(byKey.get('objects.repair_ticket.fields.priority.label')?.source).toBe('field'); + expect(byKey.get('objects.repair_ticket.fields.priority.options.low')?.source).toBe('option'); + expect(byKey.get('apps.service.label')?.source).toBe('app'); + expect(byKey.get('apps.service.navigation.nav_tickets.label')?.source).toBe('navigation'); + }); + + it('leaves a warned group out of the walk', () => { + const keys = collectExpectedEntries(STACK, { warnedGroups: new Set(['apps']) }).map((e) => e.path[0]); + expect(keys).toContain('objects'); + expect(keys).not.toContain('apps'); + }); +}); + +describe('authorWarnedTranslationGroups on the shipped ledger', () => { + // A browser reads no ledger file, so there the default leaves no group out. + // That matches the CLI only while the ledger warns on no translation group; + // the day one is warned, a browser caller must be handed the set instead. + it('warns on no translation group, so a browser and the CLI walk the same groups', () => { + expect([...authorWarnedTranslationGroups()]).toEqual([]); + }); +}); diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/lint/src/i18n-walk.ts similarity index 81% rename from packages/cli/src/utils/i18n-extract.ts rename to packages/lint/src/i18n-walk.ts index 603c22597dc..7a1816cdc15 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/lint/src/i18n-walk.ts @@ -1,15 +1,18 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * I18n Extractor - * - * Walks a normalized stack config and produces ready-to-edit `TranslationData` - * skeletons for every requested locale, pre-populated with the source labels - * from the schema for the default locale. - * - * {@link collectExpectedEntries} is also the **single** definition of what is - * translatable at all: `i18n-coverage.ts` consumes it to decide what `os lint` - * gates, instead of keeping the parallel walk the two used to maintain. They + * I18n translatable-key walk + * + * Walks a normalized stack config and lists every key a translation bundle can + * carry for it, each with the source text it translates. + * {@link collectExpectedEntries} is the **single** definition of what is + * translatable at all. The CLI's `os i18n extract` builds its skeletons from it + * (`packages/cli/src/utils/i18n-bundle-extract.ts`), `os lint`'s coverage gate + * (`i18n-coverage.ts`) decides what it gates from it, and Studio's translation + * editor draws its source-to-translation table from it. It lives in this + * package, not the CLI, so a browser runs the same walk the CLI does. + * + * The coverage gate and the extractor used to keep parallel walks. They * had drifted — inline object `actions` and object-nested `listViews` were * scaffoldable but ungated, which is how English approval buttons shipped into * a zh-CN workspace unnoticed (#3370). Add a surface here and both sides get it. @@ -149,10 +152,11 @@ * Pure given its inputs: no network, and the only filesystem read is the * shipped liveness ledger behind {@link authorWarnedTranslationGroups} — * injectable as `warnedGroups`, so the walk itself stays a pure function of - * `config`. Safe to call from the CLI, IDE tooling and unit tests. + * `config`. Safe to call from the CLI, IDE tooling, unit tests and a browser. + * A browser has no ledger file to read, so there the default leaves no group + * out; a caller that must honour the ledger there passes `warnedGroups`. */ -import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; import { METADATA_FORM_REGISTRY, PAGE_COMPONENT_COPY_KEYS, @@ -170,8 +174,8 @@ import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { deriveFieldGroupLayout } from '@objectstack/spec/data'; import { APPROVAL_NODE_TYPE } from '@objectstack/spec/automation'; import { expandViewContainer, InlineLocaleMapSchema } from '@objectstack/spec/ui'; -import { authorWarnedProperties, walkPageComponents } from '@objectstack/lint'; -import { collectFilledFromHashes } from '@objectstack/platform-objects/apps'; +import { authorWarnedProperties } from './lint-liveness-properties.js'; +import { walkPageComponents } from './page-walk.js'; // ─── Public types ────────────────────────────────────────────────────── @@ -259,90 +263,6 @@ export interface ExpectedEntry { flowName?: string; } -export type FillStrategy = 'empty' | 'default' | 'todo'; - -export interface ExtractOptions extends ExpectedEntryOptions { - /** Default locale (filled with source values). Defaults to `'en'`. */ - defaultLocale?: string; - /** Locales to emit. Defaults to `[defaultLocale]`. */ - locales?: string[]; - /** - * How to populate values for non-default locales: - * - `'empty'` → empty string (default) - * - `'default'` → copy source value verbatim - * - `'todo'` → source value with a `[TODO] ` prefix - */ - fill?: FillStrategy; - /** - * Regex filter applied against `objectName` / `appName` / `dashboard` / view - * / action identifiers. When provided, only matching entries are emitted. - */ - filter?: RegExp; - /** - * When true, entries that already exist in any of the stack's - * `translations` bundles for a given locale are *omitted* for that locale. - * This makes extract idempotent — re-running only fills the gaps. - */ - mergeExisting?: boolean; - /** - * The `.source-hashes.generated.ts` tables already committed beside - * the bundles, keyed by locale. - * - * This is the mechanism's ONLY memory (commit 09b4f4e4e, ruling #12069 Option A): a leaf that - * is a byte copy of a source revision keeps its record across runs, which is - * what makes the drift detectable after the source moves. Passing nothing - * makes the run behave like a first extract — every record is re-derived from - * the tree, so leaves that already drifted stay legacy-trusted rather than - * being reported. - */ - previousSourceHashes?: Record>; -} - -export interface ExtractResult { - /** Locale → TranslationData skeleton (only the entries we emitted). */ - bundles: Record; - /** - * Locale → the number of leaves in `bundles[locale]`: the whole skeleton - * built for that locale, every section of it. - * - * ⛔ NOT the number of keys any one emitted FILE holds. Which sections of the - * skeleton become committed modules is the caller's decision — `os i18n - * extract` makes it from `--objects-only` and `--metadata-forms` — so under - * the default `--objects-only` this number is far larger than the module the - * command writes: the registry-driven `metadataForms` baseline alone is ~773 - * keys against a one-object stack's 2 (#16121). A caller reporting a count - * for a file takes it off that file's own payload - * ({@link translationModulePayload}), never off this field. - * - * Counted off the built tree, never off the walk that built it. The walk - * counter this replaces could not disagree with anything: it incremented once - * per entry, unconditionally, so it equalled {@link ExtractResult.totalExpected} - * for every locale on every config and no observation could have shown it - * wrong. A leaf count of the bundle CAN fail — it moves the moment `setDeep` - * collapses two entries onto one path. - */ - counts: Record; - /** Total expected entries before per-locale merge filtering. */ - totalExpected: number; - /** - * Per translated locale, the digest of the source revision each GENERATED - * leaf is still a byte copy of — the content of - * `.source-hashes.generated.ts`. - * - * Computed by `collectFilledFromHashes` in - * `@objectstack/platform-objects/apps`, the module maintainer ruling #8765 - * Option B put the mechanism in; the extractor supplies the tree, the - * previous records and THE SECTIONS THIS RUN BUILT, and owns none of the - * rule. The default locale gets no entry: it is the source, not a copy of - * one. - * - * Covers every section the run generated, not just the two a default - * `--objects-only` run happens to emit (#16872). A `--no-objects-only` run - * fills `apps` / `dashboards` / `pages` from the source too, and before this - * those leaves were recorded by nobody and judged by nobody. - */ - sourceHashes: Record>; -} // ─── Walk helpers ────────────────────────────────────────────────────── @@ -2598,524 +2518,3 @@ function navigationContributionsOf(config: any): any[] { for (const entry of packages) add((entry as any)?.manifest?.navigationContributions); return out; } - -// ─── Filter + bundle assembly ────────────────────────────────────────── - -function passesFilter(entry: ExpectedEntry, filter?: RegExp): boolean { - if (!filter) return true; - if (entry.objectName && filter.test(entry.objectName)) return true; - if (entry.appName && filter.test(entry.appName)) return true; - if (entry.metadataType && filter.test(entry.metadataType)) return true; - if (entry.flowName && filter.test(entry.flowName)) return true; - // Allow matching against the joined path so users can target e.g. ^dashboards\.system_ - return filter.test(entry.path.join('.')); -} - -function setDeep(data: TranslationData, path: string[], value: string): void { - let cur: any = data; - for (let i = 0; i < path.length - 1; i++) { - const seg = path[i]; - if (typeof cur[seg] !== 'object' || cur[seg] === null) cur[seg] = {}; - cur = cur[seg]; - } - cur[path[path.length - 1]] = value; -} - -function lookupDeep(data: TranslationData | undefined, path: string[]): string | undefined { - let cur: any = data; - for (const seg of path) { - if (!cur || typeof cur !== 'object') return undefined; - cur = cur[seg]; - } - return typeof cur === 'string' && cur.length > 0 ? cur : undefined; -} - -function mergeBundles(bundles: TranslationBundle[]): Record { - const out: Record = {}; - for (const bundle of bundles) { - if (!bundle || typeof bundle !== 'object') continue; - for (const [locale, data] of Object.entries(bundle)) { - if (!data || typeof data !== 'object') continue; - out[locale] = deepMerge(out[locale] ?? {}, data as TranslationData); - } - } - return out; -} - -function deepMerge>(target: T, source: T): T { - for (const [k, v] of Object.entries(source)) { - if (v && typeof v === 'object' && !Array.isArray(v)) { - target[k as keyof T] = deepMerge(((target as any)[k] ?? {}) as any, v as any); - } else { - (target as any)[k] = v; - } - } - return target; -} - -/** - * Build per-locale skeleton bundles from a normalized stack config. - */ -export function extractTranslations(config: any, opts: ExtractOptions = {}): ExtractResult { - const defaultLocale = opts.defaultLocale ?? 'en'; - const locales = opts.locales && opts.locales.length > 0 - ? Array.from(new Set([defaultLocale, ...opts.locales])) - : [defaultLocale]; - const fill: FillStrategy = opts.fill ?? 'empty'; - - // Seed-less entries exist only so the coverage gate can spot a bundle that - // authors a key the metadata never writes inline — there is nothing to - // scaffold from, so they never reach a generated skeleton. - // `warnedGroups` rides through: a group the ledger warns authors for - // authoring must not be scaffolded either. Scaffolding it would hand the - // author a skeleton whose every filled-in key draws a warning — the same - // collision the coverage gate has, arriving by a different door (#11624). - const allEntries = collectExpectedEntries(config, { warnedGroups: opts.warnedGroups }) - .filter((e) => e.sourceValue !== undefined); - const entries = allEntries.filter((e) => passesFilter(e, opts.filter)); - - const existingBundles: TranslationBundle[] = Array.isArray(config?.translations) ? config.translations : []; - const existing = mergeBundles(existingBundles); - - const bundles: Record = {}; - const counts: Record = {}; - - for (const locale of locales) { - const data: TranslationData = {}; - for (const entry of entries) { - // Guaranteed by the seed-less filter above; narrows for the branches below. - const seed = entry.sourceValue ?? ''; - let value: string | undefined; - // If a translation already exists for this locale, carry it through - // verbatim so the generated file remains a complete, self-contained - // bundle (not just the missing-key delta). Set --no-merge to skip - // baselines entirely. - // - // The default locale is deliberately NOT merged (#8543): it is a copy of - // the source, not a translation, so "never overwrite an existing entry" - // protects the wrong thing there — an author edits a field description, - // the regeneration keeps the stale entry, and the served text drifts from - // the source silently while the drift gate reports OK (measured at 53 - // stale entries across 6 packages when this branch ran for every locale). - // The seed IS the source text for the default locale (line below), so it - // always wins; translated locales keep merge semantics exactly as before. - if (opts.mergeExisting !== false && locale !== defaultLocale) { - const existingValue = lookupDeep(existing[locale], entry.path); - if (existingValue !== undefined && existingValue !== '') { - value = String(existingValue); - } - } - if (value === undefined) { - if (locale === defaultLocale) { - value = seed; - } else if (fill === 'default') { - value = seed; - } else if (fill === 'todo') { - value = `[TODO] ${seed}`; - } else { - value = ''; - } - } - setDeep(data, entry.path, value); - } - bundles[locale] = data; - // Off the tree that was just built, never off the walk that built it — see - // {@link ExtractResult.counts} (#16121). - counts[locale] = countTranslationLeaves(data); - } - - const sourceHashes: Record> = {}; - const sourceBundle = bundles[defaultLocale]; - /** - * The population the provenance rule walks is THIS RUN'S — the sections it - * actually built — not `GENERATED_SECTIONS`, the default a caller that does - * not know inherits (#16872). - * - * Every leaf in `bundles[locale]` got there through this extractor, which is - * the discriminator the rule is written against: `--fill=default` copies it - * from the source. Under the default `--objects-only` the sections built are - * `objects` and `metadataForms`, so this is byte-identical to the previous - * behaviour; under `--no-objects-only` it is the difference between a filled - * `apps` leaf having provenance and having none. - * - * Read off the BUILT tree rather than off the config, for the reason #16121 - * records about counts: a number or a name derived independently of the bytes - * it describes is a number nothing can check. WHICH of these sections becomes - * a committed bundle stays a separate, later decision that - * `narrowToCommittedSections` already derives from the emitted module list — - * this layer supplies the tree and owns none of the rule. - */ - for (const locale of locales) { - if (locale === defaultLocale) continue; - sourceHashes[locale] = collectFilledFromHashes( - bundles[locale], - sourceBundle, - opts.previousSourceHashes?.[locale], - Object.keys(bundles[locale] ?? {}), - ); - } - - return { bundles, counts, totalExpected: entries.length, sourceHashes }; -} - -// ─── Serialization ───────────────────────────────────────────────────── - -/** - * One locale's translations MINUS the registry-driven `metadataForms` - * baseline — everything the stack itself authors (#14894). - * - * The baseline is not part of any stack's authored surface: it is derived from - * the platform's metadata-form registry, is identical for every stack, and has - * its own module (`.metadata-forms.generated.ts`) under its own flag - * (`--metadata-forms`). Folding it into the module beside them gave it a SECOND - * home, and the two homes then disagreed about who governed it — see - * {@link renderTranslationModule}. - */ -export function stackAuthoredSubtree(data: TranslationData): Omit { - const { metadataForms: _registryBaseline, ...authored } = data; - return authored; -} - -/** Sub-tree selector shared by {@link renderTranslationModule} and its callers. */ -export type TranslationModuleKind = 'objects' | 'metadataForms' | 'stack'; - -/** - * The sub-tree a module of the given {@link TranslationModuleKind} holds — the - * ONE place that decides what each emitted module contains. - * - * Exported so that a count reported for a module is taken off the SAME tree the - * module is rendered from. #16121 was the other arrangement: the command - * reported a number derived independently of the bytes it wrote, so nothing - * could compare the two and the printed count was wrong by two orders of - * magnitude with every gate green. A caller now counts - * `countTranslationLeaves(translationModulePayload(data, kind))` and renders - * `renderTranslationModule(data, { kind })` — one selector, so the number and - * the bytes agree by construction, for a `kind` added later as much as for - * these three. - */ -export function translationModulePayload( - data: TranslationData, - kind: TranslationModuleKind, -): - | NonNullable - | NonNullable - | Omit { - return kind === 'metadataForms' - ? (data.metadataForms ?? {}) - : kind === 'objects' - ? (data.objects ?? {}) - : stackAuthoredSubtree(data); -} - -/** - * The `TranslationData` SECTIONS a module of the given kind COVERS — the first - * dotted segment its leaves carry, which is the identity - * {@link narrowToCommittedSections} narrows a provenance table by. - * - * Beside {@link translationModulePayload} and switched on the same `kind`, so - * "what a module holds" and "which sections it commits" are ONE decision. The - * caller was two literals — `committed.push('objects')` and - * `committed.push('metadataForms')`, chosen by which modules were emitted — and - * a literal cannot follow the payload: under `kind: 'stack'` the module holds - * every group the stack authors (`objects`, `apps`, `dashboards`, ...) while the - * caller named exactly one of them. Replacing that list with a longer list of - * literals only moves the day it goes wrong to the next group added. - * - * ⚠️ The sections are NOT simply the payload's own top-level keys, and reading - * them off it would be WRONG for two of the three kinds. `'objects'` and - * `'metadataForms'` select the sub-tree ROOTED AT one section, so those keys are - * object and form names (`kpi_metric`) — deriving the section list from them - * would commit `['kpi_metric']` and narrow away every `objects.*` record, which - * is the one path this repository's single `--source-hashes` config is on. - * `'stack'` selects a `TranslationData`-shaped subtree, so THERE the top-level - * keys are sections — every group the stack authors, today's and any added - * later, with nothing here to update. - * - * The switch is exhaustive on purpose: a fourth kind that selects one section - * needs no edit (`[kind]` already names it), and a fourth AGGREGATE kind fails - * to compile here rather than silently committing its own name as a section. - */ -export function translationModuleSections( - data: TranslationData, - kind: TranslationModuleKind, -): string[] { - switch (kind) { - case 'stack': - return Object.keys(stackAuthoredSubtree(data)); - case 'objects': - case 'metadataForms': - // The selector's own name IS the section it roots at. - return [kind]; - default: { - const exhaustive: never = kind; - return exhaustive; - } - } -} - -/** - * String leaves under a nested translation tree. - * - * The structural measure every key count in this mechanism is taken with — the - * extractor's per-locale {@link ExtractResult.counts}, and each number - * `os i18n extract` prints beside a module. One implementation so that two - * counts of the same tree can never be two different numbers. - */ -export function countTranslationLeaves(node: unknown): number { - if (!node || typeof node !== 'object') return 0; - let n = 0; - for (const value of Object.values(node as Record)) { - if (typeof value === 'string') n += 1; - else if (value && typeof value === 'object') n += countTranslationLeaves(value); - } - return n; -} - -/** - * Render a TranslationData skeleton as a TypeScript module body. - * - * The module exports a single named const (``) typed against - * the chosen sub-tree of `TranslationData`: - * - * kind: 'objects' → `NonNullable` - * kind: 'metadataForms' → `NonNullable` - * kind: 'stack' → `Omit` - * - * ⚠️ The three are NOT three disjoint cells, and calling them a partition was - * imprecise enough to correct: `'objects'` is a SUB-SELECTION of `'stack'`, not - * a sibling of it. The invariant that actually holds — and the one this - * function exists to keep — is about a PAIR: whichever of `'objects'` / - * `'stack'` a run picks for the module it writes, that module and the - * `'metadataForms'` companion beside it are disjoint, and under `'stack'` the - * two together are the whole of what the extractor built. Measured on the - * fixture below: 3 + 773 = 776, which is the extractor's own count for that - * run — none dropped, none duplicated. - * - * `'stack'` was called `'full'` and rendered the whole `TranslationData`, which - * broke exactly that pairing — `metadataForms` landed in the stack module AND - * in its own companion. - * - * That is the #14894 defect, and it had two user-visible halves. Both were - * driven on a `defaultLocale: 'zh-CN'` stack (one object, one app) before this - * function changed: - * - * • `--no-metadata-forms` stopped suppressing the baseline the moment - * `--no-objects-only` was passed. The flag gated only the companion, while - * `'full'` inlined the same keys next door: `zh-CN.objects.generated.ts` - * came out holding 773 metadata-form leaves under an explicit - * `--no-metadata-forms`, against 2 object leaves and 1 app leaf. Those 773 - * were ENGLISH — the default locale is filled from the source labels, and - * the registry authors them in English — so a non-English default locale - * shipped the platform's English Studio baseline inside its own bundle. - * • With `--metadata-forms` left ON, the same run emitted those 773 keys - * TWICE, once in each module. - * - * `--objects-only` and `--metadata-forms` are documented as independent, and - * they are: the first picks the stack module's sub-tree (`objects` alone, or - * everything the stack authors), the second decides whether the baseline is - * emitted at all. Neither has to win over the other, and this change invents no - * precedence between them — the overlap was in the emitter, never in the two - * meanings. - * - * `objectsOnly: true` (default) is a legacy alias for `kind: 'objects'`, and - * `objectsOnly: false` for `kind: 'stack'`. - */ -export function renderTranslationModule( - data: TranslationData, - options: { - locale: string; - exportName?: string; - /** Legacy: when true, emit only the `objects` sub-tree (typed accordingly). */ - objectsOnly?: boolean; - /** Explicit sub-tree selector. Overrides `objectsOnly` when provided. */ - kind?: 'objects' | 'metadataForms' | 'stack'; - /** Header comment lines. */ - header?: string[]; - }, -): string { - const kind: TranslationModuleKind = - options.kind ?? (options.objectsOnly === false ? 'stack' : 'objects'); - const defaultExport = - kind === 'metadataForms' - ? `${camelize(options.locale)}MetadataForms` - : kind === 'stack' - ? `${camelize(options.locale)}Translations` - : `${camelize(options.locale)}Objects`; - const exportName = options.exportName ?? defaultExport; - const payload = translationModulePayload(data, kind); - const typeSig = - kind === 'metadataForms' - ? "NonNullable" - : kind === 'objects' - ? "NonNullable" - : "Omit"; - const header = options.header ?? [ - `Auto-generated by 'os i18n extract' for locale '${options.locale}'.`, - 'Edit translations in place; re-run extract (with --merge) to fill new gaps.', - 'Do not hand-edit the structure — only the leaf string values.', - 'Merge only fills gaps: correcting a source label/description does not', - 'push the correction into a leaf here that already holds a translation —', - 'a present-but-stale string is not a gap, so it is left as-is. Re-translate', - 'it by hand when its source changes; nothing here or in `os i18n check`', - 'tells a leaf a translator updated on purpose from one nobody has looked', - 'at since the source moved.', - ]; - - const lines: string[] = []; - lines.push('// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.'); - lines.push(''); - lines.push('/**'); - for (const h of header) lines.push(` * ${h}`); - lines.push(' */'); - lines.push(''); - lines.push("import type { TranslationData } from '@objectstack/spec/system';"); - lines.push(''); - lines.push(`export const ${exportName}: ${typeSig} = ${stringifyTs(payload, 0)};`); - lines.push(''); - return lines.join('\n'); -} - -/** - * Render one locale's generated source-hash table as a TypeScript module body — - * the `.source-hashes.generated.ts` companion. - * - * Deliberately types the export STRUCTURALLY (`Readonly>`) instead of importing `SourceHashes`. The companion is written into - * whichever package owns the bundles, and only one of those packages can spell - * the type with a relative import; an import path guessed per package is a - * portability bug waiting for the second package to use this. The structural - * type is what `SourceHashes` is defined as, so nothing is lost. - * - * Keys are emitted sorted, and every key is quoted (they are dotted paths, so - * `formatKey` would quote them anyway). Both are load-bearing for `--check`: - * the comparison is byte-for-byte, so a table that reordered with the walk - * would fail on a tree that is in fact in sync. - */ -export function renderSourceHashModule( - hashes: Record, - options: { locale: string; exportName?: string }, -): string { - const exportName = options.exportName ?? `${camelize(options.locale)}GeneratedSourceHashes`; - const keys = Object.keys(hashes).sort(); - const lines: string[] = []; - lines.push('// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.'); - lines.push(''); - lines.push('/**'); - lines.push(` * Auto-generated by 'os i18n extract' for locale '${options.locale}'. Do not hand-edit.`); - lines.push(' *'); - lines.push(" * Each entry is the digest of the SOURCE REVISION that this locale's leaf at"); - lines.push(' * that path is still a byte copy of — provenance for the generated half of the'); - lines.push(' * bundles (commit 09b4f4e4e): a leaf whose digest no longer matches its source is stale and serves the source text instead.'); - lines.push(' *'); - lines.push(' * An entry exists only while the leaf IS such a copy. Re-translate the leaf in'); - lines.push(' * `.objects.generated.ts` and the next extract drops its entry by'); - lines.push(' * itself — the table makes no claim about text a translator wrote. A path with'); - lines.push(' * no entry is LEGACY-TRUSTED and never reported stale.'); - lines.push(' *'); - lines.push(' * ⚠️ Do not "fix" a staleness report by editing this file. Refreshing a digest'); - lines.push(' * records that the current text was copied from the current source, which is'); - lines.push(' * the false claim the mechanism exists to detect. Fix the TRANSLATION.'); - lines.push(' */'); - lines.push(''); - lines.push(`export const ${exportName}: Readonly> = {`); - for (const key of keys) lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(hashes[key])},`); - lines.push('};'); - lines.push(''); - return lines.join('\n'); -} - -/** - * Narrow a provenance table to the generated sections a run actually COMMITS - * (#12559). - * - * {@link extractTranslations} computes the table over every generated section it - * built — `objects` and `metadataForms` both — because the rule that fills it - * (`collectFilledFromHashes`) is a statement about generated leaves, not about - * files. Which of those sections becomes a committed bundle is the command - * layer's decision, and the two must agree: a record describes the leaf sitting - * in a bundle beside it, so a record for a section this package does not commit - * describes nothing that exists there. - * - * The mismatch is measured, not hypothetical. A package that owns only its own - * objects passes `--no-metadata-forms`, and the emitter's own note says why: - * "without it, `--check` demands a baseline copy the package deliberately does - * not commit". Its `metadataForms` subtree is built anyway, and — having no - * entry in that package's merge baseline — arrives as a fresh `--fill=default` - * copy of `en`, so EVERY leaf of it satisfies `value === currentSource` and is - * recorded. Measured on `@objectstack/plugin-audit` while rolling the companion - * out in #12559: 763 records, of which **2** were its own objects and 761 were - * digests of the Studio metadata-form baseline `@objectstack/platform-objects` - * owns — unreadable in that package (no `metadataForms` bundle exists there for - * them to be about) and re-written in all three of its companions every time an - * unrelated `*.form.ts` in `packages/spec` moved. That is the cross-package - * coupling ADR-0029 D8 and each package's `bundle-ownership.test.ts` keep out of - * the committed bundles; the companion is not exempt from it. - * - * A section is named by a leaf path's FIRST dotted segment — the same identity - * `collectGeneratedLeaves` seeds its walk with, so the two cannot disagree about - * what section a path belongs to. Callers pass the sections they are committing; - * this function invents none, so a set that commits both (`platform-objects` is - * the one today) keeps every record it had. - */ -export function narrowToCommittedSections( - hashes: Record, - committedSections: Iterable, -): Record { - const committed = new Set(committedSections); - return Object.fromEntries( - Object.entries(hashes).filter(([leafPath]) => committed.has(leafPath.split('.', 1)[0])), - ); -} - -/** - * Read a committed `.source-hashes.generated.ts` back into a table. - * - * The module body is written by {@link renderSourceHashModule}, which quotes - * every key and every value, so the object literal is already valid JSON — the - * parse needs no TypeScript and no evaluation. A file that does not parse is a - * hard `undefined` (treated as "no previous records", i.e. everything - * legacy-trusted) rather than a guess: inventing records from a file we cannot - * read is how a mechanism starts asserting provenance it does not have. - */ -export function parseSourceHashModule(source: string): Record | undefined { - const marker = source.indexOf('export const'); - const open = marker < 0 ? -1 : source.indexOf('= {', marker); - if (open < 0) return undefined; - const literal = source.slice(open + 2).replace(/;\s*$/, ''); - try { - const parsed = JSON.parse(literal.replace(/,(\s*})/g, '$1')); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; - for (const value of Object.values(parsed)) if (typeof value !== 'string') return undefined; - return parsed as Record; - } catch { - return undefined; - } -} - -function camelize(locale: string): string { - // 'zh-CN' → 'zhCN', 'ja-JP' → 'jaJP', 'es-ES' → 'esES' - return locale.replace(/-(.)/g, (_m, c) => c.toUpperCase()); -} - -function stringifyTs(value: unknown, indent: number): string { - const pad = ' '.repeat(indent); - const pad2 = ' '.repeat(indent + 1); - if (value === null) return 'null'; - if (typeof value === 'string') return JSON.stringify(value); - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - if (Array.isArray(value)) { - if (value.length === 0) return '[]'; - return '[\n' + value.map((v) => pad2 + stringifyTs(v, indent + 1)).join(',\n') + `\n${pad}]`; - } - if (typeof value === 'object') { - const entries = Object.entries(value as Record); - if (entries.length === 0) return '{}'; - return '{\n' + entries.map(([k, v]) => `${pad2}${formatKey(k)}: ${stringifyTs(v, indent + 1)}`).join(',\n') + `\n${pad}}`; - } - return JSON.stringify(value); -} - -const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; -function formatKey(key: string): string { - return IDENT_RE.test(key) ? key : JSON.stringify(key); -} diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 5126ea83562..e7e7def432b 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -1213,3 +1213,11 @@ export type { EnvelopeAuditDoor, PageEnvelopeAudit, } from './page-envelope-audit.js'; + +// The translatable-key walk: every key a translation bundle can carry for a +// stack, each with the source text it translates. `os i18n extract` builds its +// skeletons from it, `os lint`'s coverage gate decides what it gates from it, +// and Studio's translation editor draws its table from it, in a browser — which +// is why it lives here and not in the CLI. +export { authorWarnedTranslationGroups, collectExpectedEntries, humanizeFieldPath } from './i18n-walk.js'; +export type { ExpectedEntry, ExpectedEntryOptions } from './i18n-walk.js'; diff --git a/packages/lint/src/validate-translatable-sections.ts b/packages/lint/src/validate-translatable-sections.ts index 7e44ab7fd5b..079ff17a87c 100644 --- a/packages/lint/src/validate-translatable-sections.ts +++ b/packages/lint/src/validate-translatable-sections.ts @@ -81,7 +81,7 @@ * ── The section face ───────────────────────────────────────────────────── * * Exactly the anchors the two landed halves already agree on — the coverage - * walker's `section` kind (`packages/cli/src/utils/i18n-extract.ts`, #5416) and + * walker's `section` kind (`packages/lint/src/i18n-walk.ts`, #5416) and * this package's `_sections` fact set (`validate-translation-references.ts` * `addSections`, #5422): * diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 88d33e67855..41fd360f303 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -722,7 +722,7 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec // rather than defer: the composer does give the default form a runtime // identity (`.form`), but `_views.*` is a LIST-view convention — // `viewLabel` / `viewDescription` resolve view tabs, and the i18n walker - // emits no `_views` entry for any form view (`i18n-extract.ts`: "form views + // emits no `_views` entry for any form view (`i18n-walk.ts`: "form views // have no counterpart in the `viewLabel` / `_views.*` resolver convention"). // A `_views` name registered here for the default form would make a key // legal that no consumer ever reads. Its SECTIONS are a different matter — @@ -762,7 +762,7 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec * the failure that made `_views.form` a non-key (#5164). * 2. **The object comes from `interfaceConfig.source` first, then the page's * own `object`.** That is the order `translateInterfaceTabs` resolves in and - * the order `walkObjectTabs` (`packages/cli/src/utils/i18n-extract.ts`) + * the order `walkObjectTabs` (`packages/lint/src/i18n-walk.ts`) * scaffolds in — the "component's own binding first" discipline `_sections` * follows. A page-level lookup alone would file the presets of every * `source`-bound list page under the wrong object, reporting correct keys. @@ -808,7 +808,7 @@ function collectPageTabs(page: AnyRec, factsFor: (objectName: string) => ObjectF * (commit b3c1f3cd5, leg 1) and this rule called the key an orphan, in ONE `os lint` run: * six instances on the showcase, and no author action could make both green. * Ruled 2026-08-06 (#5164): canonical = the runtime identity's bare key. - * Leg 1's `defaultListViewKey` in `packages/cli/src/utils/i18n-extract.ts` is + * Leg 1's `defaultListViewKey` in `packages/lint/src/i18n-walk.ts` is * this function's twin — deliberately, both are thin readers of the composer * rather than a third and fourth derivation of the key. * diff --git a/packages/lint/src/view-walk.ts b/packages/lint/src/view-walk.ts index ee3a3f98221..421ed01b383 100644 --- a/packages/lint/src/view-walk.ts +++ b/packages/lint/src/view-walk.ts @@ -157,7 +157,7 @@ function strName(v: unknown): string | undefined { * names (#6662): `boundObject` in `validate-form-layout.ts`, `viewObjectName` * in `validate-translatable-sections.ts` and in * `validate-translation-references.ts`. The majority spelling wins here, and it - * is also the CLI i18n extractor's (`packages/cli/src/utils/i18n-extract.ts`) + * is also the CLI i18n extractor's (`packages/lint/src/i18n-walk.ts`) * and the spec resolver's (`packages/spec/src/system/i18n-resolver.ts`), so * every walker in the repo now agrees on which object a record belongs to by * reading the same four lines. Disagreeing here would mean warning about the diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 4e6bdf703d7..570344ef168 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -13,7 +13,7 @@ "description": { "status": "live", "verifiedAt": "2026-08-10", - "evidence": "ui/packages/app-shell/src/views/ActionParamDialog.tsx#ActionParamDialog renders it as the dialog's DialogDescription, falling back to the generic `actionDialog.description` string; ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx#paramCollectionHandler and ui/packages/app-shell/src/views/RecordDetailView.tsx#paramCollectionHandler both feed it as `actionDescription(objForI18n, action?.name, action?.description)`; ui/packages/i18n/src/useObjectLabel.ts#actionDescription resolves it through ui/packages/i18n/src/useObjectLabel.ts#actionSuffixes, reading objects.{o}._actions.{a}.description for an action with a key object or globalActions.{a}.description for one without, never both, then this literal; framework: packages/cli/src/utils/i18n-extract.ts emits the key and packages/spec/src/system/translation.zod.ts declares the translation slot.", + "evidence": "ui/packages/app-shell/src/views/ActionParamDialog.tsx#ActionParamDialog renders it as the dialog's DialogDescription, falling back to the generic `actionDialog.description` string; ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx#paramCollectionHandler and ui/packages/app-shell/src/views/RecordDetailView.tsx#paramCollectionHandler both feed it as `actionDescription(objForI18n, action?.name, action?.description)`; ui/packages/i18n/src/useObjectLabel.ts#actionDescription resolves it through ui/packages/i18n/src/useObjectLabel.ts#actionSuffixes, reading objects.{o}._actions.{a}.description for an action with a key object or globalActions.{a}.description for one without, never both, then this literal; framework: packages/lint/src/i18n-walk.ts emits the key and packages/spec/src/system/translation.zod.ts declares the translation slot.", "note": "Added #7367 — the RENDERER half predates the key by design: the consumer chain above shipped while `ActionSchema` (strictObject) refused `description`, so no producer could reach it (the mirror image of declared-but-unenforced). Live from the moment this key exists: the dialog reads `action.description` directly, no second input required. Mechanism for the 2026-08-10 ruling on #7278 — an action with `params` carries its confirm question here instead of in `confirmText`, so one decision is one dialog. Moved out of spec source, #23434: packages/spec/src/ui/action.zod.ts (this key's docblock) and packages/spec/src/system/translation.zod.ts (the translation slot's comment) cited this chain by line, sha-less. Re-read at objectui @b9814b334 on 2026-10-15: the object-then-global fallback both described, and this entry's evidence with them, is gone — objectui#11439 retired it, so the client resolves one bundle entry the way packages/spec/src/system/i18n-resolver.ts#actionTranslationNode does." }, "submitText": { @@ -118,7 +118,7 @@ "status": "live", "verifiedAt": "2026-10-03", "evidenceScope": "in-repo", - "evidence": "ui/packages/core/src/actions/ActionRunner.ts#composeSuccessMessage — the success toast's text: `outcomeMessages[outcome]` off the handler's return value, then `successMessage`, then the runner's default text, with the `{{ }}` holes (#27948; a holeless sentence keeps its transitional `${result.*}` reading) filled in whichever is shown (called from `handlePostExecution`); ui/packages/components/src/renderers/action/action-button.tsx#ActionButtonRenderer — forwards the key to the runner, and `action:bar` spreads a registered action onto `action:button` whole (`action-icon.tsx`, `action-group.tsx` and `action-menu.tsx` forward it too); packages/spec/src/ui/action.zod.ts#refuseInertOutcomeMessages (authoring-time: the map is refused on a type with no server response to report an outcome and beside `resultDialog`, which suppresses the toast; the declarative-update table refuses it beside `operation: 'update'`); packages/spec/src/system/i18n-resolver.ts#translateAction (the REST localization seam overlays each DECLARED outcome's translated copy at the action's one address: `_actions..outcomeMessages.` for a bound action, `globalActions` for an object-less one); packages/cli/src/utils/i18n-extract.ts#pushActionOutcomeMessages (one translation key per declared outcome, beside `successMessage`)", + "evidence": "ui/packages/core/src/actions/ActionRunner.ts#composeSuccessMessage — the success toast's text: `outcomeMessages[outcome]` off the handler's return value, then `successMessage`, then the runner's default text, with the `{{ }}` holes (#27948; a holeless sentence keeps its transitional `${result.*}` reading) filled in whichever is shown (called from `handlePostExecution`); ui/packages/components/src/renderers/action/action-button.tsx#ActionButtonRenderer — forwards the key to the runner, and `action:bar` spreads a registered action onto `action:button` whole (`action-icon.tsx`, `action-group.tsx` and `action-menu.tsx` forward it too); packages/spec/src/ui/action.zod.ts#refuseInertOutcomeMessages (authoring-time: the map is refused on a type with no server response to report an outcome and beside `resultDialog`, which suppresses the toast; the declarative-update table refuses it beside `operation: 'update'`); packages/spec/src/system/i18n-resolver.ts#translateAction (the REST localization seam overlays each DECLARED outcome's translated copy at the action's one address: `_actions..outcomeMessages.` for a bound action, `globalActions` for an object-less one); packages/lint/src/i18n-walk.ts#pushActionOutcomeMessages (one translation key per declared outcome, beside `successMessage`)", "note": "LIVE from the `.objectui-sha` pin ab1879721595 (2026-10-03): the console reader objectstack-ai/objectui#11344 (objectui c476be0e0) is in the pin's range 89cad75d5570..ab1879721595, so the flip this row prescribed lands on the pin bump that carries it, with `authorWarn` / `authorHint` dropped in the same edit. At 89cad75d5570 no objectui source outside `packages/types` named the key (0 hit lines), and `handlePostExecution` toasted `result.data.message || action.successMessage || runner default`; at ab1879721595 the server-message rung is gone and `composeSuccessMessage` selects the outcome copy. An `outcome` reaches it only from a handler that returns one, which a `script` handler's author writes beside the map and an `api` target returns in its body. History: PLANNED, carrier objectstack-ai/objectui#11344 (the console's success toast picks `outcomeMessages[result.outcome]`, interpolates ${result.*}, falls back to `successMessage`, then the runner default). Declared by ruling A on objectstack-ai/cloud#2315 (maintainer 2026-10-01), the mechanism half of ruling B there — the server returns facts, the console composes the message in the user's locale — landing spec+client first (#21095), the console second, cloud's producers LAST. Measured at objectui @e420df31: packages/core/src/actions/ActionRunner.ts#handlePostExecution composes the toast from `result.data.message || action.successMessage || runner default` and reads no `outcomeMessages`; re-read unchanged at objectui @31971ff1 (the `.objectui-sha` pin after the base merge), and objectui main 3c3ce15 has no reader either. The in-repo pieces cited in `evidence` validate, translate and extract the copy; none of them SELECTS or shows it, which is what a `live` verdict would claim. Flip to `live` on the commit that cites the console reader at the `.objectui-sha` pin — and drop `authorWarn` / `authorHint` in that same edit: a warned `live` row reds this gate (#21127). Re-read at objectui @b9814b334 on 2026-10-14 (the record moved out of packages/spec/src/ui/action.zod.ts, #23434): objectui @b9814b334 packages/core/src/actions/ActionRunner.ts#composeSuccessMessage unchanged; at objectui @89cad75d5570 no objectui source outside its types named the key." }, "errorMessage": { @@ -169,7 +169,7 @@ "status": "live", "verifiedAt": "2026-10-27", "evidenceScope": "in-repo", - "evidence": "ui/packages/components/src/renderers/layout/containers.tsx#disabledReasonFor (the record header, inline and in the ⋯ overflow: the declared reason, else `actions.notAvailableForRecord`); ui/packages/plugin-detail/src/renderers/record-quick-actions.tsx#QuickActionButton (the record section bar); ui/packages/app-shell/src/views/DeclaredActionsBar.tsx#DeclaredActionButton (the declared actions bar, button and override-menu item); ui/packages/components/src/renderers/action/disabled-reason.tsx#useDisabledReason (the four `action:*` renderers: `action-button.tsx`, `action-icon.tsx`, `action-group.tsx`, `action-menu.tsx`); ui/packages/react/src/hooks/useActionTextLocalizer.ts#useActionTextLocalizer and ui/packages/i18n/src/useObjectLabel.ts#useObjectLabel (`actionDisabledReason`: the bundle's `_actions.{a}.disabledReason` / `globalActions.{a}.disabledReason`, else the authored I18nLabel collapsed to the active language); packages/spec/src/system/i18n-resolver.ts#translateAction (the served overlay); packages/cli/src/utils/i18n-extract.ts#collectExpectedEntries (the extract).", + "evidence": "ui/packages/components/src/renderers/layout/containers.tsx#disabledReasonFor (the record header, inline and in the ⋯ overflow: the declared reason, else `actions.notAvailableForRecord`); ui/packages/plugin-detail/src/renderers/record-quick-actions.tsx#QuickActionButton (the record section bar); ui/packages/app-shell/src/views/DeclaredActionsBar.tsx#DeclaredActionButton (the declared actions bar, button and override-menu item); ui/packages/components/src/renderers/action/disabled-reason.tsx#useDisabledReason (the four `action:*` renderers: `action-button.tsx`, `action-icon.tsx`, `action-group.tsx`, `action-menu.tsx`); ui/packages/react/src/hooks/useActionTextLocalizer.ts#useActionTextLocalizer and ui/packages/i18n/src/useObjectLabel.ts#useObjectLabel (`actionDisabledReason`: the bundle's `_actions.{a}.disabledReason` / `globalActions.{a}.disabledReason`, else the authored I18nLabel collapsed to the active language); packages/spec/src/system/i18n-resolver.ts#translateAction (the served overlay); packages/lint/src/i18n-walk.ts#collectExpectedEntries (the extract).", "note": "LIVE since the console half of #26619 (ruling 6442737825, letter A: spec PR #26786 first, this console PR second). Each greyed-action surface shows the declared reason as the hover title and the accessible description (a menu item's second line), only while the DECLARED `disabled` predicate is TRUE, and shows `actions.notAvailableForRecord` (\"Not available for this record\") when the action declares none or a blank one. The record header, the section bar and the declared actions bar run actions through `useActionTextLocalizer`, so the bundle's sentence outranks the authored text; the four `action:*` renderers read the action as authored and honour its inline locale map. `authorWarn` / `authorHint` were dropped in the same edit (a warned `live` row reds this gate, #21127). Display only by contract: never a server admission input, never read as a predicate." }, "pressed": { diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 36f6e763660..341e45bfc5a 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -241,7 +241,7 @@ }, "emptyState": { "status": "live", - "evidence": "packages/cli/src/utils/i18n-extract.ts", + "evidence": "packages/lint/src/i18n-walk.ts", "note": "objectui: ListView empty-state panel (audit L15); CLI i18n extract emits _views..emptyState keys (spec CHANGELOG). [#23004] This blanket `live` verdict covers `emptyState.link` too. It was declared ahead of its renderer, contract first, with the `[PLANNED — not yet rendered]` marker on its describe; the renderer half, objectstack-ai/objectui#12270 (objectui b434f5f47), is inside the console pin from objectui b9814b334, and the marker is dropped. Measured there: ListView hands ListEmptyState the `link` (ListView.tsx:5716), drawn through the shared EmptyStateLink in the grid and gallery empty branch, and ObjectGrid draws it in its authored empty state (ObjectGrid.tsx:6897-6899). `view/list.emptyState` is a recorded undrilled container (undrilled-containers.baseline.json), so the walk reads no row for the member. The `link`'s draw site beside the add-record action (packages/spec/src/ui/view.zod.ts, EmptyStateSchema; moved out of spec source, #23434): objectui @b9814b334 packages/plugin-list/src/ListEmptyState.tsx#addRecordAction, inside #ListEmptyState." }, "aria": { diff --git a/scripts/check-i18n-bundles.mjs b/scripts/check-i18n-bundles.mjs index 979eba74be7..39698702162 100644 --- a/scripts/check-i18n-bundles.mjs +++ b/scripts/check-i18n-bundles.mjs @@ -156,7 +156,7 @@ const SURFACE_MODULE = 'scripts/i18n-bundle-surface.mjs'; /** * The two producers of the `metadataForms` TYPE-LEVEL surface, declared as * bare module-body path literals for the same reason as SURFACE_MODULE above - * (#9144). `walkMetadataForms` (packages/cli/src/utils/i18n-extract.ts) emits + * (#9144). `walkMetadataForms` (packages/lint/src/i18n-walk.ts) emits * `metadataForms..label` / `.description` for every entry of * `DEFAULT_METADATA_TYPE_REGISTRY` — including form-less types like * `datasource`/`job`/`translation` — and separately reads diff --git a/scripts/check-i18n-walk-parity.mjs b/scripts/check-i18n-walk-parity.mjs index 046700805cd..30f5dd0e0a0 100644 --- a/scripts/check-i18n-walk-parity.mjs +++ b/scripts/check-i18n-walk-parity.mjs @@ -161,12 +161,12 @@ const at = (rel) => join(REPO_ROOT, rel); /** The DECLARED side: `TranslationDataSchema`, from the built spec. */ const SPEC_SYSTEM_DIST = 'packages/spec/dist/system/index.mjs'; -/** The WALKED side: `collectExpectedEntries`, from the built CLI. */ -const CLI_WALKER_DIST = 'packages/cli/dist/utils/i18n-extract.js'; +/** The WALKED side: `collectExpectedEntries`, from the built CLI (re-exported from `@objectstack/lint`). */ +const CLI_WALKER_DIST = 'packages/cli/dist/utils/i18n-bundle-extract.js'; /** The schema module a reader edits when a group is added. Named, never read. */ const TRANSLATION_SCHEMA_SRC = 'packages/spec/src/system/translation.zod.ts'; /** The walker a reader edits when a group has no emitter. Named, never read. */ -const WALKER_SRC = 'packages/cli/src/utils/i18n-extract.ts'; +const WALKER_SRC = 'packages/lint/src/i18n-walk.ts'; /** A stack config authoring one member of every group the walker can reach. */ const FIXTURE = 'scripts/fixtures/i18n-walk-parity/every-group.stack.json'; @@ -175,10 +175,12 @@ const FIXTURE = 'scripts/fixtures/i18n-walk-parity/every-group.stack.json'; * derivation can extract it (a computed spelling contributes nothing and the * gate silently drops out of every card's brief). `packages/cli/src` arrives * inherited from `cli-build-prerequisite.mjs`; what this declaration adds is - * the OTHER side — the schema whose group set is half of the comparison, and - * the fixture whose thinness could fake a red. + * the walk itself, which lives in `@objectstack/lint` and so is outside that + * inherited tree, and the OTHER side — the schema whose group set is half of + * the comparison, and the fixture whose thinness could fake a red. */ const DECLARED_WATCH_HINTS = [ + 'packages/lint/src/i18n-walk.ts/**', 'packages/spec/src/system/translation.zod.ts/**', 'scripts/fixtures/i18n-walk-parity/**', ]; diff --git a/scripts/i18n-bundle-surface.mjs b/scripts/i18n-bundle-surface.mjs index b606568b24a..f17fd0194f6 100644 --- a/scripts/i18n-bundle-surface.mjs +++ b/scripts/i18n-bundle-surface.mjs @@ -52,7 +52,7 @@ * * What it still deliberately does NOT cover, stated so the next reader does * not mistake silence for coverage: the type-level half of the same surface. - * `walkMetadataForms` in packages/cli/src/utils/i18n-extract.ts emits + * `walkMetadataForms` in packages/lint/src/i18n-walk.ts emits * `metadataForms.TYPE.label`/`.description` for every entry of * DEFAULT_METADATA_TYPE_REGISTRY (packages/spec/src/kernel/metadata-plugin.zod.ts), * and the registry module itself (packages/spec/src/system/metadata-form- diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index 42616cbb4f4..fa826a6ac2a 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -9428,7 +9428,7 @@ export function selfTest({ tier = 'full' } = {}) { // Specimens, not classes: the extractor module whose edit really does move // the committed bundles, and a test file that compiles into nothing either // gate runs. Both live, so neither direction can pass over an empty set. - const CLI_SRC_SPECIMEN = 'packages/cli/src/utils/i18n-extract.ts'; + const CLI_SRC_SPECIMEN = 'packages/cli/src/utils/i18n-bundle-extract.ts'; const CLI_TEST_SPECIMEN = 'packages/cli/test/authoring-rule-command-parity.test.ts'; t( 'both CLI specimens are real tracked files, so the two directions below are live',