From 2682e0f3a0f6fbf57f50063880d098a7c4e7ef19 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 11:05:38 +0000 Subject: [PATCH 1/5] feat(cli): walk bulkActions, validation messages and datasets in i18n extract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #14253 gave three authored display surfaces their first bundle keys and a resolver for each; the extractor walked none of them, so `os i18n extract` scaffolded nothing and `check:i18n-coverage` — which measures against that walk — was blind to all three. - `objects.._views..bulkActions..*` from a list view's authored `bulkActionDefs[]`, keyed under the same `_views` root the view's own label uses (`translateView` overlays it with `viewTranslationKey`). - `objects.._validations..message`, with a `conditional` wrapper contributing no key of its own — its branch supplies the violation. - `datasets..{label,description,dimensions..label,measures..label}`, top level because a dataset is bound by reference from N presentations. `dataset` gets its own coverage bucket (`i18n/missing-dataset`); bulk-action copy reports under `view` and a rule message under `object`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- ...li-i18n-extract-bulk-validation-dataset.md | 37 +++ packages/cli/src/utils/i18n-coverage.ts | 11 + packages/cli/src/utils/i18n-extract.ts | 208 ++++++++++++++ .../test/i18n-bulk-action-coverage.test.ts | 257 ++++++++++++++++++ .../cli/test/i18n-dataset-coverage.test.ts | 193 +++++++++++++ .../i18n-validation-message-coverage.test.ts | 218 +++++++++++++++ 6 files changed, 924 insertions(+) create mode 100644 .changeset/cli-i18n-extract-bulk-validation-dataset.md create mode 100644 packages/cli/test/i18n-bulk-action-coverage.test.ts create mode 100644 packages/cli/test/i18n-dataset-coverage.test.ts create mode 100644 packages/cli/test/i18n-validation-message-coverage.test.ts diff --git a/.changeset/cli-i18n-extract-bulk-validation-dataset.md b/.changeset/cli-i18n-extract-bulk-validation-dataset.md new file mode 100644 index 0000000000..0395d15c85 --- /dev/null +++ b/.changeset/cli-i18n-extract-bulk-validation-dataset.md @@ -0,0 +1,37 @@ +--- +'@objectstack/cli': minor +--- + +`os lint` and `os i18n extract` walk the three key families #14253 added — bulk +actions, validation messages and datasets — so the coverage ratchet can see them + +#14253 gave three authored display surfaces their first bundle keys and a +resolver for each. Nothing on the CLI side walked them, and that costs twice: + +1. `os i18n extract` scaffolded none of them, so a translator had to know the + keys existed and hand-write them. +2. **`check:i18n-coverage` could not see them.** That ratchet measures against + what `collectExpectedEntries` produces, so a family the walk never visits + contributes nothing to it — the number stays green while the surface it + claims to describe grows. Third instance of the same shape (#11485 after + #11287, #13109 after `translatePage` learned nested children). + +The three families, each emitted at the address its resolver reads: + +| family | keys | resolver | +| --- | --- | --- | +| bulk actions | `objects.._views..bulkActions..{label,confirmText,confirmLabel,params.

