Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/cli-i18n-extract-bulk-validation-dataset.md
Original file line numberDiff line numberDiff line change
@@ -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.<o>._views.<v>.bulkActions.<def>.{label,confirmText,confirmLabel,params.<p>.{label,help,placeholder}}` | `translateView` → `translateBulkActionDefs` |
| validation messages | `objects.<o>._validations.<rule>.message` | the ObjectQL rule evaluator, via `objectValidationMessageKey` |
| datasets | `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.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.
11 changes: 11 additions & 0 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export interface CoverageIssue {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataForm';
Expand DownExpand Up@@ -205,6 +206,15 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
navigation: 'navigation',
dashboard: 'dashboard',
widget: 'widget',
// Analytics dataset copy (`datasets.<d>.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.<f>.label`, `flows.<f>.screens.<n>.title`, and
// the per-field `label` / `placeholder`) — the author's own wizard text, so
Expand All@@ -231,6 +241,7 @@ const SOURCE_NOUN: Record<CoverageIssue['source'], string> = {
navigation: 'Navigation item',
dashboard: 'Dashboard',
widget: 'Widget',
dataset: 'Dataset',
page: 'Page',
flow: 'Flow',
metadataForm: 'Metadata form',
Expand Down
208 changes: 208 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,13 @@
* objects.<name>._views.<view>.label
* objects.<name>._views.<view>.description
* objects.<name>._views.<view>.emptyState.title / .message
* objects.<name>._views.<view>.bulkActions.<def>.label / .confirmText
* / .confirmLabel
* objects.<name>._views.<view>.bulkActions.<def>.params.<param>.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.<name>._validations.<rule>.message
* objects.<name>._actions.<action>.label
* objects.<name>._actions.<action>.description
* objects.<name>._actions.<action>.confirmText
Expand All@@ -48,6 +55,9 @@
* apps.<app>.navigation.<id>.label
* dashboards.<dash>.label / .description
* dashboards.<dash>.widgets.<w>.title / .description
* datasets.<dataset>.label / .description
* datasets.<dataset>.dimensions.<dim>.label
* datasets.<dataset>.measures.<measure>.label
* pages.<page>.label / .description
* pages.<page>.title / .subtitle (from the page's `page:header` component)
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
Expand DownExpand Up@@ -125,6 +135,7 @@ export interface ExpectedEntry {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataType'
Expand DownExpand Up@@ -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.<view>.bulkActions.<def>.*` 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.<view>` 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 });
}
}
}

