Skip to content

fix(cli): a written inline I18nLabel map is no longer reported as an untranslated string - #15980

Draft
zhuangjianguo wants to merge 6 commits into
mainfrom
claude/issue-14749-inline-i18nlabel-not-unauthored
Draft

fix(cli): a written inline I18nLabel map is no longer reported as an untranslated string#15980
zhuangjianguo wants to merge 6 commits into
mainfrom
claude/issue-14749-inline-i18nlabel-not-unauthored

Conversation

@zhuangjianguo

@zhuangjianguo zhuangjianguo commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #14749

Maintainer ruling of 2026-09-03 on this card, Q2 = B1 and Q3 = C3: the coverage gate stops recording a fully-written inline I18nLabel map under the same diagnostic as a key the author never wrote, and ⛔ no key scheme is built from a node's path in the page tree. Q1 = A2 is untouched — content and body still do not join PAGE_COMPONENT_COPY_KEYS, and no packages/spec schema changes.

Authored by Claude Code in session session_01ARYe3yQTQCUFm5qPYNgKaJ, stated here in prose because the platform normalises the session-URL footer down to the bare form on every edit.

The defect, and why it had two faces

I18nLabelSchema authorizes two forms of a display label: a plain string whose translations live in a bundle, and an inline locale map written out at the authoring site. The walk read only the first. inlineText() narrowed a map to undefined — which is also what an absent prop produces — so one value carried two opposite facts from the very first line of the walk, and everything downstream inherited the confusion:

  • quiet face — with no bundle entry anywhere, the key failed the "is anything authored here" filter and was dropped from the expected set. A page localised into four languages was neither covered nor missing. It did not appear at all.
  • loud face — with a bundle entry for one locale, the key re-entered the expected set carrying no inline evidence, so every locale the map held and the bundle did not was reported missing translation — about text sitting in the file.

Mechanism — how the gate now sees the map, with no node-path key

Nothing is addressed by position, because no new address was needed. Every one of these props already has a translation key (objects.member.fields.email.help, pages.member_directory.label, …); what the walk lacked was evidence that the author had written anything at it. So the fix adds a third axis to an expected entry beside sourceValue and inlineinlineLocales, the map the author wrote, carried verbatim from the authoring site to the detector — and the three push* helpers become the one place that reads it, so every call site inherits the behaviour instead of each re-deciding it. The detector then answers per locale from two sources rather than one: the bundle first, then the map's own entry for that locale. A key stays exactly where it always was, no bundle row is scaffolded, no key family is added, and an array index never becomes an address.

The narrowing that makes this a real measurement rather than a blanket pass: only the tag-matching limbs of the shared resolveI18nLabel rule count as coverage (exact tag, base language, region-qualified sibling of the same base). Its three fallback limbs — the untagged default entry, en, then any string in the map — are excluded on purpose, because falling back is what an untranslated locale looks like; reading coverage off the renderer's full chain would report every locale as covered the moment a map exists, which is the same one-answer-for-two-facts bug wearing the other hat.

Recognising a map is delegated to InlineLocaleMapSchema rather than pattern-matched here, so the retired { key, defaultValue } key-reference dialect that schema refuses by name is not laundered into "authored" by a second, drifting predicate.

Red first, then green — and the true report still fires

packages/cli/test/i18n-inline-locale-map-coverage.test.ts, run against origin/main's copies of the two changed source files (restored with git checkout from the merge base, both confirmed byte-identical to the origin/main blobs before the run, restored after, working tree verified clean):

red  (origin/main sources)  vitest exit 1 — 10 failed | 5 passed (15)
green (this branch)         vitest exit 0 — 15 passed (15)

Three assertions, verbatim from the red run:

  • the false reportobjects.member.fields.email.help, a map holding en / zh-CN / ja-JP with an en bundle row: expected [ 'ja-JP', 'zh-CN' ] to deeply equal []. Two locales reported missing while written out in full.
  • the true report — a partial map holding only en / zh-CN, asked about ja-JP: expected [] to deeply equal [ 'ja-JP' ]. Today's gate says nothing about a locale that is genuinely absent; after the change it reports it.
  • a map used where only a string was ever meant — the derived-seed sites read x.label ?? fallback, which is truthy for a map, so the seed handed to the bundle writer was the map object: seed was object. A view whose label is a map wrote a nested locale record into the extracted bundle where a translator expects a string. Same defect class, one expression over; the seed now narrows through inlineText first and the pin holds it to a string or nothing.

