Skip to content

Commit 68c5dba

Browse files
os-litantclaude
andauthored
fix(cli,lint): gate the i18n flow coverage bucket on the liveness ledger (#12442)
* fix(cli,lint): gate the i18n flow coverage bucket on the liveness ledger `os lint` computes i18n coverage and runs the authoring-rule registry in one pass over the same stack, and for the `flows` translation group the two halves pointed opposite ways: omitting the keys drew `i18n/missing-flow`, authoring them drew `liveness-planned-property` ("sets `flows` but this translation property is planned"). Measured on one stack: 4 demands vs 2 warnings, and no third move — `os lint` has no per-rule suppression, only `--skip-i18n`, which silences the whole `i18n/missing-*` family. The warning is true and is unchanged: no shipped screen-flow runner reads the group. The demand is the premature half. `collectExpectedEntries` — the single definition of what is translatable at all, shared by the coverage gate and the `os i18n extract` skeleton — now leaves out any translation group the liveness ledger warns authors for authoring, reading that set from `@objectstack/lint`'s new `authorWarnedProperties(type)`: the very warn map `lintLivenessProperties` iterates, so the demand side and the warn side read ONE verdict. Reading the ledger rather than switching on `flows` by name means the bucket turns itself back on the day the row flips to `live`, and any future warned group is covered on the day it is marked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd * chore(i18n): re-baseline the coverage ratchet after the flow-bucket gate `check:i18n-coverage` counts untranslated declared strings per example config against a shrink-only baseline. Gating the `flow` bucket removes the `flows.*` demands, so the count improves on exactly the three example apps that declare a `type: 'screen'` flow and on no other config: examples/app-crm 93 -> 89 examples/app-showcase 424 -> 393 examples/app-todo 140 -> 120 The other 9 baselined configs are unchanged — `examples/embed-objectql` and the 8 platform/plugin extract configs declare no screen flow. Regenerated with `node scripts/check-i18n-coverage.mjs --update`, the repair the gate itself prescribes for a ratchet DOWN. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 424f73c commit 68c5dba

9 files changed

Lines changed: 480 additions & 12 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/cli": patch
3+
"@objectstack/lint": patch
4+
---
5+
6+
fix(cli,lint): stop `os lint` demanding translation keys the liveness ledger warns authors for writing (#11624)
7+
8+
`os lint` computes i18n coverage and runs the authoring-rule registry in a
9+
single pass over the same stack, and for the `flows` translation group the two
10+
halves pointed opposite ways:
11+
12+
| the author does | which rule fires | what it says |
13+
|---|---|---|
14+
| omits `flows.*` from the bundle | `i18n/missing-flow` | the key is missing a translation for locale X |
15+
| adds it (`os i18n extract` scaffolds it) | `liveness-planned-property` | the `flows` group is `planned` — nothing reads it |
16+
17+
Measured on one stack, one run: omitting produced **4** `i18n/missing-flow`
18+
findings and 0 liveness findings; authoring produced 0 demands and **2**
19+
`liveness-planned-property` findings ("sets `flows` but this translation
20+
property is planned"). There is no per-rule suppression in `os lint`, only
21+
`--skip-i18n`, which silences the entire `i18n/missing-*` family — so the
22+
author's only escape cost them every other coverage signal. Under
23+
`--i18n-strict` the demand side is an **error**, so a project could be forced
24+
to author keys it is then warned for.
25+
26+
⛔ The warning is not the bug and is unchanged: no shipped screen-flow runner
27+
reads the group, so a translated wizard string is stored and never shown — the
28+
failure mode `validationMessages` was removed in 17.0.0 for. The premature half
29+
is the demand.
30+
31+
**The fix.** `collectExpectedEntries` — the single definition of what is
32+
translatable at all, shared by the coverage gate and the `os i18n extract`
33+
skeleton — now leaves out any translation group the liveness ledger warns
34+
authors for authoring. It reads that set from `@objectstack/lint`'s new
35+
`authorWarnedProperties(type)`, which returns the very warn-map
36+
`lintLivenessProperties` iterates, so the demand side and the warn side cannot
37+
drift into disagreeing about the same keys again.
38+
39+
Two properties fall out of reading the ledger rather than switching on `flows`
40+
by name: the bucket **turns itself back on** the day an objectui screen-flow
41+
runner lands and the row flips to `live` (no flag, no follow-up edit), and any
42+
future group that acquires an `authorWarn` is covered on the day it is marked
43+
rather than re-opening this collision one group at a time. Today `flows` is the
44+
only such group — pinned as an equality so a second one goes red instead of
45+
shipping.
46+
47+
No other bucket changes: `objects`, `apps`, `pages`, `dashboards`,
48+
`globalActions` and `metadataForms` are all `live` and are reported exactly as
49+
before. `@objectstack/spec` is untouched — the `flows` row keeps `planned` +
50+
`authorWarn: true`.

content/docs/ui/translations.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,17 @@ Honest limits worth knowing before you plan around them:
259259
screen field has no help text to translate (it declares none), and the
260260
runner's own chrome — the Cancel and Submit buttons — belongs to the
261261
console's message catalog rather than your app's bundle.
262+
263+
**So the tooling does not ask you for these keys either.** `os lint` does not
264+
report `flows.*` as missing translations, and `os i18n extract` does not
265+
scaffold them into your bundle — both read the same `planned` row. Without
266+
that, the two halves of a single `os lint` run contradicted each other:
267+
omitting the keys was reported as a coverage gap, and adding them was reported
268+
as authoring a group nothing reads. If you author the copy anyway you get the
269+
liveness warning and nothing else — it is telling you the truth, not asking
270+
you to delete a key you will need later. The day the runner lands and the row
271+
flips to `live`, both the coverage report and the extract skeleton pick the
272+
group up on their own; there is no flag to turn on.
262273
- **No ICU MessageFormat** — plural/gender formatting isn't available;
263274
interpolation is always simple `{variable}` substitution.
264275
- **Runtime authoring is process-wide.** The authored layer is synced across all

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

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@
5555
* flows.<flow>.screens.<node_id>.title (#7646 / #11287)
5656
* flows.<flow>.screens.<node_id>.fields.<field>.label
5757
* flows.<flow>.screens.<node_id>.fields.<field>.placeholder
58+
* ^ gated: `flows` is `planned` + `authorWarn` in the liveness ledger, so
59+
* these are walked only once the row goes `live` (#11624 — see
60+
* `authorWarnedTranslationGroups`)
5861
* metadataForms.<type>.label / .description
5962
* metadataForms.<type>.sections.<section>.label / .description
6063
* metadataForms.<type>.fields.<dotPath>.label / .helpText / .placeholder
@@ -64,8 +67,10 @@
6467
* `@objectstack/spec`) — it is included unconditionally, independent of
6568
* the supplied stack config.
6669
*
67-
* Pure: no filesystem or network. Safe to call from the CLI, IDE tooling
68-
* and unit tests.
70+
* Pure given its inputs: no network, and the only filesystem read is the
71+
* shipped liveness ledger behind {@link authorWarnedTranslationGroups} —
72+
* injectable as `warnedGroups`, so the walk itself stays a pure function of
73+
* `config`. Safe to call from the CLI, IDE tooling and unit tests.
6974
*/
7075

7176
import type { TranslationBundle, TranslationData } from '@objectstack/spec/system';
@@ -78,7 +83,7 @@ import {
7883
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
7984
import { deriveFieldGroupLayout } from '@objectstack/spec/data';
8085
import { expandViewContainer } from '@objectstack/spec/ui';
81-
import { walkPageComponents } from '@objectstack/lint';
86+
import { authorWarnedProperties, walkPageComponents } from '@objectstack/lint';
8287

8388
// ─── Public types ──────────────────────────────────────────────────────
8489

@@ -135,7 +140,7 @@ export interface ExpectedEntry {
135140

136141
export type FillStrategy = 'empty' | 'default' | 'todo';
137142

138-
export interface ExtractOptions {
143+
export interface ExtractOptions extends ExpectedEntryOptions {
139144
/** Default locale (filled with source values). Defaults to `'en'`. */
140145
defaultLocale?: string;
141146
/** Locales to emit. Defaults to `[defaultLocale]`. */
@@ -626,8 +631,76 @@ function walkObjectTabs(config: any, out: ExpectedEntry[]): void {
626631
}
627632
}
628633

629-
/** Collect every translatable entry from a normalized stack config. */
630-
export function collectExpectedEntries(config: any): ExpectedEntry[] {
634+
/**
635+
* Translation groups the shipped liveness ledger warns an author for authoring
636+
* — today exactly `flows` (`status: planned`, `authorWarn: true`).
637+
*
638+
* ## Why the walk surface is gated on this at all (#11624)
639+
*
640+
* `os lint` runs the coverage gate built on this walker AND
641+
* `lintLivenessProperties` in ONE pass, and before this gate existed they
642+
* pointed opposite ways on the same keys: omit `flows.<f>.screens.<n>.title`
643+
* from a bundle and the coverage gate reported `i18n/missing-flow`; author it
644+
* and the liveness rule reported `liveness-planned-property` ("sets `flows` but
645+
* this translation property is planned"). Measured on one stack, one run: 4
646+
* demand-side findings against 2 warn-side findings, and no third option — the
647+
* CLI has no per-rule suppression, only `--skip-i18n`, which silences the whole
648+
* `i18n/missing-*` family. Under `--i18n-strict` the demand side is an error,
649+
* so a project could be *forced* to author keys it is then warned for.
650+
*
651+
* ⛔ The warn side is not the bug and must not be softened: no shipped runner
652+
* reads the group, so a translated wizard string really is stored and never
653+
* shown. The demand is the half that is premature.
654+
*
655+
* ## Shape
656+
*
657+
* Group-general, not `flows`-specific, and read from the ledger rather than a
658+
* switch of our own: the day the objectui screen-flow runner lands and the row
659+
* flips to `live` (dropping its `authorWarn`), the bucket turns itself back on
660+
* with no edit here — and any FUTURE group that acquires a warn is covered on
661+
* the day it is marked, rather than re-opening this collision one group at a
662+
* time.
663+
*
664+
* The join is on the group (`path[0]`) and stops there deliberately: for
665+
* file-authored bundles the warn side only ever fires at that depth. Its
666+
* `getNested` fans out over ARRAYS, and a translation group is a record keyed
667+
* by target name, so a warned child row (`flows.label`) resolves
668+
* `data.flows.label` — a path no real bundle has — and warns nobody. Matching
669+
* deeper here would suppress keys nothing warns about.
670+
*
671+
* Unreadable ledger ⇒ empty set ⇒ nothing is gated, which is also the state in
672+
* which the warn side warns on nothing. The two halves go quiet together; the
673+
* one thing that must never happen is one of them speaking alone.
674+
*/
675+
export function authorWarnedTranslationGroups(): ReadonlySet<string> {
676+
const warned = authorWarnedProperties('translation');
677+
// Top-level groups only — see the `getNested` note above.
678+
return new Set([...warned].filter((path) => !path.includes('.')));
679+
}
680+
681+
/** Options shared by the two surfaces built on {@link collectExpectedEntries}. */
682+
export interface ExpectedEntryOptions {
683+
/**
684+
* Translation groups to leave out of the walk. Defaults to
685+
* {@link authorWarnedTranslationGroups}. Pass an empty set to walk the whole
686+
* declared surface — what the gated groups look like the day their ledger row
687+
* goes `live`.
688+
*/
689+
warnedGroups?: ReadonlySet<string>;
690+
}
691+
692+
/**
693+
* Collect every translatable entry from a normalized stack config.
694+
*
695+
* Groups the liveness ledger warns authors for authoring are left out — see
696+
* {@link authorWarnedTranslationGroups}. This is the single place the gate
697+
* lives, so `os lint`'s coverage report and `os i18n extract`'s skeleton can
698+
* never disagree about which keys an author is being asked for.
699+
*/
700+
export function collectExpectedEntries(
701+
config: any,
702+
opts: ExpectedEntryOptions = {},
703+
): ExpectedEntry[] {
631704
const out: ExpectedEntry[] = [];
632705

633706
// ── Objects ───────────────────────────────────────────────────────
@@ -905,7 +978,9 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
905978
// flows, etc.
906979
walkMetadataForms(out);
907980

908-
return out;
981+
const warnedGroups = opts.warnedGroups ?? authorWarnedTranslationGroups();
982+
if (warnedGroups.size === 0) return out;
983+
return out.filter((entry) => !warnedGroups.has(entry.path[0]));
909984
}
910985

911986
// ─── Screen flows (`flows.<flow>.screens.<node_id>.…`) ─────────────────
@@ -1180,7 +1255,12 @@ export function extractTranslations(config: any, opts: ExtractOptions = {}): Ext
11801255
// Seed-less entries exist only so the coverage gate can spot a bundle that
11811256
// authors a key the metadata never writes inline — there is nothing to
11821257
// scaffold from, so they never reach a generated skeleton.
1183-
const allEntries = collectExpectedEntries(config).filter((e) => e.sourceValue !== undefined);
1258+
// `warnedGroups` rides through: a group the ledger warns authors for
1259+
// authoring must not be scaffolded either. Scaffolding it would hand the
1260+
// author a skeleton whose every filled-in key draws a warning — the same
1261+
// collision the coverage gate has, arriving by a different door (#11624).
1262+
const allEntries = collectExpectedEntries(config, { warnedGroups: opts.warnedGroups })
1263+
.filter((e) => e.sourceValue !== undefined);
11841264
const entries = allEntries.filter((e) => passesFilter(e, opts.filter));
11851265

11861266
const existingBundles: TranslationBundle[] = Array.isArray(config?.translations) ? config.translations : [];

0 commit comments

Comments
 (0)