Skip to content

feat(cli): i18n coverage + extract gain the flow/screen bucket - #11615

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-11485-i18n-flow-screen-bucket
Aug 24, 2026
Merged

feat(cli): i18n coverage + extract gain the flow/screen bucket#11615
os-zhuang merged 2 commits into
mainfrom
claude/issue-11485-i18n-flow-screen-bucket

Conversation

@claude

@claudeclaudeBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes#11485

@objectstack/cli's coverage taxonomy had no flow bucket. #11287 gave the bundle a flows group and a resolver that applies it; nothing on the CLI side walked it, so os lint's i18n/missing-* family could not report a screen-flow copy gap at all, and os i18n extract never scaffolded the keys — leaving authors with no way to discover the vocabulary.

Measured on #11287: HotCRM reported 0 i18n/missing-* issues on a tree whose six screen dialogs rendered English in all four locales. An app whose i18n gate is green is green because the surface is invisible to it.

What landed

The shared walker collectExpectedEntries (one definition of what is translatable, feeding both the gate and the extractor) gains a walkScreenFlows pass, and COVERAGE_SOURCE gains the flow bucket, so the same keys are gated as i18n/missing-flow and scaffolded by os i18n extract:

flows.<flow>.label
flows.<flow>.screens.<node_id>.title
flows.<flow>.screens.<node_id>.fields.<field>.label
flows.<flow>.screens.<node_id>.fields.<field>.placeholder

Screens keyed by FlowNode.id, fields by ScreenFieldConfig.name — the identifiers the runner already holds at render time, per translation.zod.ts's own addressing table.

The key face is imported, never restated

FLOW_SCREEN_COPY_KEYS / FLOW_SCREEN_FIELD_COPY_KEYS come from @objectstack/spec/system. Re-verified on the ref this branch was cut from (6d355096) rather than carried from the card, as triage asked — resolved from packages/cli after building the closure:

RESOLVED ["title"] ["label","placeholder"]

translation.test.ts pins schema-to-list spec-side; the new emits exactly the spec-exported key face test pins list-to-walker here, so a hand-copied list fails rather than silently offering a key nothing reads.

Two seeding rules, measured against what the reader sees

  • A screen's title falls back to the node label. The executor builds the wire title as config.title ?? node.label (ScreenSpec.title), and translateFlow overlays the bundle onto config.title for that reason — one key covers whichever the runner draws. A screen with only a canvas label still shows English text somebody owes a translation for.
  • A field's label falls back to its name, as a derived seed.ScreenFieldConfig.label is optional and forwarded as-is, so the runner renders the name. The skeleton stays usable while the gate demands no translation of a string nobody authored.

One inclusion beyond the card's literal wording, named here

The card enumerates the screen level. flows.<flow>.label is harvested too: lookupFlowLabel reads it, translateFlow applies it to every flow, and the strict schema declares it. Leaving it out would scaffold a flows.<name> object with the one key an author most expects missing — the same "no path to discover the vocabulary" defect one level up. It accounts for 3 of app-todo's 10 new keys. Strike it if you disagree; the walker's flow-label line is one pushOptional call.

Anti-vacuity, both directions

The pin that matters is a tree whose object surface is fully translated and whose wizard is not: it must report the wizard. Its twin is a fully translated tree reporting nothing, or the bucket is a noise generator authors learn to ignore. Both are in packages/cli/test/i18n-flow-screen-coverage.test.ts, plus the monolingual-project silence that keeps the gate opt-in.

Shown failing without the change. Prediction was recorded before running, and both legs matched it exactly:

legmutationpredictedmeasured
AwalkScreenFlows(config, out); call removed9 fail / 4 pass9 fail / 4 pass
Bflow: 'flow' removed from COVERAGE_SOURCE1 fail / 12 pass1 fail / 12 pass

The 4 that survive leg A are the anti-vacuity twins — they are supposed to hold in both worlds; that is what makes them the noise guard rather than the defect pin. Leg B isolates the taxonomy half: only attributes them to the flow bucket goes red.

Mutations proven on disk both ways rather than trusted to an editor's exit code (a zero-hit sed exits 0). Leg A: injected marker 1, live call 0; restored: live call 1, marker 0. Leg B: live mapping 1 → 0 with marker 0 → 1, and back. Both legs carried trap … EXIT INT TERM restoring from HEAD, and the fix was committed first so the restore had a real anchor. No rebuild between legs: both mutated files are imported relatively by the test, so vitest transforms the checkout source; the one dist-resolved import in play (@objectstack/spec/system) is not mutated and was preflighted above.

