Skip to content

Commit 3637731

Browse files
claude[bot]zhuangjianguoclaude
authored
feat(cli): i18n coverage + extract gain the flow/screen bucket (#11615)
* feat(cli): i18n coverage + extract gain the flow/screen bucket `COVERAGE_SOURCE` had no flow bucket, so `os lint`'s `i18n/missing-*` family could not report a screen-flow copy gap at all and `os i18n extract` never scaffolded the `flows..screens..` skeleton. The key face is imported from `@objectstack/spec/system` (FLOW_SCREEN_COPY_KEYS / FLOW_SCREEN_FIELD_COPY_KEYS) so the extractor and the resolver cannot drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR * fix(cli): give the new i18n test explicit .js import extensions, and freeze the newly-visible flow debt Under moduleResolution NodeNext the extensionless relative imports did not resolve, and every symbol they named became `any` — 2x TS2835 plus a 10-error TS7006 cascade, +12 on the packages/cli TEST_DEBT ledger. With the extensions the layer re-measures 146, its recorded number, 0 of them in this file. The flow bucket makes 55 pre-existing untranslated screen-flow strings visible to `check:i18n-coverage` for the first time (app-crm 89->93, app-showcase 393->424, app-todo 120->140). Frozen in the baseline the way #3370's surface widening was: the debt is not new, only newly visible, and translating three example apps' wizard copy is not this card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --------- Co-authored-by: os-zhuang <zhuangjianguo@steedos.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent d15ddba commit 3637731

5 files changed

Lines changed: 455 additions & 6 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
`os lint` and `os i18n extract` gain the flow/screen bucket — a screen-flow
6+
copy gap is reportable, and the `flows..screens..` skeleton is scaffoldable,
7+
for the first time
8+
9+
#11287 gave the bundle a `flows` group and a resolver that applies it. Nothing
10+
on the CLI side walked it: `COVERAGE_SOURCE` had no flow bucket, so the
11+
`i18n/missing-*` family could not report a screen-flow copy gap **at all**, and
12+
`os i18n extract` never wrote the keys, so an author had no way to discover the
13+
vocabulary. Measured on #11287: HotCRM reported **0 `i18n/missing-*` issues** on
14+
a tree whose six screen dialogs rendered English in all four locales. An app
15+
whose i18n gate is green is green because the surface is invisible to it.
16+
17+
The shared walker (`collectExpectedEntries` — one definition of what is
18+
translatable, feeding both the gate and the extractor) now harvests, per flow:
19+
20+
```
21+
flows.<flow>.label
22+
flows.<flow>.screens.<node_id>.title
23+
flows.<flow>.screens.<node_id>.fields.<field>.label
24+
flows.<flow>.screens.<node_id>.fields.<field>.placeholder
25+
```
26+
27+
Screens are keyed by `FlowNode.id` and fields by `ScreenFieldConfig.name` — the
28+
identifiers the runner already holds at render time, not a second naming
29+
scheme. Missing keys report as `i18n/missing-flow`, per locale, with the same
30+
opt-in rule as every other bucket: a project that declares no locales and ships
31+
no bundle still reports nothing.
32+
33+
The copy keys are **imported** from `@objectstack/spec/system`
34+
(`FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS`), never restated. They
35+
are exported precisely so the extractor and the resolver cannot drift; the
36+
schema-to-list agreement is pinned spec-side in `translation.test.ts`, and the
37+
list-to-walker agreement is pinned here.
38+
39+
Two seeding rules follow what the reader actually sees rather than which key
40+
the author filled in. A screen's `title` falls back to the node `label`,
41+
because the executor builds the wire title as `config.title ?? node.label` and
42+
one bundle key covers whichever the runner draws. A field's `label` falls back
43+
to its `name` as a *derived* seed: the skeleton stays usable while the gate
44+
demands no translation for a string nobody authored.

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface CoverageIssue {
5656
| 'dashboard'
5757
| 'widget'
5858
| 'page'
59+
| 'flow'
5960
| 'metadataForm';
6061
/** Human-readable explanation. */
6162
message: string;
@@ -205,6 +206,14 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
205206
dashboard: 'dashboard',
206207
widget: 'widget',
207208
page: 'page',
209+
// Screen-flow copy (`flows.<f>.label`, `flows.<f>.screens.<n>.title`, and
210+
// the per-field `label` / `placeholder`) — the author's own wizard text, so
211+
// it keeps its own bucket and reports as `i18n/missing-flow` rather than
212+
// folding away with `--include-platform`. Until this bucket existed the
213+
// family could not report a screen-flow gap at all: HotCRM measured
214+
// `0 i18n/missing-*` while six screen dialogs rendered English in all four
215+
// locales (#11485).
216+
flow: 'flow',
208217
metadataType: 'metadataForm',
209218
metadataFormSection: 'metadataForm',
210219
metadataFormField: 'metadataForm',
@@ -223,13 +232,14 @@ const SOURCE_NOUN: Record<CoverageIssue['source'], string> = {
223232
dashboard: 'Dashboard',
224233
widget: 'Widget',
225234
page: 'Page',
235+
flow: 'Flow',
226236
metadataForm: 'Metadata form',
227237
};
228238

229239
/** Subject line for the "missing translation" message. */
230240
function describeEntry(entry: ExpectedEntry, source: CoverageIssue['source']): string {
231241
const noun = SOURCE_NOUN[source];
232-
const owner = entry.objectName ?? entry.appName ?? entry.metadataType;
242+
const owner = entry.objectName ?? entry.appName ?? entry.metadataType ?? entry.flowName;
233243
// Everything past the owning collection and its name reads as the attribute
234244
// path: `objects.account.fields.name.label` → `fields.name.label`.
235245
const attribute = entry.path.slice(2).join('.');

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

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@
5151
* pages.<page>.label / .description
5252
* pages.<page>.title / .subtitle (from the page's `page:header` component)
5353
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
54+
* flows.<flow>.label
55+
* flows.<flow>.screens.<node_id>.title (#7646 / #11287)
56+
* flows.<flow>.screens.<node_id>.fields.<field>.label
57+
* flows.<flow>.screens.<node_id>.fields.<field>.placeholder
5458
* metadataForms.<type>.label / .description
5559
* metadataForms.<type>.sections.<section>.label / .description
5660
* metadataForms.<type>.fields.<dotPath>.label / .helpText / .placeholder
@@ -65,7 +69,12 @@
6569
*/
6670

6771
import type { TranslationBundle, TranslationData } from '@objectstack/spec/system';
68-
import { METADATA_FORM_REGISTRY, PAGE_COMPONENT_COPY_KEYS } from '@objectstack/spec/system';
72+
import {
73+
METADATA_FORM_REGISTRY,
74+
PAGE_COMPONENT_COPY_KEYS,
75+
FLOW_SCREEN_COPY_KEYS,
76+
FLOW_SCREEN_FIELD_COPY_KEYS,
77+
} from '@objectstack/spec/system';
6978
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
7079
import { deriveFieldGroupLayout } from '@objectstack/spec/data';
7180
import { expandViewContainer } from '@objectstack/spec/ui';
@@ -110,6 +119,7 @@ export interface ExpectedEntry {
110119
| 'dashboard'
111120
| 'widget'
112121
| 'page'
122+
| 'flow'
113123
| 'metadataType'
114124
| 'metadataFormSection'
115125
| 'metadataFormField';
@@ -119,6 +129,8 @@ export interface ExpectedEntry {
119129
appName?: string;
120130
/** Metadata type name when applicable (for `--filter` matching). */
121131
metadataType?: string;
132+
/** Flow name when applicable (for `--filter` matching). */
133+
flowName?: string;
122134
}
123135

124136
export type FillStrategy = 'empty' | 'default' | 'todo';
@@ -249,7 +261,7 @@ function pushViewEmptyState(out: ExpectedEntry[], viewPath: string[], view: any,
249261
}
250262
}
251263

252-
type EntryScope = Pick<ExpectedEntry, 'objectName' | 'appName' | 'metadataType'>;
264+
type EntryScope = Pick<ExpectedEntry, 'objectName' | 'appName' | 'metadataType' | 'flowName'>;
253265

254266
/** Narrow to a usable source string; an empty string is not authored text. */
255267
function inlineText(value: unknown): string | undefined {
@@ -873,6 +885,9 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
873885
}
874886
}
875887

888+
// ── Screen flows (`flows.<flow>.screens.<node>.…`, #7646 / #11287) ─
889+
walkScreenFlows(config, out);
890+
876891
// ── Object sections (fieldGroups + authored form/page sections) ───
877892
// Deliberately a pass of its own: the two authoring surfaces live in
878893
// `objects`, `views` and `pages`, and one section may be declared by more
@@ -893,6 +908,105 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
893908
return out;
894909
}
895910

911+
// ─── Screen flows (`flows.<flow>.screens.<node_id>.…`) ─────────────────
912+
913+
/**
914+
* The one flow-node type whose copy the bundle addresses. Spelled once here
915+
* rather than at each guard; the resolver's own constant is module-private,
916+
* and `translateScreenNode` filters on exactly this value.
917+
*/
918+
const SCREEN_NODE_TYPE = 'screen';
919+
920+
/**
921+
* Emit the screen-flow copy surface (#7646, resolver landed in #11287).
922+
*
923+
* **The hole this closes.** A `type: 'screen'` flow is a wizard the user
924+
* reads — a heading and a list of labelled inputs — and this walker had no
925+
* pass for it, so `os lint` could not report a screen-flow copy gap and
926+
* `os i18n extract` never scaffolded the keys. HotCRM measured
927+
* `0 i18n/missing-*` on a tree whose six screen dialogs rendered English in
928+
* all four locales: the gate was green because the surface was invisible to
929+
* it, not because the app was translated.
930+
*
931+
* **The key face is IMPORTED, never restated.** {@link FLOW_SCREEN_COPY_KEYS}
932+
* and {@link FLOW_SCREEN_FIELD_COPY_KEYS} are exported by
933+
* `@objectstack/spec/system` precisely so this scaffolder and the resolver
934+
* that reads the bundle cannot drift — a local copy, however correct on the
935+
* day it is written, is the drift the export exists to make impossible. The
936+
* schema↔list agreement is pinned spec-side in `translation.test.ts`.
937+
*
938+
* Addressing (`translation.zod.ts`, `flows`): flow by `Flow.name`, screen by
939+
* `FlowNode.id` (the client's `ScreenSpec.nodeId`), field by
940+
* `ScreenFieldConfig.name` — every level an identifier some consumer already
941+
* holds at render time.
942+
*
943+
* Two seeding rules worth stating, both measured against what the reader sees
944+
* rather than against which key the author happened to fill in:
945+
*
946+
* - **A screen's `title` falls back to the node `label`.** The executor builds
947+
* the wire title as `config.title ?? node.label` (`ScreenSpec.title`), and
948+
* `translateFlow` overlays the bundle onto `config.title` for that reason —
949+
* one key covers whichever of the two the runner draws. So the seed, and the
950+
* `inline` the coverage gate judges, is that same pair: a screen with only a
951+
* canvas label still shows English text a translator owes a translation for.
952+
* - **A field's `label` falls back to its `name`.** `ScreenFieldConfig.label`
953+
* is optional and forwarded as-is (`ScreenFieldSpec.label`), so the runner
954+
* renders the field name when the author wrote no label. That is a derived
955+
* fallback nobody authored — {@link pushDerived}, so the skeleton stays
956+
* usable while the gate demands no translation of a string that does not
957+
* exist.
958+
*
959+
* A screen node whose `waitForInput` is `false` is deliberately NOT skipped:
960+
* `translateFlow` overlays every screen node, and a walker that skipped one
961+
* would re-open the extractable-but-ungated gap in miniature.
962+
*/
963+
function walkScreenFlows(config: any, out: ExpectedEntry[]): void {
964+
const flows: any[] = Array.isArray(config?.flows) ? config.flows : [];
965+
for (const flow of flows) {
966+
const flowName = typeof flow?.name === 'string' && flow.name.length > 0 ? flow.name : undefined;
967+
if (!flowName) continue;
968+
const scope: EntryScope = { flowName };
969+
970+
// `flows.<flow>.label` — `lookupFlowLabel`'s key. `Flow.label` is required
971+
// by the schema, so this is authored text in practice; `pushOptional`
972+
// keeps a label-less flow from seeding an empty string anyway.
973+
pushOptional(out, ['flows', flowName, 'label'], flow.label, 'flow', scope);
974+
975+
const nodes: any[] = Array.isArray(flow.nodes) ? flow.nodes : [];
976+
for (const node of nodes) {
977+
if (!node || typeof node !== 'object' || node.type !== SCREEN_NODE_TYPE) continue;
978+
const nodeId = typeof node.id === 'string' && node.id.length > 0 ? node.id : undefined;
979+
// No id, no key: `translateScreenNode` cannot address the node either.
980+
if (!nodeId) continue;
981+
const cfg = node.config && typeof node.config === 'object' ? node.config : {};
982+
const screenRoot = ['flows', flowName, 'screens', nodeId];
983+
984+
for (const key of FLOW_SCREEN_COPY_KEYS) {
985+
const authored = key === 'title'
986+
? (inlineText(cfg[key]) ?? inlineText(node.label))
987+
: inlineText(cfg[key]);
988+
pushOptional(out, [...screenRoot, key], authored, 'flow', scope);
989+
}
990+
991+
const fields: any[] = Array.isArray(cfg.fields) ? cfg.fields : [];
992+
for (const field of fields) {
993+
const fieldName = typeof field?.name === 'string' && field.name.length > 0 ? field.name : undefined;
994+
// An item with an empty name is dropped by the runner too.
995+
if (!fieldName) continue;
996+
const fieldRoot = [...screenRoot, 'fields', fieldName];
997+
for (const key of FLOW_SCREEN_FIELD_COPY_KEYS) {
998+
const authored = inlineText(field[key]);
999+
if (key === 'label') {
1000+
pushDerived(out, [...fieldRoot, key], authored ?? fieldName, authored, 'flow', scope);
1001+
} else {
1002+
pushOptional(out, [...fieldRoot, key], authored, 'flow', scope);
1003+
}
1004+
}
1005+
}
1006+
}
1007+
}
1008+
}
1009+
8961010
/**
8971011
* Iterate the canonical metadata form registry and emit translation entries
8981012
* for every metadata type's display label/description, plus the section and
@@ -1006,6 +1120,7 @@ function passesFilter(entry: ExpectedEntry, filter?: RegExp): boolean {
10061120
if (entry.objectName && filter.test(entry.objectName)) return true;
10071121
if (entry.appName && filter.test(entry.appName)) return true;
10081122
if (entry.metadataType && filter.test(entry.metadataType)) return true;
1123+
if (entry.flowName && filter.test(entry.flowName)) return true;
10091124
// Allow matching against the joined path so users can target e.g. ^dashboards\.system_
10101125
return filter.test(entry.path.join('.'));
10111126
}

0 commit comments

Comments
 (0)