/**
Expand DownExpand Up@@ -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.<object>._validations.<rule>.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.<o>._sections.<section>.label`) ─────────
//
// A section heading is authored in TWO independent places and rendered from
Expand DownExpand Up@@ -862,6 +1007,9 @@ export function collectExpectedEntries(
pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
}
}

// Custom validation-rule rejection messages (`_validations.<rule>.message`).
pushValidationMessages(out, objectName, obj.validations, 0);
}

// ── Top-level views ──────────────────────────────────────────────
Expand DownExpand Up@@ -974,6 +1122,9 @@ export function collectExpectedEntries(
}
}

// ── Analytics datasets (`datasets.<name>.…`) ─────────────────────
walkDatasets(config, out);

// ── Pages + their `page:header` copy ──────────────────────────────
const pages: any[] = Array.isArray(config?.pages) ? config.pages : [];
for (const page of pages) {
Expand DownExpand Up@@ -1041,6 +1192,63 @@ export function collectExpectedEntries(
return out.filter((entry) => !warnedGroups.has(entry.path[0]));
}

// ─── Analytics datasets (`datasets.<name>.…`) ──────────────────────────

/**
* Emit the dataset copy surface (#14253's resolver, #14376's walk):
*
* datasets.<name>.label
* datasets.<name>.description
* datasets.<name>.dimensions.<dimension>.label
* datasets.<name>.measures.<measure>.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.<d>.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.<flow>.screens.<node_id>.…`) ─────────────────

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/cli-i18n-extract-bulk-validation-dataset.md
Original file line numberDiff line numberDiff line change
@@ -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.<o>._views.<v>.bulkActions.<def>.{label,confirmText,confirmLabel,params.<p>.{label,help,placeholder}}` | `translateView` → `translateBulkActionDefs` |
| validation messages | `objects.<o>._validations.<rule>.message` | the ObjectQL rule evaluator, via `objectValidationMessageKey` |
| datasets | `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.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.
11 changes: 11 additions & 0 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export interface CoverageIssue {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataForm';
Expand DownExpand Up@@ -205,6 +206,15 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
navigation: 'navigation',
dashboard: 'dashboard',
widget: 'widget',
// Analytics dataset copy (`datasets.<d>.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.<f>.label`, `flows.<f>.screens.<n>.title`, and
// the per-field `label` / `placeholder`) — the author's own wizard text, so
Expand All@@ -231,6 +241,7 @@ const SOURCE_NOUN: Record<CoverageIssue['source'], string> = {
navigation: 'Navigation item',
dashboard: 'Dashboard',
widget: 'Widget',
dataset: 'Dataset',
page: 'Page',
flow: 'Flow',
metadataForm: 'Metadata form',
Expand Down
208 changes: 208 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,13 @@
* objects.<name>._views.<view>.label
* objects.<name>._views.<view>.description
* objects.<name>._views.<view>.emptyState.title / .message
* objects.<name>._views.<view>.bulkActions.<def>.label / .confirmText
* / .confirmLabel
* objects.<name>._views.<view>.bulkActions.<def>.params.<param>.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.<name>._validations.<rule>.message
* objects.<name>._actions.<action>.label
* objects.<name>._actions.<action>.description
* objects.<name>._actions.<action>.confirmText
Expand All@@ -48,6 +55,9 @@
* apps.<app>.navigation.<id>.label
* dashboards.<dash>.label / .description
* dashboards.<dash>.widgets.<w>.title / .description
* datasets.<dataset>.label / .description
* datasets.<dataset>.dimensions.<dim>.label
* datasets.<dataset>.measures.<measure>.label
* pages.<page>.label / .description
* pages.<page>.title / .subtitle (from the page's `page:header` component)
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
Expand DownExpand Up@@ -125,6 +135,7 @@ export interface ExpectedEntry {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataType'
Expand DownExpand Up@@ -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.<view>.bulkActions.<def>.*` 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.<view>` 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 });
}
}
}

/**
Expand DownExpand Up@@ -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.<object>._validations.<rule>.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.<o>._sections.<section>.label`) ─────────
//
// A section heading is authored in TWO independent places and rendered from
Expand DownExpand Up@@ -862,6 +1007,9 @@ export function collectExpectedEntries(
pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
}
}

// Custom validation-rule rejection messages (`_validations.<rule>.message`).
pushValidationMessages(out, objectName, obj.validations, 0);
}

// ── Top-level views ──────────────────────────────────────────────
Expand DownExpand Up@@ -974,6 +1122,9 @@ export function collectExpectedEntries(
}
}

// ── Analytics datasets (`datasets.<name>.…`) ─────────────────────
walkDatasets(config, out);

// ── Pages + their `page:header` copy ──────────────────────────────
const pages: any[] = Array.isArray(config?.pages) ? config.pages : [];
for (const page of pages) {
Expand DownExpand Up@@ -1041,6 +1192,63 @@ export function collectExpectedEntries(
return out.filter((entry) => !warnedGroups.has(entry.path[0]));
}

// ─── Analytics datasets (`datasets.<name>.…`) ──────────────────────────

/**
* Emit the dataset copy surface (#14253's resolver, #14376's walk):
*
* datasets.<name>.label
* datasets.<name>.description
* datasets.<name>.dimensions.<dimension>.label
* datasets.<name>.measures.<measure>.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.<d>.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.<flow>.screens.<node_id>.…`) ─────────────────

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/cli-i18n-extract-bulk-validation-dataset.md
Original file line numberDiff line numberDiff line change
@@ -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.<o>._views.<v>.bulkActions.<def>.{label,confirmText,confirmLabel,params.<p>.{label,help,placeholder}}` | `translateView` → `translateBulkActionDefs` |
| validation messages | `objects.<o>._validations.<rule>.message` | the ObjectQL rule evaluator, via `objectValidationMessageKey` |
| datasets | `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.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.
11 changes: 11 additions & 0 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export interface CoverageIssue {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataForm';
Expand DownExpand Up@@ -205,6 +206,15 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
navigation: 'navigation',
dashboard: 'dashboard',
widget: 'widget',
// Analytics dataset copy (`datasets.<d>.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.<f>.label`, `flows.<f>.screens.<n>.title`, and
// the per-field `label` / `placeholder`) — the author's own wizard text, so
Expand All@@ -231,6 +241,7 @@ const SOURCE_NOUN: Record<CoverageIssue['source'], string> = {
navigation: 'Navigation item',
dashboard: 'Dashboard',
widget: 'Widget',
dataset: 'Dataset',
page: 'Page',
flow: 'Flow',
metadataForm: 'Metadata form',
Expand Down
208 changes: 208 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,13 @@
* objects.<name>._views.<view>.label
* objects.<name>._views.<view>.description
* objects.<name>._views.<view>.emptyState.title / .message
* objects.<name>._views.<view>.bulkActions.<def>.label / .confirmText
* / .confirmLabel
* objects.<name>._views.<view>.bulkActions.<def>.params.<param>.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.<name>._validations.<rule>.message
* objects.<name>._actions.<action>.label
* objects.<name>._actions.<action>.description
* objects.<name>._actions.<action>.confirmText
Expand All@@ -48,6 +55,9 @@
* apps.<app>.navigation.<id>.label
* dashboards.<dash>.label / .description
* dashboards.<dash>.widgets.<w>.title / .description
* datasets.<dataset>.label / .description
* datasets.<dataset>.dimensions.<dim>.label
* datasets.<dataset>.measures.<measure>.label
* pages.<page>.label / .description
* pages.<page>.title / .subtitle (from the page's `page:header` component)
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
Expand DownExpand Up@@ -125,6 +135,7 @@ export interface ExpectedEntry {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataType'
Expand DownExpand Up@@ -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.<view>.bulkActions.<def>.*` 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.<view>` 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 });
}
}
}

/**
Expand DownExpand Up@@ -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.<object>._validations.<rule>.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.<o>._sections.<section>.label`) ─────────
//
// A section heading is authored in TWO independent places and rendered from
Expand DownExpand Up@@ -862,6 +1007,9 @@ export function collectExpectedEntries(
pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
}
}

// Custom validation-rule rejection messages (`_validations.<rule>.message`).
pushValidationMessages(out, objectName, obj.validations, 0);
}

// ── Top-level views ──────────────────────────────────────────────
Expand DownExpand Up@@ -974,6 +1122,9 @@ export function collectExpectedEntries(
}
}

// ── Analytics datasets (`datasets.<name>.…`) ─────────────────────
walkDatasets(config, out);

// ── Pages + their `page:header` copy ──────────────────────────────
const pages: any[] = Array.isArray(config?.pages) ? config.pages : [];
for (const page of pages) {
Expand DownExpand Up@@ -1041,6 +1192,63 @@ export function collectExpectedEntries(
return out.filter((entry) => !warnedGroups.has(entry.path[0]));
}

// ─── Analytics datasets (`datasets.<name>.…`) ──────────────────────────

/**
* Emit the dataset copy surface (#14253's resolver, #14376's walk):
*
* datasets.<name>.label
* datasets.<name>.description
* datasets.<name>.dimensions.<dimension>.label
* datasets.<name>.measures.<measure>.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.<d>.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.<flow>.screens.<node_id>.…`) ─────────────────

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/cli-i18n-extract-bulk-validation-dataset.md
Original file line numberDiff line numberDiff line change
@@ -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.<o>._views.<v>.bulkActions.<def>.{label,confirmText,confirmLabel,params.<p>.{label,help,placeholder}}` | `translateView` → `translateBulkActionDefs` |
| validation messages | `objects.<o>._validations.<rule>.message` | the ObjectQL rule evaluator, via `objectValidationMessageKey` |
| datasets | `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.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.
11 changes: 11 additions & 0 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export interface CoverageIssue {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataForm';
Expand DownExpand Up@@ -205,6 +206,15 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
navigation: 'navigation',
dashboard: 'dashboard',
widget: 'widget',
// Analytics dataset copy (`datasets.<d>.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.<f>.label`, `flows.<f>.screens.<n>.title`, and
// the per-field `label` / `placeholder`) — the author's own wizard text, so
Expand All@@ -231,6 +241,7 @@ const SOURCE_NOUN: Record<CoverageIssue['source'], string> = {
navigation: 'Navigation item',
dashboard: 'Dashboard',
widget: 'Widget',
dataset: 'Dataset',
page: 'Page',
flow: 'Flow',
metadataForm: 'Metadata form',
Expand Down
208 changes: 208 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,13 @@
* objects.<name>._views.<view>.label
* objects.<name>._views.<view>.description
* objects.<name>._views.<view>.emptyState.title / .message
* objects.<name>._views.<view>.bulkActions.<def>.label / .confirmText
* / .confirmLabel
* objects.<name>._views.<view>.bulkActions.<def>.params.<param>.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.<name>._validations.<rule>.message
* objects.<name>._actions.<action>.label
* objects.<name>._actions.<action>.description
* objects.<name>._actions.<action>.confirmText
Expand All@@ -48,6 +55,9 @@
* apps.<app>.navigation.<id>.label
* dashboards.<dash>.label / .description
* dashboards.<dash>.widgets.<w>.title / .description
* datasets.<dataset>.label / .description
* datasets.<dataset>.dimensions.<dim>.label
* datasets.<dataset>.measures.<measure>.label
* pages.<page>.label / .description
* pages.<page>.title / .subtitle (from the page's `page:header` component)
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
Expand DownExpand Up@@ -125,6 +135,7 @@ export interface ExpectedEntry {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataType'
Expand DownExpand Up@@ -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.<view>.bulkActions.<def>.*` 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.<view>` 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 });
}
}
}

/**
Expand DownExpand Up@@ -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.<object>._validations.<rule>.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.<o>._sections.<section>.label`) ─────────
//
// A section heading is authored in TWO independent places and rendered from
Expand DownExpand Up@@ -862,6 +1007,9 @@ export function collectExpectedEntries(
pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
}
}

// Custom validation-rule rejection messages (`_validations.<rule>.message`).
pushValidationMessages(out, objectName, obj.validations, 0);
}

// ── Top-level views ──────────────────────────────────────────────
Expand DownExpand Up@@ -974,6 +1122,9 @@ export function collectExpectedEntries(
}
}

// ── Analytics datasets (`datasets.<name>.…`) ─────────────────────
walkDatasets(config, out);

// ── Pages + their `page:header` copy ──────────────────────────────
const pages: any[] = Array.isArray(config?.pages) ? config.pages : [];
for (const page of pages) {
Expand DownExpand Up@@ -1041,6 +1192,63 @@ export function collectExpectedEntries(
return out.filter((entry) => !warnedGroups.has(entry.path[0]));
}

// ─── Analytics datasets (`datasets.<name>.…`) ──────────────────────────

/**
* Emit the dataset copy surface (#14253's resolver, #14376's walk):
*
* datasets.<name>.label
* datasets.<name>.description
* datasets.<name>.dimensions.<dimension>.label
* datasets.<name>.measures.<measure>.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.<d>.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.<flow>.screens.<node_id>.…`) ─────────────────

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/cli-i18n-extract-bulk-validation-dataset.md
Original file line numberDiff line numberDiff line change
@@ -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.<o>._views.<v>.bulkActions.<def>.{label,confirmText,confirmLabel,params.<p>.{label,help,placeholder}}` | `translateView` → `translateBulkActionDefs` |
| validation messages | `objects.<o>._validations.<rule>.message` | the ObjectQL rule evaluator, via `objectValidationMessageKey` |
| datasets | `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.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.
11 changes: 11 additions & 0 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export interface CoverageIssue {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataForm';
Expand DownExpand Up@@ -205,6 +206,15 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
navigation: 'navigation',
dashboard: 'dashboard',
widget: 'widget',
// Analytics dataset copy (`datasets.<d>.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.<f>.label`, `flows.<f>.screens.<n>.title`, and
// the per-field `label` / `placeholder`) — the author's own wizard text, so
Expand All@@ -231,6 +241,7 @@ const SOURCE_NOUN: Record<CoverageIssue['source'], string> = {
navigation: 'Navigation item',
dashboard: 'Dashboard',
widget: 'Widget',
dataset: 'Dataset',
page: 'Page',
flow: 'Flow',
metadataForm: 'Metadata form',
Expand Down
208 changes: 208 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,13 @@
* objects.<name>._views.<view>.label
* objects.<name>._views.<view>.description
* objects.<name>._views.<view>.emptyState.title / .message
* objects.<name>._views.<view>.bulkActions.<def>.label / .confirmText
* / .confirmLabel
* objects.<name>._views.<view>.bulkActions.<def>.params.<param>.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.<name>._validations.<rule>.message
* objects.<name>._actions.<action>.label
* objects.<name>._actions.<action>.description
* objects.<name>._actions.<action>.confirmText
Expand All@@ -48,6 +55,9 @@
* apps.<app>.navigation.<id>.label
* dashboards.<dash>.label / .description
* dashboards.<dash>.widgets.<w>.title / .description
* datasets.<dataset>.label / .description
* datasets.<dataset>.dimensions.<dim>.label
* datasets.<dataset>.measures.<measure>.label
* pages.<page>.label / .description
* pages.<page>.title / .subtitle (from the page's `page:header` component)
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
Expand DownExpand Up@@ -125,6 +135,7 @@ export interface ExpectedEntry {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataType'
Expand DownExpand Up@@ -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.<view>.bulkActions.<def>.*` 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.<view>` 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 });
}
}
}

/**
Expand DownExpand Up@@ -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.<object>._validations.<rule>.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.<o>._sections.<section>.label`) ─────────
//
// A section heading is authored in TWO independent places and rendered from
Expand DownExpand Up@@ -862,6 +1007,9 @@ export function collectExpectedEntries(
pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
}
}

// Custom validation-rule rejection messages (`_validations.<rule>.message`).
pushValidationMessages(out, objectName, obj.validations, 0);
}

// ── Top-level views ──────────────────────────────────────────────
Expand DownExpand Up@@ -974,6 +1122,9 @@ export function collectExpectedEntries(
}
}

// ── Analytics datasets (`datasets.<name>.…`) ─────────────────────
walkDatasets(config, out);

// ── Pages + their `page:header` copy ──────────────────────────────
const pages: any[] = Array.isArray(config?.pages) ? config.pages : [];
for (const page of pages) {
Expand DownExpand Up@@ -1041,6 +1192,63 @@ export function collectExpectedEntries(
return out.filter((entry) => !warnedGroups.has(entry.path[0]));
}

// ─── Analytics datasets (`datasets.<name>.…`) ──────────────────────────

/**
* Emit the dataset copy surface (#14253's resolver, #14376's walk):
*
* datasets.<name>.label
* datasets.<name>.description
* datasets.<name>.dimensions.<dimension>.label
* datasets.<name>.measures.<measure>.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.<d>.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.<flow>.screens.<node_id>.…`) ─────────────────

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/cli-i18n-extract-bulk-validation-dataset.md
Original file line numberDiff line numberDiff line change
@@ -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.<o>._views.<v>.bulkActions.<def>.{label,confirmText,confirmLabel,params.<p>.{label,help,placeholder}}` | `translateView` → `translateBulkActionDefs` |
| validation messages | `objects.<o>._validations.<rule>.message` | the ObjectQL rule evaluator, via `objectValidationMessageKey` |
| datasets | `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.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.
11 changes: 11 additions & 0 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export interface CoverageIssue {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataForm';
Expand DownExpand Up@@ -205,6 +206,15 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
navigation: 'navigation',
dashboard: 'dashboard',
widget: 'widget',
// Analytics dataset copy (`datasets.<d>.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.<f>.label`, `flows.<f>.screens.<n>.title`, and
// the per-field `label` / `placeholder`) — the author's own wizard text, so
Expand All@@ -231,6 +241,7 @@ const SOURCE_NOUN: Record<CoverageIssue['source'], string> = {
navigation: 'Navigation item',
dashboard: 'Dashboard',
widget: 'Widget',
dataset: 'Dataset',
page: 'Page',
flow: 'Flow',
metadataForm: 'Metadata form',
Expand Down
208 changes: 208 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,13 @@
* objects.<name>._views.<view>.label
* objects.<name>._views.<view>.description
* objects.<name>._views.<view>.emptyState.title / .message
* objects.<name>._views.<view>.bulkActions.<def>.label / .confirmText
* / .confirmLabel
* objects.<name>._views.<view>.bulkActions.<def>.params.<param>.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.<name>._validations.<rule>.message
* objects.<name>._actions.<action>.label
* objects.<name>._actions.<action>.description
* objects.<name>._actions.<action>.confirmText
Expand All@@ -48,6 +55,9 @@
* apps.<app>.navigation.<id>.label
* dashboards.<dash>.label / .description
* dashboards.<dash>.widgets.<w>.title / .description
* datasets.<dataset>.label / .description
* datasets.<dataset>.dimensions.<dim>.label
* datasets.<dataset>.measures.<measure>.label
* pages.<page>.label / .description
* pages.<page>.title / .subtitle (from the page's `page:header` component)
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
Expand DownExpand Up@@ -125,6 +135,7 @@ export interface ExpectedEntry {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataType'
Expand DownExpand Up@@ -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.<view>.bulkActions.<def>.*` 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.<view>` 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 });
}
}
}

/**
Expand DownExpand Up@@ -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.<object>._validations.<rule>.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.<o>._sections.<section>.label`) ─────────
//
// A section heading is authored in TWO independent places and rendered from
Expand DownExpand Up@@ -862,6 +1007,9 @@ export function collectExpectedEntries(
pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
}
}

// Custom validation-rule rejection messages (`_validations.<rule>.message`).
pushValidationMessages(out, objectName, obj.validations, 0);
}

// ── Top-level views ──────────────────────────────────────────────
Expand DownExpand Up@@ -974,6 +1122,9 @@ export function collectExpectedEntries(
}
}

// ── Analytics datasets (`datasets.<name>.…`) ─────────────────────
walkDatasets(config, out);

// ── Pages + their `page:header` copy ──────────────────────────────
const pages: any[] = Array.isArray(config?.pages) ? config.pages : [];
for (const page of pages) {
Expand DownExpand Up@@ -1041,6 +1192,63 @@ export function collectExpectedEntries(
return out.filter((entry) => !warnedGroups.has(entry.path[0]));
}

// ─── Analytics datasets (`datasets.<name>.…`) ──────────────────────────

/**
* Emit the dataset copy surface (#14253's resolver, #14376's walk):
*
* datasets.<name>.label
* datasets.<name>.description
* datasets.<name>.dimensions.<dimension>.label
* datasets.<name>.measures.<measure>.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.<d>.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.<flow>.screens.<node_id>.…`) ─────────────────

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/cli-i18n-extract-bulk-validation-dataset.md
Original file line numberDiff line numberDiff line change
@@ -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.<o>._views.<v>.bulkActions.<def>.{label,confirmText,confirmLabel,params.<p>.{label,help,placeholder}}` | `translateView` → `translateBulkActionDefs` |
| validation messages | `objects.<o>._validations.<rule>.message` | the ObjectQL rule evaluator, via `objectValidationMessageKey` |
| datasets | `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.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.
11 changes: 11 additions & 0 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export interface CoverageIssue {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataForm';
Expand DownExpand Up@@ -205,6 +206,15 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
navigation: 'navigation',
dashboard: 'dashboard',
widget: 'widget',
// Analytics dataset copy (`datasets.<d>.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.<f>.label`, `flows.<f>.screens.<n>.title`, and
// the per-field `label` / `placeholder`) — the author's own wizard text, so
Expand All@@ -231,6 +241,7 @@ const SOURCE_NOUN: Record<CoverageIssue['source'], string> = {
navigation: 'Navigation item',
dashboard: 'Dashboard',
widget: 'Widget',
dataset: 'Dataset',
page: 'Page',
flow: 'Flow',
metadataForm: 'Metadata form',
Expand Down
208 changes: 208 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,13 @@
* objects.<name>._views.<view>.label
* objects.<name>._views.<view>.description
* objects.<name>._views.<view>.emptyState.title / .message
* objects.<name>._views.<view>.bulkActions.<def>.label / .confirmText
* / .confirmLabel
* objects.<name>._views.<view>.bulkActions.<def>.params.<param>.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.<name>._validations.<rule>.message
* objects.<name>._actions.<action>.label
* objects.<name>._actions.<action>.description
* objects.<name>._actions.<action>.confirmText
Expand All@@ -48,6 +55,9 @@
* apps.<app>.navigation.<id>.label
* dashboards.<dash>.label / .description
* dashboards.<dash>.widgets.<w>.title / .description
* datasets.<dataset>.label / .description
* datasets.<dataset>.dimensions.<dim>.label
* datasets.<dataset>.measures.<measure>.label
* pages.<page>.label / .description
* pages.<page>.title / .subtitle (from the page's `page:header` component)
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
Expand DownExpand Up@@ -125,6 +135,7 @@ export interface ExpectedEntry {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataType'
Expand DownExpand Up@@ -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.<view>.bulkActions.<def>.*` 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.<view>` 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 });
}
}
}

/**
Expand DownExpand Up@@ -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.<object>._validations.<rule>.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.<o>._sections.<section>.label`) ─────────
//
// A section heading is authored in TWO independent places and rendered from
Expand DownExpand Up@@ -862,6 +1007,9 @@ export function collectExpectedEntries(
pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
}
}

// Custom validation-rule rejection messages (`_validations.<rule>.message`).
pushValidationMessages(out, objectName, obj.validations, 0);
}

// ── Top-level views ──────────────────────────────────────────────
Expand DownExpand Up@@ -974,6 +1122,9 @@ export function collectExpectedEntries(
}
}

// ── Analytics datasets (`datasets.<name>.…`) ─────────────────────
walkDatasets(config, out);

// ── Pages + their `page:header` copy ──────────────────────────────
const pages: any[] = Array.isArray(config?.pages) ? config.pages : [];
for (const page of pages) {
Expand DownExpand Up@@ -1041,6 +1192,63 @@ export function collectExpectedEntries(
return out.filter((entry) => !warnedGroups.has(entry.path[0]));
}

// ─── Analytics datasets (`datasets.<name>.…`) ──────────────────────────

/**
* Emit the dataset copy surface (#14253's resolver, #14376's walk):
*
* datasets.<name>.label
* datasets.<name>.description
* datasets.<name>.dimensions.<dimension>.label
* datasets.<name>.measures.<measure>.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.<d>.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.<flow>.screens.<node_id>.…`) ─────────────────

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/cli-i18n-extract-bulk-validation-dataset.md
Original file line numberDiff line numberDiff line change
@@ -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.<o>._views.<v>.bulkActions.<def>.{label,confirmText,confirmLabel,params.<p>.{label,help,placeholder}}` | `translateView` → `translateBulkActionDefs` |
| validation messages | `objects.<o>._validations.<rule>.message` | the ObjectQL rule evaluator, via `objectValidationMessageKey` |
| datasets | `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.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.
11 changes: 11 additions & 0 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export interface CoverageIssue {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataForm';
Expand DownExpand Up@@ -205,6 +206,15 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
navigation: 'navigation',
dashboard: 'dashboard',
widget: 'widget',
// Analytics dataset copy (`datasets.<d>.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.<f>.label`, `flows.<f>.screens.<n>.title`, and
// the per-field `label` / `placeholder`) — the author's own wizard text, so
Expand All@@ -231,6 +241,7 @@ const SOURCE_NOUN: Record<CoverageIssue['source'], string> = {
navigation: 'Navigation item',
dashboard: 'Dashboard',
widget: 'Widget',
dataset: 'Dataset',
page: 'Page',
flow: 'Flow',
metadataForm: 'Metadata form',
Expand Down
208 changes: 208 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,13 @@
* objects.<name>._views.<view>.label
* objects.<name>._views.<view>.description
* objects.<name>._views.<view>.emptyState.title / .message
* objects.<name>._views.<view>.bulkActions.<def>.label / .confirmText
* / .confirmLabel
* objects.<name>._views.<view>.bulkActions.<def>.params.<param>.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.<name>._validations.<rule>.message
* objects.<name>._actions.<action>.label
* objects.<name>._actions.<action>.description
* objects.<name>._actions.<action>.confirmText
Expand All@@ -48,6 +55,9 @@
* apps.<app>.navigation.<id>.label
* dashboards.<dash>.label / .description
* dashboards.<dash>.widgets.<w>.title / .description
* datasets.<dataset>.label / .description
* datasets.<dataset>.dimensions.<dim>.label
* datasets.<dataset>.measures.<measure>.label
* pages.<page>.label / .description
* pages.<page>.title / .subtitle (from the page's `page:header` component)
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
Expand DownExpand Up@@ -125,6 +135,7 @@ export interface ExpectedEntry {
| 'navigation'
| 'dashboard'
| 'widget'
| 'dataset'
| 'page'
| 'flow'
| 'metadataType'
Expand DownExpand Up@@ -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.<view>.bulkActions.<def>.*` 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.<view>` 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 });
}
}
}

/**
Expand DownExpand Up@@ -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.<object>._validations.<rule>.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.<o>._sections.<section>.label`) ─────────
//
// A section heading is authored in TWO independent places and rendered from
Expand DownExpand Up@@ -862,6 +1007,9 @@ export function collectExpectedEntries(
pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
}
}

// Custom validation-rule rejection messages (`_validations.<rule>.message`).
pushValidationMessages(out, objectName, obj.validations, 0);
}

// ── Top-level views ──────────────────────────────────────────────
Expand DownExpand Up@@ -974,6 +1122,9 @@ export function collectExpectedEntries(
}
}

// ── Analytics datasets (`datasets.<name>.…`) ─────────────────────
walkDatasets(config, out);

// ── Pages + their `page:header` copy ──────────────────────────────
const pages: any[] = Array.isArray(config?.pages) ? config.pages : [];
for (const page of pages) {
Expand DownExpand Up@@ -1041,6 +1192,63 @@ export function collectExpectedEntries(
return out.filter((entry) => !warnedGroups.has(entry.path[0]));
}

// ─── Analytics datasets (`datasets.<name>.…`) ──────────────────────────

/**
* Emit the dataset copy surface (#14253's resolver, #14376's walk):
*
* datasets.<name>.label
* datasets.<name>.description
* datasets.<name>.dimensions.<dimension>.label
* datasets.<name>.measures.<measure>.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.<d>.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.<flow>.screens.<node_id>.…`) ─────────────────

/**
Expand Down
Loading
Loading