The second of those is the answer to "is this just the check switched off": the change moves the verdict in both directions. And the five pins that pass on both legs are exactly the controls — an untranslated plain string still missing in every other locale, an empty map still not authoring, the retired key-reference dialect still refused, no bundle row scaffolded for a map in any locale, and every emitted key still free of a numeric path segment.

Verification

Union re-run at head 0691987f3 (after merging origin/main), exit codes captured before any pipe:

command exit
pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/i18n test/platform-page-i18n-parity.test.ts test/lint-platform-fold.test.ts test/unbuilt-workspace-lead.test.ts 0 — 20 files, 230 tests passed
pnpm --filter @objectstack/cli typecheck 0
pnpm --filter @objectstack/cli build 0
pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated 0 — all 15 generated artifacts up to date
pnpm check:i18n-coverage 0 — 13 config(s), 621 baselined untranslated string(s), none new
pnpm check:i18n 0 — 9 package(s) — all bundles in sync
pnpm check:i18n-walk-parity 0
pnpm check:i18n-stale-fill 0
pnpm check:app-nav-i18n 0
pnpm check:cross-package-test-inputs 0
pnpm check:test-source-alias 0
pnpm check:cli-test-child-env 0
pnpm check:changeset-gate-self-tests 0
pnpm check:nul-bytes 0
pnpm lint (whole repo, eslint . --no-inline-config) 0

The ratchet did not move: 621 before, 621 after. That is the measured confirmation of the projection recorded on the card — no config in this gate's population declares a page carrying an inline map, so the counting change has nothing to count there. On a project that does author them, it changes in both directions.

Published docs, read per file: nothing falsified

The drift checker reports that this diff yields no anchor for packages/spec/src/ui/i18n.zod.ts and names its own structural blind spot — a page stating a rule by its INPUTS shares no identifier with the EMITTER implementing it, and this is an emitter behaviour change. So the question was answered by reading rather than by an anchor.