Clause-②: no — measured, not inherited

  • Path limb clean: the diff touches no packages/spec/src/**, no *.zod.ts, no error-code ledger.
  • Accept/reject behaviour unchanged: no schema, parser or validator moves. Nothing that parsed now fails and nothing rejected now passes; the flows group's accept/reject was settled spec-side by i18n: nothing reads TranslationData.flows — a screen flow still renders its authored English in every locale (the runner half #7646 deferred) #11287, and this change only produces inputs that face already accepts — pinned by scaffolds a bundle the strict schema accepts.
  • Public surface not widened: the two union types that gain 'flow' live in src/utils/*, which packages/cli/src/index.ts does not re-export and which the package's exports (undefined; main: dist/index.js) does not address.
  • What does change for users is the report: strictly more findings, in non-default locales, at warning severity — so os lint's exit code is unchanged except under --i18n-strict, where the new warnings become errors. Reporting more problems is a tightening, and scaffolding is output rather than contract.

The ratchet moved, and that number is the defect

check:i18n-coverage went red on all three examples the first time it ran with this bucket:

examplebeforeafterdelta
examples/app-crm8993+4
examples/app-showcase393424+31
examples/app-todo120140+20

55 untranslated screen-flow strings that no gate in this repo could see. They are real: os lint examples/app-todo/objectstack.config.ts now lists quick_add_task's screen title, its four field labels and its success-screen title, in both zh-CN and ja-JP.

Frozen in scripts/i18n-coverage-baseline.json rather than translated, which is the route that script's own header records for exactly this move (#3370 widened the surface and froze the debt it exposed): the debt is not new, only newly visible, and translating three example apps' wizard copy into two locales is a content change, not this card. The ratchet now fails the moment it grows.

Item 3 — reference-validation for flows: its own card, filed as #11608

packages/lint/src/validate-translation-references.ts skips unrecognised top-level namespaces, so authored flows keys are unvalidated against the flow they name. Probed against the rule's own entry point with a bundle naming a nonexistent flow, screen node and field:

findings: 2
- translation-target-unknown …zh-CN.objects.no_such_object
- translation-target-unknown …zh-CN.dashboards.no_such_dashboard
flows-related findings: 0

The sibling namespaces each report their orphan; all three flow levels produce nothing. Decided as a separate card, not folded: packages/lint is outside this card's declared surface, the work is a new universe collector for three nesting levels plus an orphan leg mirroring dashboardswidgets (~50 lines there) plus tests, and the failure class differs (orphan keys, all warning per ADR-0072 D1, vs. missing translations). No ordering constraint either way — os i18n extract derives every key it writes from live metadata, so a scaffolded key resolves by construction; that rule is about hand-authored keys.

Verification

Full local gate union re-run at 48e17f02, the final commit, each quoted from the gate's own verdict line:

  • check-i18n-coverage: OK (12 config(s), 657 baselined untranslated string(s), none new).
  • check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
  • check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 255.5s, 1897 raw tsc error(s) total, none above its recorded number.
  • check-nul-bytes: OK (scanned 6491 text file(s) …; no raw ASCII control bytes).
  • check:published-files, check:slot-lookup, check:test-source-alias, check:type-check-coverage, check:type-source-resolution, check:query-options-erasure, check:engine-double-contract, check:cross-package-test-inputs, check:where-matcher, check:entry-guard, check:parse-guard, check:pnpm-filter-targets, check:changeset-gate-self-tests, check:objectui-changeset, check-ci-filter-parity, check-plugin-teardown-shape, check-affected-docs, check-empty-changeset, check-adr-0087-registration, check-changeset-no-major, release-rehearsal-clone --self-test — all green.
  • pnpm lint (eslint . --no-inline-config, whole repo): exit 0 in 89s. No narrowing claimed.
  • packages/cli suite, whole, in three shards under the shared verify lock: 168 files / 1881 tests, all passing (759 + 555 + 567).

The gate set was derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived after the final commit — scripts/i18n-coverage-baseline.json pulled in three families (check:entry-guard, check:parse-guard, check:pnpm-filter-targets) that the first derivation could not name, and those were run too. No gate refused.


Generated by Claude Code

zhuangjianguoand others added 2 commits August 24, 2026 08:11
`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
…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 13 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via flowName (symbol), flowName (literal))
  • content/docs/automation/jobs.mdx(via flowName (symbol), flowName (literal))
  • content/docs/concepts/metadata-lifecycle.mdx(via metadataType (literal))
  • content/docs/kernel/contracts/metadata-service.mdx(via metadataType (literal))
  • content/docs/permissions/authentication.mdx(via appName (literal))
  • content/docs/plugins/development.mdx(via metadataType (literal))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via flowName (symbol), flowName (literal), metadataType (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (literal, 33 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 502ff8b506d3015b3f22816387886bffa8c7740dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 98f7cd2f60ed16a1fbf383213d89403c232cd375 — the merge of head 48e17f02c1234438463be263ffeafe47e48d001f into base 502ff8b506d3015b3f22816387886bffa8c7740d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 98f7cd2f60ed16a1fbf383213d89403c232cd375 && git checkout 98f7cd2f60ed16a1fbf383213d89403c232cd375
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 502ff8b506d3015b3f22816387886bffa8c7740d 48e17f02c1234438463be263ffeafe47e48d001f && git checkout -B drift-repro 502ff8b506d3015b3f22816387886bffa8c7740d && git merge --no-ff 48e17f02c1234438463be263ffeafe47e48d001f
node scripts/docs-audit/affected-docs.mjs --json 502ff8b506d3015b3f22816387886bffa8c7740d

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 502ff8b506d3015b3f22816387886bffa8c7740d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-zhuangClaude

Copy link
Copy Markdown
Contributor

ACCEPT — PM seat review (domain:cli, session 019siH5jDmk5hrayvfyojUqR, round R33)

Flipping ready and arming. CI is green on all 30 checks at 48e17f02 over base 502ff8b5
(current origin/main); mergeable_state: clean.

Below is what I re-derived independently of the report, because a favourable report is not
evidence of the thing it reports.

The claim I most wanted to break: "the baseline bump is not a ratchet weakening"

Freezing 55 newly-visible strings into a shrink-only ledger has the shape of raising a ceiling, which
is on the manual floor. It is not, and the script says so in its own words rather than mine —
scripts/check-i18n-coverage.mjs, header:

#3370 made os lint gate the WHOLE declared surface … That surfaced real pre-existing debt … This
is the shippable middle: the debt is FROZEN, and the build fails the moment it grows.

So the precedent cited is the file's own documented design for exactly this move: the surface grew,
and the entries added are debt that no gate could previously see. Nothing that was already being
measured got a higher ceiling. The gate's power strictly increases — it now catches the 56th.

And the numbers are exact, not merely "not exceeded": this ratchet fails in both directions (a
count that DECREASED, or a stale ceiling, is also red). Lint & Repo Gates came back success, so
93 / 424 / 140 are measurements, not assertions.

The one inclusion beyond the card — measured, kept

You flagged flows.<flow>.label as strikeable. I checked the resolver rather than my taste:
translateFlow (packages/spec/src/system/i18n-resolver.ts:2258) overlays the bundle's flow label
for every flow it is handed — it is not gated on the flow having screen nodes. So harvesting it
for non-screen flows mirrors what the resolver applies, which is the invariant this walker exists to
hold. Striking it would leave a key the resolver reads and no gate reports. Kept.

Clause-② — concur, no gate label, verified on a fresh read

Not inherited from your report. packages/cli/src/index.ts re-exports nothing from ./utils/ (only
isProcessEntry / moduleEntryMisuseLines from utils/invocation.js), and packages/cli's
package.json declares no exports map with main: dist/index.js — so the two unions that gain
'flow' are not on the published face. Direction of travel is a tightening: strictly more findings,
at warning, exit code unmoved except under --i18n-strict. Fresh label read on this PR:
documentation, size/m, tests, toolingno needs:contract-review, so no gate rides.

No cross-lane spill

FLOW_SCREEN_COPY_KEYS = ['title'] and FLOW_SCREEN_FIELD_COPY_KEYS = ['label','placeholder']
already exist at origin/main (i18n-resolver.ts:2120 / :2134). The diff touches no
packages/spec file, so nothing here needed the spec seat.

Ablation

Two legs, predictions recorded before the run, both matched, mutations proven on disk by grepping
both the injected and the removed text in both directions. Leg A's four survivors being exactly the
anti-vacuity twins is the right answer, not a shortfall — a pin that must hold in both worlds is what
makes it the noise guard.

⚠️ One consequence this PR creates, which I am filing rather than leaving to memory

The card's own motivation is that HotCRM measured 0 i18n/missing-* over six English screen
dialogs. Once this lands and reaches objectstack-ai/hotcrm, those dialogs stop being invisible and
that repo acquires real i18n findings — at warning today, and errors the moment anything there runs
--i18n-strict. That is the intended outcome, not a regression, but it is downstream work in another
repo and the multi-repo rule puts filing it on the seat that accepts this PR. Filing it now; this PR
is not blocked on it.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CLI i18n coverage + extract gain the flow/screen bucket — downstream of #11287

2 participants

@os-zhuang@zhuangjianguo