Skip to content

Commit ac8c233

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14639-osv-xmldom-qs-fix
2 parents c6f67d0 + dbf1152 commit ac8c233

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)