content/docs/ui/translations.mdx and content/docs/protocol/kernel/i18n-standard.mdx — the two hand-written pages that document i18n coverage and os i18n extract — were read end to end. Neither states that an inline map is reported as untranslated, that only plain-string labels count toward coverage, or that os i18n extract scaffolds a bundle key for a map; neither carries a worked example whose coverage numbers move (the CRM example uses plain-string labels throughout, and i18n-standard's 450/450 output block is an illustrative sample of a hypothetical stack, not a measured example from this repo). Nothing to fix under E3.

Positive control for that null, on the same command and scope: the phrase "inline locale map" fires on 12 files under content/docs/, and 0 of the 12 are outside content/docs/references/. Every occurrence is in the auto-generated tree, is a schema .describe() about rendering ("resolved at render time") or about bundle addressing ("no translation-bundle slot addresses this key"), and remains true — this change adds no bundle slot. check:docs confirms that tree is in sync, and the docblock edited here does not feed it (0 hits for its new prose in the generated output).

content/docs/releases/v15/v16/v17 mention os i18n extract and I18nLabel historically, about other keys; none is falsified. ⛔ Not edited either way — release pages are never touched in a code PR.

The one thing the reading did find is a gap, not a falsehood, and it is filed as #15984: the translations guide never mentions inline locale maps at all, which matters more after this PR because an author writing a partial map now gets a real lint finding the guide gives them no vocabulary for. Same class as #15435, which was accepted and fixed.

Clause-2 declaration, re-derived from the diff: no

Nothing in packages/spec changes shape — the only edit there is 31 lines inside a docblock, and check:api-surface, check:authorable-surface and check:docs all report their artifacts unchanged. os i18n extract emits exactly what it emitted: a map-only entry carries no sourceValue, and the extractor's bundle build already filters on that, which the pin asserts per locale. No bundle key, no key family, no new authorable key. What changes is the diagnostic set os lint prints — one false finding removed, one real finding added — and a gate that stops lying widens no published contract face.

Scope boundary, stated because it is easy to read this PR as wider than it is

This addresses the fourth of the four layers the measurement on this card identified. The other three are untouched and are not this card's: the three platform record pages are in no extract config (#14817), the walk's roots are regions[].components[] while those pages author under slots.*, and content / body have no bundle key family (settled as A2). The stale "31" in i18n.zod.ts is #14816 and is deliberately left as it is.

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

31 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/ui/i18n.zod.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/ui/i18n.zod.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 135 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 aa6ba0623a983e90145c8a29fb6548a0965f645cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 8c3f5ce10f9bfd74b8ad96a8927ff1acfce777fb — the merge of head 0691987f39d31bfe79efb9277b704002d254527a into base aa6ba0623a983e90145c8a29fb6548a0965f645c, 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 8c3f5ce10f9bfd74b8ad96a8927ff1acfce777fb && git checkout 8c3f5ce10f9bfd74b8ad96a8927ff1acfce777fb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aa6ba0623a983e90145c8a29fb6548a0965f645c 0691987f39d31bfe79efb9277b704002d254527a && git checkout -B drift-repro aa6ba0623a983e90145c8a29fb6548a0965f645c && git merge --no-ff 0691987f39d31bfe79efb9277b704002d254527a

node scripts/docs-audit/affected-docs.mjs --json aa6ba0623a983e90145c8a29fb6548a0965f645c

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

Copy link
Copy Markdown
Collaborator Author

Standing down on the red Lint & Repo Gates: measured, and it is not this PR's. Filed as #15990. Nothing changed here, and nothing was skipped, disabled or re-triggered.

What failed

pnpm check:merge-drivernode scripts/check-regen-pending.mjs --self-test, run 33980432580 / job 101344623621. 11 assertions red inside the deferred-merge sequence battery.

The reading

The self-test's verdict is a pure function of the gate runner, not of the tree. Its fixture writes a stub package.json into a temp dir under tmpdir() carrying only name and scripts, then invokes the stub through ownerRunCommand, which is pnpm <script> — so it depends on an ambient pnpm resolving in a directory that declares no packageManager and has no parent manifest to inherit one from. In CI that launcher is corepack (COREPACK_HOME is in the failing job's env). When it cannot resolve, every stub collapses to "the gate exited non-zero", which the script reads as stale — so precisely the stubs whose expected outcome is NOT stale go red. That is the exact 11-red / 3-interleaved-green shape in the log, greens included.

Reproduced by shimming pnpm to fail. Exit codes captured before any pipe:

tree gate runner exit
origin/main f7db8f4 real pnpm 0
this PR's head 0691987 real pnpm 0
this PR's CI merge ref 8c3f5ce real pnpm 0
origin/main f7db8f4 broken pnpm 1
this PR's head 0691987 broken pnpm 1

The two failing runs are byte-identical to each other and carry CI's own summary line verbatim. origin/main fails the same way under the same condition, and this PR's tree — including the exact merge ref CI built, which is a newer main than the one merged here — passes under a working one. ⛔ Not called a flake: the cause is named and reproduced 2/2 on two different trees.

Also worth recording, since it invites a wrong read: 1 failure(s) (cases and floor) alongside 11 red assertions is not a floor breach. A battery contributes one entry to results and prints its own per-assertion lines, so 1 is one failing battery. The floor held.

Consequence

The defect can only produce a false RED, never a false green — a failing launcher makes every artifact look stale, and stale is the refusing verdict. So it costs a triage cycle on an innocent PR rather than letting anything through. #15990 carries the mechanism, the 2x2, and three candidate directions, none of them implemented here.

Re-running this check should clear it whenever the launcher resolves; that call is the PM's, and the one permitted re-run is unspent.


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation protocol:ui size/l tests tooling

Projects

None yet

2 participants