Skip to content

Commit dbf1152

Browse files
os-trumpclaude
andauthored
fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them (#14651)
* feat(cli): walk bulkActions, validation messages and datasets in i18n extract #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.<o>._views.<v>.bulkActions.<def>.*` 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.<o>._validations.<rule>.message`, with a `conditional` wrapper contributing no key of its own — its branch supplies the violation. - `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * fix(cli): ratchet the i18n coverage baseline for the three new families 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.<v>.description` does, so a def that authors no confirm copy still contributes those two keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * fix(cli): give the three i18n pins their `.js` import extensions `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * fix(cli): re-derive the i18n coverage baseline against a main carrying #14517 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4d0d944 commit dbf1152

7 files changed

Lines changed: 944 additions & 3 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
`os lint` and `os i18n extract` walk the three key families #14253 added — bulk
6+
actions, validation messages and datasets — so the coverage ratchet can see them
7+
8+
#14253 gave three authored display surfaces their first bundle keys and a
9+
resolver for each. Nothing on the CLI side walked them, and that costs twice:
10+
11+
1. `os i18n extract` scaffolded none of them, so a translator had to know the
12+
keys existed and hand-write them.
13+
2. **`check:i18n-coverage` could not see them.** That ratchet measures against
14+
what `collectExpectedEntries` produces, so a family the walk never visits
15+
contributes nothing to it — the number stays green while the surface it
16+
claims to describe grows. Third instance of the same shape (#11485 after
17+
#11287, #13109 after `translatePage` learned nested children).
18+
19+
The three families, each emitted at the address its resolver reads:
20+
21+
| family | keys | resolver |
22+
| --- | --- | --- |
23+
| bulk actions | `objects.<o>._views.<v>.bulkActions.<def>.{label,confirmText,confirmLabel,params.<p>.{label,help,placeholder}}` | `translateView``translateBulkActionDefs` |
24+
| validation messages | `objects.<o>._validations.<rule>.message` | the ObjectQL rule evaluator, via `objectValidationMessageKey` |
25+
| datasets | `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.label}` | `translateDataset` |
26+
27+
Three exclusions are measured rather than assumed, because the schema declares
28+
no slot for them and `.strict()` would reject a key: a bulk def's
29+
`successMessage` and `description`, and per-param `options`. A bulk param's hint
30+
is spelled `help` (an ACTION param spells the same idea `helpText`). A
31+
`conditional` validation rule contributes no key of its own — `checkConditional`
32+
returns the BRANCH's violation, so the wrapper's `message` never reaches a user.
33+
34+
`datasets` gets its own coverage bucket, so a gap reports as
35+
`i18n/missing-dataset` rather than folding into a neighbouring noun; bulk-action
36+
copy reports under `view` and a rule message under `object`, the buckets whose
37+
namespace each key lives in.

packages/cli/src/utils/i18n-coverage.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export interface CoverageIssue {
5555
| 'navigation'
5656
| 'dashboard'
5757
| 'widget'
58+
| 'dataset'
5859
| 'page'
5960
| 'flow'
6061
| 'metadataForm';
@@ -205,6 +206,15 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
205206
navigation: 'navigation',
206207
dashboard: 'dashboard',
207208
widget: 'widget',
209+
// Analytics dataset copy (`datasets.<d>.label`, `.description`, and each
210+
// dimension's / measure's `label`) — the author's own semantic layer, drawn
211+
// under every metric tile and on every chart axis, so it keeps its own
212+
// bucket and reports as `i18n/missing-dataset` rather than folding away with
213+
// `--include-platform`. A dataset is bound BY REFERENCE from N widgets
214+
// across M dashboards (ADR-0021 D1), which is also why it is not folded into
215+
// the `dashboard` bucket: the string is defined once, not once per
216+
// presentation.
217+
dataset: 'dataset',
208218
page: 'page',
209219
// Screen-flow copy (`flows.<f>.label`, `flows.<f>.screens.<n>.title`, and
210220
// the per-field `label` / `placeholder`) — the author's own wizard text, so
@@ -231,6 +241,7 @@ const SOURCE_NOUN: Record<CoverageIssue['source'], string> = {
231241
navigation: 'Navigation item',
232242
dashboard: 'Dashboard',
233243
widget: 'Widget',
244+
dataset: 'Dataset',
234245
page: 'Page',
235246
flow: 'Flow',
236247
metadataForm: 'Metadata form',

packages/cli/src/utils/i18n-extract.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@
3434
* objects.<name>._views.<view>.label
3535
* objects.<name>._views.<view>.description
3636
* objects.<name>._views.<view>.emptyState.title / .message
37+
* objects.<name>._views.<view>.bulkActions.<def>.label / .confirmText
38+
* / .confirmLabel
39+
* objects.<name>._views.<view>.bulkActions.<def>.params.<param>.label
40+
* / .help / .placeholder
41+
* ^ a bulk param spells its hint `help`; an ACTION param spells the same
42+
* idea `helpText` (`ui/bulk-action.zod.ts`'s known divergence)
43+
* objects.<name>._validations.<rule>.message
3744
* objects.<name>._actions.<action>.label
3845
* objects.<name>._actions.<action>.description
3946
* objects.<name>._actions.<action>.confirmText
@@ -48,6 +55,9 @@
4855
* apps.<app>.navigation.<id>.label
4956
* dashboards.<dash>.label / .description
5057
* dashboards.<dash>.widgets.<w>.title / .description
58+
* datasets.<dataset>.label / .description
59+
* datasets.<dataset>.dimensions.<dim>.label
60+
* datasets.<dataset>.measures.<measure>.label
5161
* pages.<page>.label / .description
5262
* pages.<page>.title / .subtitle (from the page's `page:header` component)
5363
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
@@ -125,6 +135,7 @@ export interface ExpectedEntry {
125135
| 'navigation'
126136
| 'dashboard'
127137
| 'widget'
138+
| 'dataset'
128139
| 'page'
129140
| 'flow'
130141
| 'metadataType'
@@ -274,6 +285,81 @@ function pushViewEntries(out: ExpectedEntry[], objectName: string, viewName: str
274285
pushDerived(out, [...root, 'label'], view?.label ?? viewName, inlineText(view?.label), 'view', { objectName });
275286
pushOptional(out, [...root, 'description'], view?.description, 'view', { objectName });
276287
pushViewEmptyState(out, root, view, objectName);
288+
pushBulkActionDefs(out, root, view, objectName);
289+
}
290+
291+
/**
292+
* Emit `_views.<view>.bulkActions.<def>.*` for a list view's authored
293+
* `bulkActionDefs[]` (#14253's resolver, #14376's walk).
294+
*
295+
* **Why this hangs off the VIEW and not the action pass.** A `bulkActionDefs`
296+
* entry is authored inside the view and is not an action document, so it never
297+
* reaches `translateAction` and no other pass here would ever see it. That is
298+
* the same reason `translateView` — not `translateAction` — is where the
299+
* resolver overlays it, and the reason `ObjectTranslationDataSchema` puts the
300+
* group under `_views.<view>` rather than beside `_actions`.
301+
*
302+
* **The view key is the caller's, deliberately.** `translateBulkActionDefs` is
303+
* called by `translateView` with `viewTranslationKey(view, objectName)` — the
304+
* bare `_views` key — so emitting under the same `root` this function already
305+
* built for `label` / `description` keeps the two halves keyed by construction
306+
* rather than by a second derivation (the #5164 lesson one surface over).
307+
*
308+
* **Read from the AUTHORED address.** The resolver reads
309+
* `config.bulkActionDefs` because a SERVED `ViewItem` nests the whole
310+
* `ListViewSchema` under `config`; this walker is handed the authored stack
311+
* config, where the defs sit on the list view itself — the same authored
312+
* addresses the rest of this file reads (`view.list.data.object`,
313+
* `obj.listViews`). Accepting the served spelling here as well would be a
314+
* tolerant alias for a shape this walk is never given.
315+
*
316+
* Three deliberate exclusions, each measured against `BulkActionDefSchema`
317+
* rather than mirrored from the report:
318+
*
319+
* - `successMessage` — a def declares none (the run reports a per-record
320+
* outcome summary the console words from its own catalog);
321+
* - `description` — a def declares none either; the sentence above the
322+
* affected-record summary IS `confirmText`;
323+
* - per-param `options` — `BulkActionParamTranslationSchema` carries
324+
* `guidance` against them instead of a key, so scaffolding them would
325+
* write keys `.strict()` then rejects.
326+
*
327+
* ⚠️ `help`, not `helpText`. A bulk param spells its hint `help`
328+
* (`BulkActionParamSchema.help`) where an ACTION param spells it `helpText` —
329+
* the known divergence `ui/bulk-action.zod.ts` names, and the one spelling the
330+
* translation face declares.
331+
*/
332+
function pushBulkActionDefs(out: ExpectedEntry[], viewRoot: string[], view: any, objectName: string): void {
333+
const defs = view?.bulkActionDefs;
334+
if (!Array.isArray(defs)) return;
335+
for (const def of defs) {
336+
if (!def || typeof def !== 'object') continue;
337+
const defName = def.name;
338+
if (typeof defName !== 'string' || defName.length === 0) continue;
339+
const base = [...viewRoot, 'bulkActions', defName];
340+
// The selection bar renders `def.label ?? formatActionLabel(def.name)`
341+
// (objectui `BulkActionBar.tsx`), so the humanized name is what a reader
342+
// actually sees when the author omitted a label — a usable seed, with
343+
// `inline` left unset so coverage never demands a translation of a string
344+
// nobody wrote.
345+
const authoredLabel = inlineText(def.label);
346+
pushDerived(out, [...base, 'label'], authoredLabel ?? humanizeFieldPath(defName), authoredLabel, 'view', { objectName });
347+
pushOptional(out, [...base, 'confirmText'], def.confirmText, 'view', { objectName });
348+
pushOptional(out, [...base, 'confirmLabel'], def.confirmLabel, 'view', { objectName });
349+
if (!Array.isArray(def.params)) continue;
350+
for (const param of def.params) {
351+
if (!param || typeof param !== 'object') continue;
352+
const pname = param.name;
353+
if (typeof pname !== 'string' || pname.length === 0) continue;
354+
const pbase = [...base, 'params', pname];
355+
// The dialog renders `param.label ?? param.name` — the bare name, the
356+
// same fallback `pushActionParams` seeds an inline action param from.
357+
const literalLabel = inlineText(param.label);
358+
pushDerived(out, [...pbase, 'label'], literalLabel ?? pname, literalLabel, 'view', { objectName });
359+
pushOptional(out, [...pbase, 'help'], param.help, 'view', { objectName });
360+
pushOptional(out, [...pbase, 'placeholder'], param.placeholder, 'view', { objectName });
361+
}
362+
}
277363
}
278364

279365
/**
@@ -424,6 +510,65 @@ function pushActionResultDialog(
424510
}
425511
}
426512

513+
/**
514+
* How deep a `conditional` chain is followed. `ValidationRuleSchema` is
515+
* recursive with no declared bound, and this walker is handed hand-authored
516+
* TypeScript — a shared branch object appearing under its own ancestor would
517+
* otherwise loop forever. Real nesting is two or three deep (the schema's own
518+
* worked examples stop at two).
519+
*/
520+
const MAX_VALIDATION_DEPTH = 10;
521+
522+
/**
523+
* Emit `objects.<object>._validations.<rule>.message` for an object's custom
524+
* validation rules (#14253's resolver, #14376's walk).
525+
*
526+
* `object.validations[].message` is the sentence a rejected write returns, and
527+
* the ObjectQL rule evaluator now resolves it through the engine's existing
528+
* `i18nService` channel at exactly this address
529+
* (`objectValidationMessageKey`, `spec/system/i18n-resolver.ts`). Without this
530+
* pass the address has a reader and a schema slot but nothing writes the
531+
* skeleton, so a deployment gets platform-generated refusals in the caller's
532+
* language and author-written ones in the source language, side by side in one
533+
* error envelope.
534+
*
535+
* **A `conditional` wrapper contributes no key of its own.** `checkConditional`
536+
* evaluates `when` and then returns `evaluateRule(branch, …)` — the BRANCH
537+
* supplies the violation, so the wrapper's own `message` never reaches a user.
538+
* Scaffolding it would offer a translator a string no rejected write can ever
539+
* show. The branches carry their own `name` and are addressed by it, which is
540+
* what both the resolver's JSDoc and `_validations`' schema note state.
541+
*
542+
* **`active: false` is not a reason to skip a rule.** It is a toggle on a
543+
* surface that exists, not the absence of one, and no other family in this
544+
* walker consults a runtime toggle — this walk reports what a config
545+
* DECLARES. Flipping the toggle back on must not silently owe a translation.
546+
*/
547+
function pushValidationMessages(
548+
out: ExpectedEntry[],
549+
objectName: string,
550+
rules: unknown,
551+
depth: number,
552+
): void {
553+
if (!Array.isArray(rules) || depth >= MAX_VALIDATION_DEPTH) return;
554+
for (const rule of rules) {
555+
if (!rule || typeof rule !== 'object') continue;
556+
const ruleName = (rule as any).name;
557+
if (typeof ruleName !== 'string' || ruleName.length === 0) continue;
558+
if ((rule as any).type === 'conditional') {
559+
pushValidationMessages(out, objectName, [(rule as any).then, (rule as any).otherwise], depth + 1);
560+
continue;
561+
}
562+
pushEntry(
563+
out,
564+
['objects', objectName, '_validations', ruleName, 'message'],
565+
inlineText((rule as any).message),
566+
'object',
567+
{ objectName },
568+
);
569+
}
570+
}
571+
427572
// ─── Object sections (`objects.<o>._sections.<section>.label`) ─────────
428573
//
429574
// A section heading is authored in TWO independent places and rendered from
@@ -862,6 +1007,9 @@ export function collectExpectedEntries(
8621007
pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
8631008
}
8641009
}
1010+
1011+
// Custom validation-rule rejection messages (`_validations.<rule>.message`).
1012+
pushValidationMessages(out, objectName, obj.validations, 0);
8651013
}
8661014

8671015
// ── Top-level views ──────────────────────────────────────────────
@@ -974,6 +1122,9 @@ export function collectExpectedEntries(
9741122
}
9751123
}
9761124

1125+
// ── Analytics datasets (`datasets.<name>.…`) ─────────────────────
1126+
walkDatasets(config, out);
1127+
9771128
// ── Pages + their `page:header` copy ──────────────────────────────
9781129
const pages: any[] = Array.isArray(config?.pages) ? config.pages : [];
9791130
for (const page of pages) {
@@ -1041,6 +1192,63 @@ export function collectExpectedEntries(
10411192
return out.filter((entry) => !warnedGroups.has(entry.path[0]));
10421193
}
10431194

1195+
// ─── Analytics datasets (`datasets.<name>.…`) ──────────────────────────
1196+
1197+
/**
1198+
* Emit the dataset copy surface (#14253's resolver, #14376's walk):
1199+
*
1200+
* datasets.<name>.label
1201+
* datasets.<name>.description
1202+
* datasets.<name>.dimensions.<dimension>.label
1203+
* datasets.<name>.measures.<measure>.label
1204+
*
1205+
* **Why a dataset is a display surface at all.** It reads like a back-office
1206+
* definition, but a measure label is drawn ON THE DASHBOARD — under every
1207+
* metric tile and on every chart axis. `translateDataset` is registered in
1208+
* `METADATA_DOCUMENT_TRANSLATORS`, so a served dataset is already localized at
1209+
* the REST boundary; this pass is the half that writes the skeleton.
1210+
*
1211+
* **Top level, not under `dashboards`.** A dataset is the one definition every
1212+
* presentation binds to BY REFERENCE (ADR-0021 D1): the same measure is drawn
1213+
* by N widgets across M dashboards, so addressing it under a dashboard would
1214+
* ask for the same string once per presentation and leave a dataset no
1215+
* dashboard references unaddressable.
1216+
*
1217+
* **`pushOptional`, not `pushDerived`, for every key here.** These four are
1218+
* `I18nLabelSchema` at the authoring site, so a value may already be an inline
1219+
* `{ en, 'zh-CN' }` map (#5728) — not source text to scaffold from, and
1220+
* `inlineText` narrows it away. And no renderer fallback is measured for a
1221+
* member that declares no `label` at all, so there is no reader-visible string
1222+
* to seed one from: recording the key without an `inline` keeps the coverage
1223+
* gate quiet about a string nobody wrote while still noticing a bundle that
1224+
* authors it. It is the same posture the resolver takes — `translateDataset`
1225+
* writes only where the bundle answered.
1226+
*
1227+
* The face stops at `label` below the dataset: `DatasetDimensionSchema` and
1228+
* `DatasetMeasureSchema` declare no `description` and say so in their own
1229+
* authoring guidance, so a `dimensions.<d>.description` key would parse clean
1230+
* and translate nothing.
1231+
*/
1232+
function walkDatasets(config: any, out: ExpectedEntry[]): void {
1233+
const datasets: any[] = Array.isArray(config?.datasets) ? config.datasets : [];
1234+
for (const dataset of datasets) {
1235+
if (!dataset || typeof dataset !== 'object') continue;
1236+
const name = dataset.name;
1237+
if (typeof name !== 'string' || name.length === 0) continue;
1238+
pushOptional(out, ['datasets', name, 'label'], dataset.label, 'dataset');
1239+
pushOptional(out, ['datasets', name, 'description'], dataset.description, 'dataset');
1240+
for (const group of ['dimensions', 'measures'] as const) {
1241+
const members: any[] = Array.isArray(dataset[group]) ? dataset[group] : [];
1242+
for (const member of members) {
1243+
if (!member || typeof member !== 'object') continue;
1244+
const memberName = member.name;
1245+
if (typeof memberName !== 'string' || memberName.length === 0) continue;
1246+
pushOptional(out, ['datasets', name, group, memberName, 'label'], member.label, 'dataset');
1247+
}
1248+
}
1249+
}
1250+
}
1251+
10441252
// ─── Screen flows (`flows.<flow>.screens.<node_id>.…`) ─────────────────
10451253

10461254
/**

0 commit comments

Comments
 (0)