.{label,help,placeholder}}` | `translateView` → `translateBulkActionDefs` | +| validation messages | `objects.._validations..message` | the ObjectQL rule evaluator, via `objectValidationMessageKey` | +| datasets | `datasets..{label,description,dimensions..label,measures..label}` | `translateDataset` | + +Three exclusions are measured rather than assumed, because the schema declares +no slot for them and `.strict()` would reject a key: a bulk def's +`successMessage` and `description`, and per-param `options`. A bulk param's hint +is spelled `help` (an ACTION param spells the same idea `helpText`). A +`conditional` validation rule contributes no key of its own — `checkConditional` +returns the BRANCH's violation, so the wrapper's `message` never reaches a user. + +`datasets` gets its own coverage bucket, so a gap reports as +`i18n/missing-dataset` rather than folding into a neighbouring noun; bulk-action +copy reports under `view` and a rule message under `object`, the buckets whose +namespace each key lives in. diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index d94828ff0b..bdeb5c685d 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -55,6 +55,7 @@ export interface CoverageIssue { | 'navigation' | 'dashboard' | 'widget' + | 'dataset' | 'page' | 'flow' | 'metadataForm'; @@ -205,6 +206,15 @@ const COVERAGE_SOURCE: Record navigation: 'navigation', dashboard: 'dashboard', widget: 'widget', + // Analytics dataset copy (`datasets..label`, `.description`, and each + // dimension's / measure's `label`) — the author's own semantic layer, drawn + // under every metric tile and on every chart axis, so it keeps its own + // bucket and reports as `i18n/missing-dataset` rather than folding away with + // `--include-platform`. A dataset is bound BY REFERENCE from N widgets + // across M dashboards (ADR-0021 D1), which is also why it is not folded into + // the `dashboard` bucket: the string is defined once, not once per + // presentation. + dataset: 'dataset', page: 'page', // Screen-flow copy (`flows..label`, `flows..screens..title`, and // the per-field `label` / `placeholder`) — the author's own wizard text, so @@ -231,6 +241,7 @@ const SOURCE_NOUN: Record = { navigation: 'Navigation item', dashboard: 'Dashboard', widget: 'Widget', + dataset: 'Dataset', page: 'Page', flow: 'Flow', metadataForm: 'Metadata form', diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index e27c884fb1..13dc74a6d4 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -34,6 +34,13 @@ * objects.._views..label * objects.._views..description * objects.._views..emptyState.title / .message + * objects.._views..bulkActions..label / .confirmText + * / .confirmLabel + * objects.._views..bulkActions..params..label + * / .help / .placeholder + * ^ a bulk param spells its hint `help`; an ACTION param spells the same + * idea `helpText` (`ui/bulk-action.zod.ts`'s known divergence) + * objects.._validations..message * objects.._actions..label * objects.._actions..description * objects.._actions..confirmText @@ -48,6 +55,9 @@ * apps..navigation..label * dashboards..label / .description * dashboards..widgets..title / .description + * datasets..label / .description + * datasets..dimensions..label + * datasets..measures..label * pages..label / .description * pages..title / .subtitle (from the page's `page:header` component) * pages..components.. (per-component copy, #6080) @@ -125,6 +135,7 @@ export interface ExpectedEntry { | 'navigation' | 'dashboard' | 'widget' + | 'dataset' | 'page' | 'flow' | 'metadataType' @@ -274,6 +285,81 @@ function pushViewEntries(out: ExpectedEntry[], objectName: string, viewName: str pushDerived(out, [...root, 'label'], view?.label ?? viewName, inlineText(view?.label), 'view', { objectName }); pushOptional(out, [...root, 'description'], view?.description, 'view', { objectName }); pushViewEmptyState(out, root, view, objectName); + pushBulkActionDefs(out, root, view, objectName); +} + +/** + * Emit `_views..bulkActions..*` for a list view's authored + * `bulkActionDefs[]` (#14253's resolver, #14376's walk). + * + * **Why this hangs off the VIEW and not the action pass.** A `bulkActionDefs` + * entry is authored inside the view and is not an action document, so it never + * reaches `translateAction` and no other pass here would ever see it. That is + * the same reason `translateView` — not `translateAction` — is where the + * resolver overlays it, and the reason `ObjectTranslationDataSchema` puts the + * group under `_views.` rather than beside `_actions`. + * + * **The view key is the caller's, deliberately.** `translateBulkActionDefs` is + * called by `translateView` with `viewTranslationKey(view, objectName)` — the + * bare `_views` key — so emitting under the same `root` this function already + * built for `label` / `description` keeps the two halves keyed by construction + * rather than by a second derivation (the #5164 lesson one surface over). + * + * **Read from the AUTHORED address.** The resolver reads + * `config.bulkActionDefs` because a SERVED `ViewItem` nests the whole + * `ListViewSchema` under `config`; this walker is handed the authored stack + * config, where the defs sit on the list view itself — the same authored + * addresses the rest of this file reads (`view.list.data.object`, + * `obj.listViews`). Accepting the served spelling here as well would be a + * tolerant alias for a shape this walk is never given. + * + * Three deliberate exclusions, each measured against `BulkActionDefSchema` + * rather than mirrored from the report: + * + * - `successMessage` — a def declares none (the run reports a per-record + * outcome summary the console words from its own catalog); + * - `description` — a def declares none either; the sentence above the + * affected-record summary IS `confirmText`; + * - per-param `options` — `BulkActionParamTranslationSchema` carries + * `guidance` against them instead of a key, so scaffolding them would + * write keys `.strict()` then rejects. + * + * ⚠️ `help`, not `helpText`. A bulk param spells its hint `help` + * (`BulkActionParamSchema.help`) where an ACTION param spells it `helpText` — + * the known divergence `ui/bulk-action.zod.ts` names, and the one spelling the + * translation face declares. + */ +function pushBulkActionDefs(out: ExpectedEntry[], viewRoot: string[], view: any, objectName: string): void { + const defs = view?.bulkActionDefs; + if (!Array.isArray(defs)) return; + for (const def of defs) { + if (!def || typeof def !== 'object') continue; + const defName = def.name; + if (typeof defName !== 'string' || defName.length === 0) continue; + const base = [...viewRoot, 'bulkActions', defName]; + // The selection bar renders `def.label ?? formatActionLabel(def.name)` + // (objectui `BulkActionBar.tsx`), so the humanized name is what a reader + // actually sees when the author omitted a label — a usable seed, with + // `inline` left unset so coverage never demands a translation of a string + // nobody wrote. + const authoredLabel = inlineText(def.label); + pushDerived(out, [...base, 'label'], authoredLabel ?? humanizeFieldPath(defName), authoredLabel, 'view', { objectName }); + pushOptional(out, [...base, 'confirmText'], def.confirmText, 'view', { objectName }); + pushOptional(out, [...base, 'confirmLabel'], def.confirmLabel, 'view', { objectName }); + if (!Array.isArray(def.params)) continue; + for (const param of def.params) { + if (!param || typeof param !== 'object') continue; + const pname = param.name; + if (typeof pname !== 'string' || pname.length === 0) continue; + const pbase = [...base, 'params', pname]; + // The dialog renders `param.label ?? param.name` — the bare name, the + // same fallback `pushActionParams` seeds an inline action param from. + const literalLabel = inlineText(param.label); + pushDerived(out, [...pbase, 'label'], literalLabel ?? pname, literalLabel, 'view', { objectName }); + pushOptional(out, [...pbase, 'help'], param.help, 'view', { objectName }); + pushOptional(out, [...pbase, 'placeholder'], param.placeholder, 'view', { objectName }); + } + } } /** @@ -424,6 +510,65 @@ function pushActionResultDialog( } } +/** + * How deep a `conditional` chain is followed. `ValidationRuleSchema` is + * recursive with no declared bound, and this walker is handed hand-authored + * TypeScript — a shared branch object appearing under its own ancestor would + * otherwise loop forever. Real nesting is two or three deep (the schema's own + * worked examples stop at two). + */ +const MAX_VALIDATION_DEPTH = 10; + +/** + * Emit `objects.._validations..message` for an object's custom + * validation rules (#14253's resolver, #14376's walk). + * + * `object.validations[].message` is the sentence a rejected write returns, and + * the ObjectQL rule evaluator now resolves it through the engine's existing + * `i18nService` channel at exactly this address + * (`objectValidationMessageKey`, `spec/system/i18n-resolver.ts`). Without this + * pass the address has a reader and a schema slot but nothing writes the + * skeleton, so a deployment gets platform-generated refusals in the caller's + * language and author-written ones in the source language, side by side in one + * error envelope. + * + * **A `conditional` wrapper contributes no key of its own.** `checkConditional` + * evaluates `when` and then returns `evaluateRule(branch, …)` — the BRANCH + * supplies the violation, so the wrapper's own `message` never reaches a user. + * Scaffolding it would offer a translator a string no rejected write can ever + * show. The branches carry their own `name` and are addressed by it, which is + * what both the resolver's JSDoc and `_validations`' schema note state. + * + * **`active: false` is not a reason to skip a rule.** It is a toggle on a + * surface that exists, not the absence of one, and no other family in this + * walker consults a runtime toggle — this walk reports what a config + * DECLARES. Flipping the toggle back on must not silently owe a translation. + */ +function pushValidationMessages( + out: ExpectedEntry[], + objectName: string, + rules: unknown, + depth: number, +): void { + if (!Array.isArray(rules) || depth >= MAX_VALIDATION_DEPTH) return; + for (const rule of rules) { + if (!rule || typeof rule !== 'object') continue; + const ruleName = (rule as any).name; + if (typeof ruleName !== 'string' || ruleName.length === 0) continue; + if ((rule as any).type === 'conditional') { + pushValidationMessages(out, objectName, [(rule as any).then, (rule as any).otherwise], depth + 1); + continue; + } + pushEntry( + out, + ['objects', objectName, '_validations', ruleName, 'message'], + inlineText((rule as any).message), + 'object', + { objectName }, + ); + } +} + // ─── Object sections (`objects.._sections.
.label`) ───────── // // A section heading is authored in TWO independent places and rendered from @@ -862,6 +1007,9 @@ export function collectExpectedEntries( pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName); } } + + // Custom validation-rule rejection messages (`_validations..message`). + pushValidationMessages(out, objectName, obj.validations, 0); } // ── Top-level views ────────────────────────────────────────────── @@ -974,6 +1122,9 @@ export function collectExpectedEntries( } } + // ── Analytics datasets (`datasets..…`) ───────────────────── + walkDatasets(config, out); + // ── Pages + their `page:header` copy ────────────────────────────── const pages: any[] = Array.isArray(config?.pages) ? config.pages : []; for (const page of pages) { @@ -1041,6 +1192,63 @@ export function collectExpectedEntries( return out.filter((entry) => !warnedGroups.has(entry.path[0])); } +// ─── Analytics datasets (`datasets..…`) ────────────────────────── + +/** + * Emit the dataset copy surface (#14253's resolver, #14376's walk): + * + * datasets..label + * datasets..description + * datasets..dimensions..label + * datasets..measures..label + * + * **Why a dataset is a display surface at all.** It reads like a back-office + * definition, but a measure label is drawn ON THE DASHBOARD — under every + * metric tile and on every chart axis. `translateDataset` is registered in + * `METADATA_DOCUMENT_TRANSLATORS`, so a served dataset is already localized at + * the REST boundary; this pass is the half that writes the skeleton. + * + * **Top level, not under `dashboards`.** A dataset is the one definition every + * presentation binds to BY REFERENCE (ADR-0021 D1): the same measure is drawn + * by N widgets across M dashboards, so addressing it under a dashboard would + * ask for the same string once per presentation and leave a dataset no + * dashboard references unaddressable. + * + * **`pushOptional`, not `pushDerived`, for every key here.** These four are + * `I18nLabelSchema` at the authoring site, so a value may already be an inline + * `{ en, 'zh-CN' }` map (#5728) — not source text to scaffold from, and + * `inlineText` narrows it away. And no renderer fallback is measured for a + * member that declares no `label` at all, so there is no reader-visible string + * to seed one from: recording the key without an `inline` keeps the coverage + * gate quiet about a string nobody wrote while still noticing a bundle that + * authors it. It is the same posture the resolver takes — `translateDataset` + * writes only where the bundle answered. + * + * The face stops at `label` below the dataset: `DatasetDimensionSchema` and + * `DatasetMeasureSchema` declare no `description` and say so in their own + * authoring guidance, so a `dimensions..description` key would parse clean + * and translate nothing. + */ +function walkDatasets(config: any, out: ExpectedEntry[]): void { + const datasets: any[] = Array.isArray(config?.datasets) ? config.datasets : []; + for (const dataset of datasets) { + if (!dataset || typeof dataset !== 'object') continue; + const name = dataset.name; + if (typeof name !== 'string' || name.length === 0) continue; + pushOptional(out, ['datasets', name, 'label'], dataset.label, 'dataset'); + pushOptional(out, ['datasets', name, 'description'], dataset.description, 'dataset'); + for (const group of ['dimensions', 'measures'] as const) { + const members: any[] = Array.isArray(dataset[group]) ? dataset[group] : []; + for (const member of members) { + if (!member || typeof member !== 'object') continue; + const memberName = member.name; + if (typeof memberName !== 'string' || memberName.length === 0) continue; + pushOptional(out, ['datasets', name, group, memberName, 'label'], member.label, 'dataset'); + } + } + } +} + // ─── Screen flows (`flows..screens..…`) ───────────────── /** diff --git a/packages/cli/test/i18n-bulk-action-coverage.test.ts b/packages/cli/test/i18n-bulk-action-coverage.test.ts new file mode 100644 index 0000000000..065329c49d --- /dev/null +++ b/packages/cli/test/i18n-bulk-action-coverage.test.ts @@ -0,0 +1,257 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#14376, family 1 of 3 — a list view's `bulkActionDefs[]`. +// +// #14253 gave the selection bar its first bundle address: `translateView` +// overlays `objects.._views..bulkActions..*` onto the authored defs, +// and `ObjectTranslationDataSchema._views` declares the slot. The EXTRACTOR did +// not walk it, which costs twice over — `os i18n extract` scaffolds nothing, and +// `check:i18n-coverage` measures against what this walk produces, so the family +// contributed nothing to the ratchet at all. This file pins the walk. +// +// A def is authored INSIDE the view and is not an action document, so no other +// pass in the extractor would ever reach it — the same reason the resolver +// overlays it in `translateView` rather than `translateAction`. +// +// ⚠️ `help`, not `helpText`: a bulk param spells its hint `help` +// (`BulkActionParamSchema`), an action param spells the same idea `helpText`. +// The translation face follows the authored key, so a walk that emitted +// `helpText` here would scaffold keys `.strict()` rejects. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage'; +import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; + +/** Every `…_views..bulkActions.*` path the walker emits, as dot-paths. */ +const bulkKeys = (config: any): string[] => + collectExpectedEntries(config) + .filter((e) => e.path.includes('bulkActions')) + .map((e) => e.path.join('.')); + +const bulkEntries = (config: any) => + collectExpectedEntries(config).filter((e) => e.path.includes('bulkActions')); + +/** The `examples/app-showcase` project view shape: defs with params. */ +const defs = (): any[] => [ + { + name: 'set_labels', + label: 'Set Labels', + operation: 'update', + confirmText: 'Set these labels on every selected project?', + confirmLabel: 'Apply labels', + params: [ + { + name: 'labels', + label: 'Labels', + help: 'Applied to every selected project', + placeholder: 'Pick one or more', + type: 'select', + multiple: true, + options: [ + { label: 'Frontend', value: 'frontend' }, + { label: 'Backend', value: 'backend' }, + ], + }, + ], + }, + // A def with no authored copy at all — the bar renders its humanized name. + { name: 'archive_selected', operation: 'update' }, +]; + +/** An object-nested list view (the `project.object.ts` shape). */ +const objectConfig = (overrides: Record = {}) => ({ + objects: [ + { + name: 'showcase_project', + label: 'Project', + listViews: { + all: { label: 'All Projects', type: 'grid', bulkActionDefs: defs(), ...overrides }, + }, + }, + ], +}); + +describe('the extractor scaffolds a key for every string the selection bar draws', () => { + it('emits `objects.._views..bulkActions..*`', () => { + expect(bulkKeys(objectConfig()).sort()).toEqual([ + 'objects.showcase_project._views.all.bulkActions.archive_selected.label', + 'objects.showcase_project._views.all.bulkActions.set_labels.confirmLabel', + 'objects.showcase_project._views.all.bulkActions.set_labels.confirmText', + 'objects.showcase_project._views.all.bulkActions.set_labels.label', + 'objects.showcase_project._views.all.bulkActions.set_labels.params.labels.help', + 'objects.showcase_project._views.all.bulkActions.set_labels.params.labels.label', + 'objects.showcase_project._views.all.bulkActions.set_labels.params.labels.placeholder', + ]); + }); + + it('seeds each entry with the authored literal', () => { + const entries = bulkEntries(objectConfig()); + const label = entries.find((e) => e.path.at(-1) === 'label' && e.path.includes('set_labels')); + expect(label?.sourceValue).toBe('Set Labels'); + expect(label?.inline).toBe('Set Labels'); + expect(label?.objectName).toBe('showcase_project'); + + const confirm = entries.find((e) => e.path.at(-1) === 'confirmText'); + expect(confirm?.inline).toBe('Set these labels on every selected project?'); + }); + + it('falls back to the humanized def name, and leaves `inline` unset', () => { + // `BulkActionBar` renders `def.label ?? formatActionLabel(def.name)`, so the + // humanized name is what a reader sees — a usable seed. Coverage must not + // demand a translation of a string nobody authored. + const archive = bulkEntries(objectConfig()).find((e) => e.path.includes('archive_selected')); + expect(archive?.sourceValue).toBe('Archive Selected'); + expect(archive?.inline).toBeUndefined(); + }); + + it('falls back to the bare param name for a param with no label', () => { + // The dialog renders `param.label ?? param.name` — the bare name, exactly + // the fallback an inline ACTION param is seeded from. + const config = objectConfig(); + delete config.objects[0].listViews.all.bulkActionDefs[0].params[0].label; + const param = bulkEntries(config).find((e) => e.path.at(-1) === 'label' && e.path.includes('params')); + expect(param?.sourceValue).toBe('labels'); + expect(param?.inline).toBeUndefined(); + }); + + it('spells a param hint `help`, never `helpText`', () => { + const keys = bulkKeys(objectConfig()); + expect(keys).toContain('objects.showcase_project._views.all.bulkActions.set_labels.params.labels.help'); + expect(keys.some((k) => k.endsWith('.helpText'))).toBe(false); + }); + + it('does not scaffold the three keys the def surface deliberately excludes', () => { + // Measured against `BulkActionDefSchema`, not mirrored from the report: + // a def declares no `successMessage` and no `description`, and the + // translation face carries `guidance` against per-param `options` instead + // of a key. Emitting any of them would write keys `.strict()` rejects. + const config = objectConfig(); + config.objects[0].listViews.all.bulkActionDefs[0].successMessage = 'Done'; + config.objects[0].listViews.all.bulkActionDefs[0].description = 'Sets labels'; + const keys = bulkKeys(config); + expect(keys.some((k) => k.includes('.successMessage'))).toBe(false); + expect(keys.some((k) => k.endsWith('.bulkActions.set_labels.description'))).toBe(false); + expect(keys.some((k) => k.includes('.params.labels.options'))).toBe(false); + }); + + it('emits nothing for a view that declares no defs', () => { + expect(bulkKeys({ objects: [{ name: 'showcase_project', listViews: { all: { label: 'All' } } }] })).toEqual([]); + }); + + it('skips a def with no `name` — the key it would be addressed by', () => { + const config = objectConfig(); + config.objects[0].listViews.all.bulkActionDefs = [{ label: 'Nameless', operation: 'update' }]; + expect(bulkKeys(config)).toEqual([]); + }); +}); + +describe('the def keys share the view key `label` is emitted under', () => { + it('a container-authored list view keys both under the runtime view identity', () => { + // `translateBulkActionDefs` is called by `translateView` with + // `viewTranslationKey(view, objectName)` — the same bare `_views` key the + // view's own label resolves under. Deriving it twice is the #5164 defect + // one surface over, so the walk emits both under one root. + const container = { + views: [ + { + list: { data: { object: 'showcase_project' }, type: 'grid', bulkActionDefs: defs() }, + listViews: {}, + }, + ], + }; + const paths = collectExpectedEntries(container) + .filter((e) => e.path[1] === 'showcase_project' && e.path[2] === '_views') + .map((e) => e.path.join('.')); + + const viewKeys = new Set(paths.map((p) => p.split('.')[3])); + expect(viewKeys.size).toBe(1); + expect(paths).toContain(`objects.showcase_project._views.${[...viewKeys][0]}.label`); + expect(paths).toContain( + `objects.showcase_project._views.${[...viewKeys][0]}.bulkActions.set_labels.label`, + ); + }); +}); + +describe('the emitted key is the key the schema declares', () => { + it('`ObjectTranslationDataSchema` accepts a bundle written at the extracted paths', () => { + // Extractor and schema agreeing is the whole contract: a key `os i18n + // extract` writes that `.strict()` then rejects is worse than no key. + expect(() => + ObjectTranslationDataSchema.parse({ + _views: { + all: { + bulkActions: { + set_labels: { + label: '设置标签', + confirmText: '确认?', + confirmLabel: '应用', + params: { labels: { label: '标签', help: '帮助', placeholder: '选择' } }, + }, + }, + }, + }, + }), + ).not.toThrow(); + }); + + it('rejects the neighbouring `helpText` spelling — the slot did not go open', () => { + expect(() => + ObjectTranslationDataSchema.parse({ + _views: { all: { bulkActions: { set_labels: { params: { labels: { helpText: 'x' } } } } } }, + }), + ).toThrow(); + }); +}); + +describe('coverage', () => { + const config = () => ({ + ...objectConfig(), + i18n: { supportedLocales: ['en', 'zh-CN'] }, + translations: [ + { + 'zh-CN': { + objects: { + showcase_project: { + _views: { all: { bulkActions: { set_labels: { label: '设置标签' } } } }, + }, + }, + }, + }, + ], + }); + + const bulkIssues = (report: { issues: Array<{ key: string; source: string }> }) => + report.issues.filter((i) => i.key.includes('.bulkActions.')); + + it('reports the untranslated def copy and stays quiet about the translated label', () => { + const keys = bulkIssues(computeI18nCoverage(config())).map((i) => i.key); + expect(keys).not.toContain('objects.showcase_project._views.all.bulkActions.set_labels.label'); + expect(keys).toContain('objects.showcase_project._views.all.bulkActions.set_labels.confirmText'); + }); + + it('files the finding under the view bucket', () => { + // A bulk def is view copy: it lives under `_views.` in the schema and is + // overlaid by `translateView`, so it reports as `i18n/missing-view`. + expect(bulkIssues(computeI18nCoverage(config())).every((i) => i.source === 'view')).toBe(true); + }); + + it('demands nothing for the def nobody wrote copy for', () => { + // `archive_selected` has a derived seed and no `inline` — there is no + // source string to translate, so it is `required/label`'s business if any. + expect(bulkIssues(computeI18nCoverage(config())).some((i) => i.key.includes('archive_selected'))) + .toBe(false); + }); + + it('a project that declares no locales still reports nothing', () => { + expect(bulkIssues(computeI18nCoverage(objectConfig()))).toEqual([]); + }); + + it('`extractTranslations` writes the def keys into the skeleton', () => { + const out = extractTranslations(objectConfig(), { locales: ['zh-CN'] }); + const zh = out.bundles['zh-CN'] as any; + expect(zh?.objects?.showcase_project?._views?.all?.bulkActions?.set_labels).toBeDefined(); + expect(zh?.objects?.showcase_project?._views?.all?.bulkActions?.set_labels?.params?.labels) + .toBeDefined(); + }); +}); diff --git a/packages/cli/test/i18n-dataset-coverage.test.ts b/packages/cli/test/i18n-dataset-coverage.test.ts new file mode 100644 index 0000000000..6cd8bffa44 --- /dev/null +++ b/packages/cli/test/i18n-dataset-coverage.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#14376, family 3 of 3 — `datasets..…`. +// +// #14253 registered `translateDataset` in `METADATA_DOCUMENT_TRANSLATORS` (which +// is what `TRANSLATABLE_METADATA_TYPES` is derived from, so the REST boundary +// followed with nothing to remember) and declared the `datasets` group. The +// EXTRACTOR did not walk it, so `os i18n extract` scaffolded nothing and +// `check:i18n-coverage` — which measures against this walk — was blind to the +// family. +// +// A dataset reads like a back-office definition, but a measure label is drawn ON +// THE DASHBOARD, under every metric tile and on every chart axis: the #14253 +// report is a dashboard rendering Chinese tile titles with `Untouched > 14 days` +// directly beneath them. +// +// Top level, not under `dashboards`: a dataset is the ONE definition every +// presentation binds to by reference (ADR-0021 D1), so the same measure is drawn +// by N widgets across M dashboards and a dataset no dashboard references would +// otherwise be unaddressable. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage'; +import { TranslationDataSchema } from '@objectstack/spec/system'; + +/** Every `datasets.*` path the walker emits, as dot-paths. */ +const datasetKeys = (config: any): string[] => + collectExpectedEntries(config) + .filter((e) => e.path[0] === 'datasets') + .map((e) => e.path.join('.')); + +const datasetEntries = (config: any) => + collectExpectedEntries(config).filter((e) => e.path[0] === 'datasets'); + +/** The `examples/app-todo` dataset shape, trimmed to two members per group. */ +const config = (overrides: Record = {}) => ({ + datasets: [ + { + name: 'task_metrics', + label: 'Task Metrics', + description: 'Semantic layer for task counts and time-tracking measures', + object: 'todo_task', + dimensions: [ + { name: 'status', label: 'Status', field: 'status', type: 'string' }, + { name: 'due_date', label: 'Due Date', field: 'due_date', type: 'date' }, + ], + measures: [ + { name: 'task_count', label: 'Tasks', aggregate: 'count' }, + { name: 'est_hours', label: 'Estimated Hours', aggregate: 'sum', field: 'estimated_hours' }, + ], + ...overrides, + }, + ], +}); + +describe('the extractor scaffolds a key for every string a dataset draws', () => { + it('emits label, description and one `label` per dimension and measure', () => { + expect(datasetKeys(config()).sort()).toEqual([ + 'datasets.task_metrics.description', + 'datasets.task_metrics.dimensions.due_date.label', + 'datasets.task_metrics.dimensions.status.label', + 'datasets.task_metrics.label', + 'datasets.task_metrics.measures.est_hours.label', + 'datasets.task_metrics.measures.task_count.label', + ]); + }); + + it('seeds each entry with the authored literal', () => { + const entries = datasetEntries(config()); + const measure = entries.find((e) => e.path.join('.') === 'datasets.task_metrics.measures.task_count.label'); + expect(measure?.sourceValue).toBe('Tasks'); + expect(measure?.inline).toBe('Tasks'); + expect(measure?.source).toBe('dataset'); + }); + + it('records the key but seeds nothing for a member with no label', () => { + // No renderer fallback is measured for an unlabelled member, so there is no + // reader-visible string to seed one from. Recording the key still lets the + // coverage gate notice a bundle that authors it. + const entries = datasetEntries( + config({ measures: [{ name: 'task_count', aggregate: 'count' }] }), + ); + const measure = entries.find((e) => e.path.includes('task_count')); + expect(measure).toBeDefined(); + expect(measure?.sourceValue).toBeUndefined(); + expect(measure?.inline).toBeUndefined(); + }); + + it('treats an inline locale map as already multilingual (#5728)', () => { + // `Dataset.label` and each member's `label` are `I18nLabelSchema`, so an + // author may have written the map form. It is not plain source text: there + // is nothing to scaffold and nothing to demand, and `translateDataset` + // leaves such a value intact rather than flattening it to one language. + const entries = datasetEntries(config({ label: { en: 'Task Metrics', 'zh-CN': '任务指标' } })); + const label = entries.find((e) => e.path.join('.') === 'datasets.task_metrics.label'); + expect(label).toBeDefined(); + expect(label?.inline).toBeUndefined(); + }); + + it('emits no `description` below the dataset', () => { + // `DatasetDimensionSchema` / `DatasetMeasureSchema` declare none and say so + // in their own authoring guidance — a `dimensions..description` key + // would parse clean and translate nothing. + const keys = datasetKeys( + config({ dimensions: [{ name: 'status', label: 'Status', description: 'ignored' }] }), + ); + expect(keys.some((k) => k.startsWith('datasets.task_metrics.dimensions.') && k.endsWith('.description'))) + .toBe(false); + expect(keys).toContain('datasets.task_metrics.description'); + }); + + it('skips a dataset or member with no `name` — the key it would be addressed by', () => { + expect(datasetKeys({ datasets: [{ label: 'Nameless' }] })).toEqual([]); + expect(datasetKeys(config({ measures: [{ label: 'Nameless' }] })).some((k) => k.includes('.measures.'))) + .toBe(false); + }); + + it('emits nothing for a stack with no datasets', () => { + expect(datasetKeys({ objects: [{ name: 'todo_task' }] })).toEqual([]); + }); +}); + +describe('the emitted key is the key the schema declares', () => { + it('`TranslationDataSchema` accepts a bundle written at the extracted paths', () => { + expect(() => + TranslationDataSchema.parse({ + datasets: { + task_metrics: { + label: '任务指标', + description: '任务计数与工时的语义层', + dimensions: { status: { label: '状态' } }, + measures: { task_count: { label: '任务数' } }, + }, + }, + }), + ).not.toThrow(); + }); + + it('a member `description` is still rejected — the slot did not go open', () => { + expect(() => + TranslationDataSchema.parse({ + datasets: { task_metrics: { measures: { task_count: { label: '任务数', description: 'x' } } } }, + }), + ).toThrow(); + }); +}); + +describe('coverage', () => { + const withLocales = () => ({ + ...config(), + i18n: { supportedLocales: ['en', 'zh-CN'] }, + translations: [ + { 'zh-CN': { datasets: { task_metrics: { measures: { task_count: { label: '任务数' } } } } } }, + ], + }); + + const datasetIssues = (report: { issues: Array<{ key: string; source: string; message: string }> }) => + report.issues.filter((i) => i.key.startsWith('datasets.')); + + it('reports the untranslated measure and stays quiet about the translated one', () => { + const keys = datasetIssues(computeI18nCoverage(withLocales())).map((i) => i.key); + expect(keys).not.toContain('datasets.task_metrics.measures.task_count.label'); + expect(keys).toContain('datasets.task_metrics.measures.est_hours.label'); + expect(keys).toContain('datasets.task_metrics.dimensions.status.label'); + }); + + it('files the finding under its own bucket, so it reports as `i18n/missing-dataset`', () => { + const issues = datasetIssues(computeI18nCoverage(withLocales())); + expect(issues.length).toBeGreaterThan(0); + expect(issues.every((i) => i.source === 'dataset')).toBe(true); + expect(issues[0].message.startsWith('Dataset datasets.task_metrics.')).toBe(true); + }); + + it('demands nothing for a member label nobody wrote', () => { + const report = computeI18nCoverage({ + ...config({ measures: [{ name: 'task_count', aggregate: 'count' }] }), + i18n: { supportedLocales: ['en', 'zh-CN'] }, + }); + expect(datasetIssues(report).some((i) => i.key.includes('task_count'))).toBe(false); + }); + + it('a project that declares no locales still reports nothing', () => { + expect(datasetIssues(computeI18nCoverage(config()))).toEqual([]); + }); + + it('`extractTranslations` writes the dataset keys into the skeleton', () => { + const out = extractTranslations(config(), { locales: ['zh-CN'] }); + const zh = out.bundles['zh-CN'] as any; + expect(zh?.datasets?.task_metrics?.label).toBeDefined(); + expect(zh?.datasets?.task_metrics?.measures?.task_count?.label).toBeDefined(); + }); +}); diff --git a/packages/cli/test/i18n-validation-message-coverage.test.ts b/packages/cli/test/i18n-validation-message-coverage.test.ts new file mode 100644 index 0000000000..6af7ccb2d6 --- /dev/null +++ b/packages/cli/test/i18n-validation-message-coverage.test.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#14376, family 2 of 3 — `objects.._validations..message`. +// +// #14253 gave an author-written `validations[].message` its first bundle +// address: `objectValidationMessageKey` spells it, and the ObjectQL rule +// evaluator resolves it through the engine's EXISTING `i18nService` channel, so +// a rejected write returns the author's sentence in the caller's language the +// way the built-in field catalog already does. The EXTRACTOR did not walk it, so +// `os i18n extract` scaffolded nothing and `check:i18n-coverage` — which +// measures against this walk — could not see the family at all. +// +// The shape that decides this file: a `conditional` rule contributes NO key of +// its own. `checkConditional` evaluates `when` and returns the BRANCH's +// violation, so the wrapper's `message` never reaches a user; the branches carry +// their own `name` and are addressed by it. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage'; +import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; + +/** Every `objects.._validations.*` path the walker emits, as dot-paths. */ +const ruleKeys = (config: any): string[] => + collectExpectedEntries(config) + .filter((e) => e.path[2] === '_validations') + .map((e) => e.path.join('.')); + +const ruleEntries = (config: any) => + collectExpectedEntries(config).filter((e) => e.path[2] === '_validations'); + +/** The `examples/app-crm` opportunity shape: a script rule with admin prose. */ +const objectConfig = (validations?: unknown[]) => ({ + objects: [ + { + name: 'crm_opportunity', + label: 'Opportunity', + validations: validations ?? [ + { + type: 'script', + name: 'discount_cap', + label: 'Discount Cap 40%', + description: 'Discounts over 40% require special approval.', + condition: 'record.discount_percent > 40', + message: 'Discount cannot exceed 40% without an approved exception.', + severity: 'error', + }, + { + type: 'cross_field', + name: 'opp_close_date_not_past', + fields: ['close_date'], + condition: 'record.close_date < now()', + message: 'Close Date must be today or a future date.', + }, + ], + }, + ], +}); + +describe('the extractor scaffolds a key for every authored rejection sentence', () => { + it('emits `objects.._validations..message`', () => { + expect(ruleKeys(objectConfig()).sort()).toEqual([ + 'objects.crm_opportunity._validations.discount_cap.message', + 'objects.crm_opportunity._validations.opp_close_date_not_past.message', + ]); + }); + + it('seeds the entry with the authored sentence', () => { + const entry = ruleEntries(objectConfig()).find((e) => e.path[3] === 'discount_cap'); + expect(entry?.sourceValue).toBe('Discount cannot exceed 40% without an approved exception.'); + expect(entry?.inline).toBe('Discount cannot exceed 40% without an approved exception.'); + expect(entry?.objectName).toBe('crm_opportunity'); + }); + + it('emits `message` and nothing else — `label` and `description` are not user copy', () => { + // A rule's `label` is its entry in the admin rule listing and `description` + // is the maintainer's note; neither reaches a rejected caller, so a key for + // either would parse clean and translate nothing. The schema carries + // `guidance` against both rather than a slot. + const keys = ruleKeys(objectConfig()); + expect(keys.every((k) => k.endsWith('.message'))).toBe(true); + }); + + it('addresses a conditional rule by its BRANCH, never by the wrapper', () => { + // `checkConditional` returns `evaluateRule(branch, …)` — the branch supplies + // the violation the caller sees. Scaffolding the wrapper's own `message` + // would offer a translator a string no rejected write can show. + const keys = ruleKeys( + objectConfig([ + { + type: 'conditional', + name: 'enterprise_approval_required', + when: 'record.tier = "enterprise"', + message: 'Enterprise validation', + then: { + type: 'script', + name: 'require_approval', + message: 'Enterprise accounts require manager approval', + }, + otherwise: { + type: 'script', + name: 'standard_approval', + message: 'Standard accounts need a reviewer', + }, + }, + ]), + ); + expect(keys.sort()).toEqual([ + 'objects.crm_opportunity._validations.require_approval.message', + 'objects.crm_opportunity._validations.standard_approval.message', + ]); + }); + + it('follows a nested conditional down to the branch that speaks', () => { + const keys = ruleKeys( + objectConfig([ + { + type: 'conditional', + name: 'country_state_validation', + when: 'record.country = "US"', + message: 'US-specific validation', + then: { + type: 'conditional', + name: 'california_validation', + when: 'record.state = "CA"', + message: 'California-specific validation', + then: { + type: 'script', + name: 'ca_tax_id_required', + message: 'California requires a valid tax ID', + }, + }, + }, + ]), + ); + expect(keys).toEqual(['objects.crm_opportunity._validations.ca_tax_id_required.message']); + }); + + it('still emits for a rule switched off', () => { + // `active: false` is a toggle on a surface that exists, not the absence of + // one, and no other family in this walker consults a runtime toggle. + // Flipping it back on must not silently owe a translation. + const keys = ruleKeys( + objectConfig([ + { type: 'script', name: 'paused_rule', active: false, message: 'Paused but declared.' }, + ]), + ); + expect(keys).toEqual(['objects.crm_opportunity._validations.paused_rule.message']); + }); + + it('skips a rule with no `name` — the key it would be addressed by', () => { + expect(ruleKeys(objectConfig([{ type: 'script', message: 'Nameless.' }]))).toEqual([]); + }); + + it('emits nothing for an object that declares no validations', () => { + expect(ruleKeys({ objects: [{ name: 'crm_opportunity', label: 'Opportunity' }] })).toEqual([]); + }); +}); + +describe('the emitted key is the key the schema declares', () => { + it('`ObjectTranslationDataSchema` accepts a bundle written at the extracted path', () => { + const keys = ruleKeys(objectConfig()); + expect(keys.length).toBeGreaterThan(0); + const data: Record = {}; + for (const key of keys) data[key.split('.')[3]] = { message: '折扣不能超过 40%。' }; + + expect(() => ObjectTranslationDataSchema.parse({ _validations: data })).not.toThrow(); + }); + + it('a rule `label` is still rejected there — the slot did not go open', () => { + expect(() => + ObjectTranslationDataSchema.parse({ + _validations: { discount_cap: { message: 'ok', label: 'Discount Cap' } }, + }), + ).toThrow(); + }); +}); + +describe('coverage', () => { + const config = () => ({ + ...objectConfig(), + i18n: { supportedLocales: ['en', 'zh-CN'] }, + translations: [ + { + 'zh-CN': { + objects: { + crm_opportunity: { _validations: { discount_cap: { message: '折扣不能超过 40%。' } } }, + }, + }, + }, + ], + }); + + const ruleIssues = (report: { issues: Array<{ key: string; source: string }> }) => + report.issues.filter((i) => i.key.includes('._validations.')); + + it('reports the untranslated rule and stays quiet about the translated one', () => { + expect(ruleIssues(computeI18nCoverage(config())).map((i) => i.key)).toEqual([ + 'objects.crm_opportunity._validations.opp_close_date_not_past.message', + ]); + }); + + it('files the finding under the object bucket', () => { + // The address is object-scoped (`objects.._validations.…`), beside + // `_views` / `_actions` / `_tabs`, so it reports as `i18n/missing-object`. + expect(ruleIssues(computeI18nCoverage(config())).every((i) => i.source === 'object')).toBe(true); + }); + + it('a project that declares no locales still reports nothing', () => { + expect(ruleIssues(computeI18nCoverage(objectConfig()))).toEqual([]); + }); + + it('`extractTranslations` writes the rule keys into the skeleton', () => { + const out = extractTranslations(objectConfig(), { locales: ['zh-CN'] }); + const zh = out.bundles['zh-CN'] as any; + expect(zh?.objects?.crm_opportunity?._validations?.discount_cap?.message).toBeDefined(); + }); +}); From ea509bbeee631eaf2cc26f7870d49bbd2f8660c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 16:10:21 +0000 Subject: [PATCH 2/5] fix(cli): ratchet the i18n coverage baseline for the three new families MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three families the extract walk now visits are authored by the example apps and untranslated for the locales those apps claim to support, so the ratchet moves — and only there. Every unit is accounted for: examples/app-crm 89 -> 102 (+13: 5 _validations, 8 datasets) examples/app-showcase 393 -> 454 (+61: 18 bulkActions, 11 _validations, 32 datasets) examples/app-todo 120 -> 146 (+26: 4 _validations, 22 datasets) Every other line is byte-identical, including app-multi-package and all nine packages that own an `i18n-extract.config.ts` — no package authors a bulkActionDef, a validations[].message or a dataset, so platform bundles move zero and `check:i18n` stays in sync across all nine. The pre-existing per-config counts are unchanged (89 / 393 / 120 / 0 measured again after the change), so nothing already gated was dropped: the movement is new coverage, not a loosened gate. Also corrects the bulk-action pin's expected key list: `pushOptional` records an optional key with no seed, exactly as `_views..description` does, so a def that authors no confirm copy still contributes those two keys. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- .../cli/test/i18n-bulk-action-coverage.test.ts | 17 +++++++++++++++++ scripts/i18n-coverage-baseline.json | 6 +++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/cli/test/i18n-bulk-action-coverage.test.ts b/packages/cli/test/i18n-bulk-action-coverage.test.ts index 065329c49d..07da817edd 100644 --- a/packages/cli/test/i18n-bulk-action-coverage.test.ts +++ b/packages/cli/test/i18n-bulk-action-coverage.test.ts @@ -74,7 +74,13 @@ const objectConfig = (overrides: Record = {}) => ({ describe('the extractor scaffolds a key for every string the selection bar draws', () => { it('emits `objects.._views..bulkActions..*`', () => { + // `confirmText` / `confirmLabel` are recorded for BOTH defs even though + // only one authors them: `pushOptional` records an optional key with no + // seed and no `inline`, exactly as `_views..description` does one level + // up, so the coverage gate can still notice a bundle that authors it. expect(bulkKeys(objectConfig()).sort()).toEqual([ + 'objects.showcase_project._views.all.bulkActions.archive_selected.confirmLabel', + 'objects.showcase_project._views.all.bulkActions.archive_selected.confirmText', 'objects.showcase_project._views.all.bulkActions.archive_selected.label', 'objects.showcase_project._views.all.bulkActions.set_labels.confirmLabel', 'objects.showcase_project._views.all.bulkActions.set_labels.confirmText', @@ -96,6 +102,17 @@ describe('the extractor scaffolds a key for every string the selection bar draws expect(confirm?.inline).toBe('Set these labels on every selected project?'); }); + it('records an unauthored optional key without seeding it', () => { + // The other half of the contract above: the key exists so a bundle that + // authors it is recognised, but nothing is scaffolded and coverage demands + // nothing — there is no source string to translate. + const confirm = bulkEntries(objectConfig()) + .find((e) => e.path.join('.').endsWith('archive_selected.confirmText')); + expect(confirm).toBeDefined(); + expect(confirm?.sourceValue).toBeUndefined(); + expect(confirm?.inline).toBeUndefined(); + }); + it('falls back to the humanized def name, and leaves `inline` unset', () => { // `BulkActionBar` renders `def.label ?? formatActionLabel(def.name)`, so the // humanized name is what a reader sees — a usable seed. Coverage must not diff --git a/scripts/i18n-coverage-baseline.json b/scripts/i18n-coverage-baseline.json index 285cc3e550..0077dfde32 100644 --- a/scripts/i18n-coverage-baseline.json +++ b/scripts/i18n-coverage-baseline.json @@ -1,8 +1,8 @@ { - "examples/app-crm/objectstack.config.ts": 89, + "examples/app-crm/objectstack.config.ts": 102, "examples/app-multi-package/objectstack.config.ts": 0, - "examples/app-showcase/objectstack.config.ts": 393, - "examples/app-todo/objectstack.config.ts": 120, + "examples/app-showcase/objectstack.config.ts": 454, + "examples/app-todo/objectstack.config.ts": 146, "packages/platform-objects/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-audit/scripts/i18n-extract.config.ts": 0, From 3c94e561b8520c40a6e965d645b27cf966f73e80 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:04:52 +0000 Subject: [PATCH 3/5] fix(cli): give the three i18n pins their `.js` import extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:type-check-debt` measured @objectstack/cli's TEST_DEBT at 171 against a recorded 144 (+27) — the pins' own errors. Cause is the one the ledger entry already names twice: under `moduleResolution: NodeNext` an extension-less relative import does not resolve, so every symbol it names becomes `any` and each file grows a TS2835 plus a TS7006 per untyped binding. One extension per file, and the layer is back to its frozen number: `--re-measure` now reports 22 entries re-measured, none above its recorded number. The same one-import repair collapsed 35 -> 0 and 7 -> 0 in this very file family before; `platform-page-i18n-parity.test.ts` already carries it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- packages/cli/test/i18n-bulk-action-coverage.test.ts | 4 ++-- packages/cli/test/i18n-dataset-coverage.test.ts | 4 ++-- packages/cli/test/i18n-validation-message-coverage.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/test/i18n-bulk-action-coverage.test.ts b/packages/cli/test/i18n-bulk-action-coverage.test.ts index 07da817edd..3db1a21c65 100644 --- a/packages/cli/test/i18n-bulk-action-coverage.test.ts +++ b/packages/cli/test/i18n-bulk-action-coverage.test.ts @@ -19,8 +19,8 @@ // `helpText` here would scaffold keys `.strict()` rejects. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; -import { computeI18nCoverage } from '../src/utils/i18n-coverage'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; /** Every `…_views..bulkActions.*` path the walker emits, as dot-paths. */ diff --git a/packages/cli/test/i18n-dataset-coverage.test.ts b/packages/cli/test/i18n-dataset-coverage.test.ts index 6cd8bffa44..de9cf099e2 100644 --- a/packages/cli/test/i18n-dataset-coverage.test.ts +++ b/packages/cli/test/i18n-dataset-coverage.test.ts @@ -20,8 +20,8 @@ // otherwise be unaddressable. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; -import { computeI18nCoverage } from '../src/utils/i18n-coverage'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { TranslationDataSchema } from '@objectstack/spec/system'; /** Every `datasets.*` path the walker emits, as dot-paths. */ diff --git a/packages/cli/test/i18n-validation-message-coverage.test.ts b/packages/cli/test/i18n-validation-message-coverage.test.ts index 6af7ccb2d6..e52c3efd9e 100644 --- a/packages/cli/test/i18n-validation-message-coverage.test.ts +++ b/packages/cli/test/i18n-validation-message-coverage.test.ts @@ -16,8 +16,8 @@ // their own `name` and are addressed by it. import { describe, it, expect } from 'vitest'; -import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; -import { computeI18nCoverage } from '../src/utils/i18n-coverage'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; /** Every `objects.._validations.*` path the walker emits, as dot-paths. */ From ce637132fdc615ce108315c64660e1bbf7214814 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 20:17:42 +0000 Subject: [PATCH 4/5] fix(cli): re-derive the i18n coverage baseline against a main carrying #14517 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #14517 (the New Project wizard) landed on `main` after this branch measured, and it touched two of the showcase files these numbers are computed from. The collision is exactly on the family this branch introduces, so the number could not stand: `examples/app-showcase` re-derives 454 -> 450. The move is DOWNWARD and fully attributed. #14517 added a `_validations` block to the showcase bundle carrying zh-CN for four rule messages — `project_status_flow`, `project_health_progression`, `end_after_start`, `spent_within_budget` — and those are exactly the four findings that disappear: showcase `_validations` findings 11 -> 7 (-4, no key added) showcase bulkActions / datasets 18 / 32 (unchanged) pre-existing count 393 (unchanged) No new key appeared. `project_status_flow` already existed as a rule, so #14517's rewritten message text moved its content, not its address. Those four are the first real consumers of the key shape #14253 declared and this branch scaffolds: an author-written refusal that now reaches a zh-CN caller in Chinese, which the coverage gate can only see because the walk visits the family. Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand. Every other line is byte-identical. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- scripts/i18n-coverage-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n-coverage-baseline.json b/scripts/i18n-coverage-baseline.json index 0077dfde32..f1d8460672 100644 --- a/scripts/i18n-coverage-baseline.json +++ b/scripts/i18n-coverage-baseline.json @@ -1,7 +1,7 @@ { "examples/app-crm/objectstack.config.ts": 102, "examples/app-multi-package/objectstack.config.ts": 0, - "examples/app-showcase/objectstack.config.ts": 454, + "examples/app-showcase/objectstack.config.ts": 450, "examples/app-todo/objectstack.config.ts": 146, "packages/platform-objects/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts": 0, From fee694e4d6244c7841ccd57f15e5b44211002e5c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 21:41:15 +0000 Subject: [PATCH 5/5] fix(cli): re-derive the i18n coverage baseline against a main carrying #14709 #14709 put the remaining eight authored validation messages on the showcase's translation channel, moving the `objects.OBJECT._validations.RULE.message` population this branch is the first to walk. Re-derived with the repo's own tooling (`node scripts/check-i18n-coverage.mjs --update`), never by hand. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- scripts/i18n-coverage-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n-coverage-baseline.json b/scripts/i18n-coverage-baseline.json index f1d8460672..6365dbb7ea 100644 --- a/scripts/i18n-coverage-baseline.json +++ b/scripts/i18n-coverage-baseline.json @@ -1,7 +1,7 @@ { "examples/app-crm/objectstack.config.ts": 102, "examples/app-multi-package/objectstack.config.ts": 0, - "examples/app-showcase/objectstack.config.ts": 450, + "examples/app-showcase/objectstack.config.ts": 443, "examples/app-todo/objectstack.config.ts": 146, "packages/platform-objects/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts": 0,