Skip to content

tooling(i18n): gate that every declared translation group is walked by the extractor - #14763

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate
Sep 4, 2026
Merged

tooling(i18n): gate that every declared translation group is walked by the extractor#14763
hotlong merged 5 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate

Conversation

@claude

@claude claude Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #14653

Ruling — the three unwalked groups are ledgered in this PR

The gate's first measurement (recorded below, unchanged) reported three declared translation groups that collectExpectedEntries produces no key for: messages, settingsCommon and settings. That red was reported rather than repaired, because the ledger path this gate offers is ⛔ MAINTAINER-ONLY by its own failure text.

It has now been ruled on — #14653, comment 5535827386, 2026-09-04. Option A: all three groups enter the exemption ledger in this PR, each with its reason; LEDGER_CEILING moves 0 → 3; no settings emitter is built now; packages/cli/** stays untouched. The maintainer's reply adopting that recommendation, verbatim and untranslated:

按照你的建议,跟进并处理完这两个pr。

and the direct dispatch of the follow-through, verbatim and untranslated:

这两个你帮我派发更新到合并。

So the ⛔ MAINTAINER-ONLY act named in the gate's own unwalked-group message is performed here under that authorization and nowhere else — by the owner the message names, not by a landing author reaching for green. Three consequences worth stating plainly:

  • settings is a DEFERRAL, not a finding of fact. Its terminal state — a registry-driven emitter on the metadataForms precedent, or removal from the per-app schema — is held on [finding] TranslationDataSchema.settings is declared, read by the settings service, and extractable by nothing — registry-driven emitter, or removal from the per-app schema (ADR-0049) #15178, and the ledger entry says so in its own reason so that nobody later reads the exemption as the answer. That card stays open; nothing here closes it.
  • packages/cli/** is untouched: 0 files. So is packages/spec/**: 0 files. The emitter question is not answered in this PR by design.
  • No check is weakened to accommodate the entries. The reason floor, the placeholder refusal, the both-direction size ratchet, the stale-entry detection, the empty-population refusal and the ⛔ MAINTAINER-ONLY remedy text are all exactly as they were. The only numbers that moved are the ledger's contents and its ceiling, in the same diff, which is the mechanism working rather than being bypassed.

pnpm check:i18n-walk-parity is green on this branch's head.

What lands

file what
scripts/check-i18n-walk-parity.mjs (new, 633 lines) the gate, with the ruled-in ledger
scripts/fixtures/i18n-walk-parity/every-group.stack.json (new) a stack config authoring one member of every group the walker can reach
package.json check:i18n-walk-parity, beside check:i18n / check:i18n-coverage
.github/workflows/lint.yml one step, beside the check:i18n-coverage step
scripts/check-ratchet-remedy-authority.mjs +14 lines — see "One file outside the declared surface"

git diff --stat against the merge-base, 5 files, all additions:

 .github/workflows/lint.yml                               |  21 +
 package.json                                             |   1 +
 scripts/check-i18n-walk-parity.mjs                       | 633 +++++++++++++++
 scripts/check-ratchet-remedy-authority.mjs               |  14 +
 scripts/fixtures/i18n-walk-parity/every-group.stack.json | 163 ++++++
 5 files changed, 832 insertions(+)

packages/cli/**: 0 files touched. packages/spec/**: 0 files touched. (git diff --name-only MERGE_BASE..HEAD | grep -c '^packages/' = 0.)

The mechanism

Declared side — the top-level keys of translationDataShape(), read off the built TranslationDataSchema's zod shape (packages/spec/dist/system/index.mjs). The contract, not the source text: a gate that grepped translation.zod.ts would agree with a comment and disagree with the schema.

Walked side — the path[0] of every entry collectExpectedEntries produces, imported from the built walker (packages/cli/dist/utils/i18n-extract.js) and run over the fixture. The walker's own output, not a transcription of its header. It is called with an empty warnedGroups set on purpose: the default skips groups the liveness ledger warns authors away from (flows today), which is right for os lint and wrong here — this gate asks whether the walker has an emitter, not whether the ledger currently lets it run.

Both sides come from built output, so the gate carries the same hard build prerequisite check-i18n-coverage.mjs states one file over, in the same shape: a PREREQUISITE NOT MET failure that says it measured nothing, never a skip. An empty declared or walked set is refused for the same reason.

Assertiondeclared \ (walked ∪ ledger) = ∅, ledger \ declared = ∅, and ledger ∩ walked = ∅ (an exemption for a group that is walked now is spent).

Not a derivation of the extractor from the resolvers, per the triage ruling and #14376's measurement.

Why the unit is the top-level group

It is the only unit both sides can name. path[0] is what the walker emits and a top-level shape key is what the schema declares; below that line the vocabularies stop corresponding. The liveness ledger for this same schema draws the boundary in the same place and says so: "WALK BOUNDARY: every group is a z.record keyed by target names — the drill sees each record's VALUE shape one level; the deeper per-key conventions … are governed by the resolvers cited per row".

The cost is stated in the file rather than hidden: a group that gains a new leaf the walker does not emit stays green here. That is a narrower defect than a whole family with no keys, and it is what the per-family pins under packages/cli/test/i18n-*-coverage.test.ts are for.

The exemption ledger

Ships with three entries (LEDGER_CEILING = 3), all three ruled in on 2026-09-04, in the KNOWN_IMPORT_UNSAFE shape from scripts/check-entry-guard.mjs. Every constraint that made an empty ledger honest still holds, and now has something to hold:

  • every entry carries the reason the group has no extractor face. Blank, non-string, pro-forma (n/a, TODO, …) and under-24-character reasons all fail. The failure mode this survives is not a missing reason — the value is the reason — but a pro-forma one. All three shipped reasons are real sentences that name the mechanism, and the self-test now runs them through the gate's own reason checks.
  • shrink-only, both directions, from three. A stale entry (undeclared, or now walked) fails and names itself. LEDGER_CEILING refuses growth the author did not edit into the same diff, and refuses slack above the real size — so from here the number can only be walked down. When settings gets its emitter under [finding] TranslationDataSchema.settings is declared, read by the settings service, and extractable by nothing — registry-driven emitter, or removal from the per-app schema (ADR-0049) #15178, that entry goes stale and the gate says so out loud rather than letting it sit.
  • the ledger path is still ⛔ MAINTAINER-ONLY in the failure text, unchanged. Emitting the group remains the landing author's remedy, offered first and freely; exempting one remains a decision with an owner. This PR is that owner's decision written down, not a precedent for the next author.

Declared / walked, on this branch

node scripts/check-i18n-walk-parity.mjs --list, at 7e8b09ce0, pasted verbatim:

check-i18n-walk-parity --list

  declared (11) — top-level keys of TranslationDataSchema
    apps               walked
    dashboards         walked
    datasets           walked
    flows              walked
    globalActions      walked
    messages           ledgered
    metadataForms      walked
    objects            walked
    pages              walked
    settings           ledgered
    settingsCommon     ledgered

  walked (8) — path[0] over scripts/fixtures/i18n-walk-parity/every-group.stack.json
    apps, dashboards, datasets, flows, globalActions, metadataForms, objects, pages

  ledger (3/3)
    messages: Keyed by arbitrary ids composed at the `i18n.t()` call sites (plugin-audit's activity-feed and mention strings); no registry and no stack config enumerates that id set, so there is no face for an extractor to walk.
    settingsCommon: The Settings UI's own five source-badge labels (env / global / tenant / user / default) — the console's words in every app rather than any app's own, ruled out of the per-app bundles on #7646.
    settings: Keyed by `SettingsManifest.namespace`, and manifests are platform code (`packages/services/service-settings/src/manifests/*.manifest.ts`), not authored metadata: no stack config carries them, and no consumer asks for per-app settings translations today. A DEFERRAL, not a fact — the terminal state (a registry-driven emitter on the `metadataForms` precedent, or removal from the per-app schema) is held on #15178.

The three families #14253 declared (bulkActions under objects._views, datasets, object-scoped _validations) are all walked — PR #14651 landed the fourth manual walk before this branch was cut, and the gate confirms it rather than assuming it.

The first measurement — the three unwalked groups, now recorded as ledgered

The per-group measurements below are the ones the first run produced; they are what the ruling was made on, and each is now the substance of that group's ledger reason. Kept unchanged so the decision can be re-judged against what was actually measured.

settings — keyed by SettingsManifest.namespace. Manifests are platform code, not authored metadata: packages/services/service-settings/src/manifests/*.manifest.ts, with a convenience aggregate exported from that directory's index.ts, and settings is not in MetadataCategoryEnum. So a stack config cannot declare one and the walker's input never carries them. But that is exactly the shape metadataForms already has, and the walker emits that group — walkMetadataForms iterates METADATA_FORM_REGISTRY unconditionally, independent of the config. The same route is open here. Of the three, this is the one that looks like a real missing emitter rather than a real exemption — which is why its ledger entry is written as a deferral and its terminal state is held on #15178, not as a claim that no emitter is possible.

messagesz.record(z.string(), z.string()), keyed by arbitrary message id and consumed through i18n.t() with composed keys (plugin-audit's activity-feed and mention strings). Nothing in a stack config, and no registry anywhere, enumerates the id set. The liveness ledger's own note flags it as easy to mis-verify for the same reason. No extractor face is derivable.

settingsCommon — a fixed strictObject of five Settings-UI source-badge labels (env / global / tenant / user / default). Not keyed by anything an author declares; the platform console's own words. The same class as the flow-runner chrome the maintainer ruled out of per-app bundles on #7646 ("they are the console's words in every app, so putting them in a per-app bundle would ask every app to re-translate the platform").

Verification

Re-run in a dedicated worktree at 7e8b09ce0, the head of this branch after origin/main was merged in (merge-base 7bc5d37e4). Exit codes captured before any pipe — redirect first, then read $? — and every verdict below is the gate's own printed line.

Self-testnode scripts/check-i18n-walk-parity.mjs --self-test, exit 0:

✓ check-i18n-walk-parity self-test: 23 cases pass — an added declared group and a deleted emitter are both named, a ledgered group is not, a blank/placeholder/short reason is refused, a stale entry (undeclared, or now walked) fails, the size ratchet refuses growth AND slack, an empty side is refused, the recorded sample of today's real group names reproduces its verdict, and the SHIPPED ledger is pinned to exactly that recorded unwalked set — every reason passing the reason checks, its size exactly at the ceiling.

The case count is counted at runtime, never transcribed. It moved 21 → 23 because the single ledger is empty pin — no longer true, and no longer the thing worth pinning — was replaced by three that stay true and still bite: the ledger's key set equals RECORDED_UNWALKED (every recorded unwalked group is ledgered, and nothing else is), every shipped reason passes ledgerShapeProblems, and the shipped ledger's size is exactly LEDGER_CEILING under the real ratchet classifier.

The gatepnpm check:i18n-walk-parity (self-test leg then production leg), exit 0:

✓ check-i18n-walk-parity: 11 declared group(s), 8 walked, 3 exempted — every declared group has an extractor face.

Negative control — the ledger is live, not merely green. LEDGER_CEILING was temporarily set to 2 and the mutation proved on disk before any reading was taken (git hash-object moved 64a7179f9…e55e6fe06…; injected LEDGER_CEILING = 2 count 1, removed LEDGER_CEILING = 3 count 0):

  • production leg, exit 1: "the ledger holds 3 entries against a ceiling of 2 — growth is a reviewed act: raise LEDGER_CEILING in the same diff as the entry, or do not add it"
  • self-test leg, exit 1, on the new pin specifically: "shipped ledger: size is exactly LEDGER_CEILING: got 1, want 0" — so a ceiling raised without an entry, or an entry added without the ceiling, reddens in the self-test as well as in production.
  • restore leg, proved rather than assumed: git checkout HEAD -- (absolute path), hash back to 64a7179f9… (the HEAD blob), git diff HEAD empty, git status --porcelain empty, then both legs re-run green at the quoted lines above.

Gate union

Derived in this worktree with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (the repo assertion held against this checkout's origin; the change set is derived by the tool itself from the merge base, five paths) — 47 runnable commands, all run at 7e8b09ce0.

Green (45) — including every family that judges a scripts/ gate script: check:i18n-walk-parity, check:ratchet-remedy-authority (188 scripts swept; 13 mark the expanding remedy ⛔ MAINTAINER-ONLY), check-self-test-wired, check-self-test-workflow-commands, check:entry-guard, check:parse-guard, check:watch-hint-literal, check:declared-population-live, check:pm-dispatch-gates, check:nul-bytes, check:cross-package-test-inputs, check-ci-filter-parity, check-closing-keyword-parity, check-comment-mask-corpus, check-aggregator-roster, check-step-collectors, check-whole-set-label-write, check:pnpm-filter-targets, check:type-check-coverage, check:type-check-debt, check:turbo-task-graph, bare-root-worklist --self-test, and the rest.

Red: none.

NOT MEASURED (2) — each says so itself, with an exit code distinct from a finding's 1. Neither is a finding and neither can be moved by this diff:

  • pnpm check:dual-build-cjs-loads, exit 3"PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. ⛔ This is NOT a pass: nothing was measured." The named packages (studio, client-react, the connectors, two plugins) are outside this branch's build closure, which was built to the CLI's dependencies rather than the whole workspace. Its --self-test leg passes (93 cases). CI runs it after the full build step.
  • node scripts/check-required-contexts.mjs --verify-required-set, exit 2"required-set sweep: NOT VERIFIED — GET https://api.github.com/repos/objectstack-ai/objectstack answered HTTP 401 … NOT VERIFIED is not a pass and not a failure of the tree; exit 2 classifies the ENVIRONMENT." Re-run with NODE_OPTIONS=--use-env-proxy for completeness: still exit 2, HTTP 403. This container's repo-scoped REST reads are 403 across the board, so the classification is the environment's and not the tree's. The sibling pnpm check:required-contexts (the tree-side half) is green.

Also run outside the derived union: npx eslint --no-inline-config scripts/check-i18n-walk-parity.mjs — clean.

origin/main was merged into the branch as its own commit (the merge brought 199 commits; no conflicts, and the branch's single added lint.yml step survived beside check:i18n-coverage).

One file outside the declared surface

scripts/check-ratchet-remedy-authority.mjs, +14 lines. That gate reached the new file and was right (#8435): the unwalked-group message offered two remedies, and one of them expands a shrink-only registry without naming an owner — the author excusing themselves from the check they just failed. Its two findings were UNMARKED and UNCLASSIFIED, and it states that the second fix "needs nobody's leave — the control is a declaration registry, not a ratchet".

So: the ledger path is now marked ⛔ MAINTAINER-ONLY in the gate's own text, and the gate is registered in that gate's hand-classified control corpus as marked — the same reading as check-auth-mount-ledger.mjs and check-exported-any-returns.mts, and for the same reason (refused would be untrue; a group keyed by strings no stack config declares genuinely has no extractor face, so a legitimate act exists and what it needs is an owner). Re-run green on this head.

⚠️ One prose drift this patch round created and deliberately did not fix, because it is outside the round's declared file surface: that file's control-corpus note and its why string both say this gate's exemption ledger "ships EMPTY" / "ships empty and only shrinks". As of this PR it ships with three entries. Nothing mechanical reads those sentences — the checked field is expect: 'marked', which is unchanged and green — so this is a documentation correction, filed as #15189 rather than smuggled in.

Not in scope

No changeset — scripts/** + root manifest + workflow publish nothing from any package, so skip-changeset (already on the PR). content/docs/releases/** untouched. lint.yml carries exactly one added step, adjacent to check:i18n-coverage.

🤖 Generated with Claude Code

claude Bot and others added 3 commits September 2, 2026 23:38
…y the extractor

A translation key family lands in `packages/spec` with a resolver that reads
it, and the CLI extractor that produces the coverage population is updated
later or not at all. In between, `os i18n extract` scaffolds no key for the
family, so no translator is asked for one, so `check:i18n-coverage` measures a
debt of zero and reports OK while the strings ship in English. Five instances
so far, every one found by a person noticing rather than by an instrument.

`scripts/check-i18n-walk-parity.mjs` is the instrument. It reads the DECLARED
side off the built `TranslationDataSchema`'s zod shape (the contract, not the
source text) and the WALKED side off `collectExpectedEntries` run over a
fixture stack config that authors a member of every group, then asserts
`declared \ (walked u ledger) = {}` and that no ledger entry is stale.

The exemption ledger ships EMPTY and is shrink-only in the `KNOWN_IMPORT_UNSAFE`
shape: every entry carries the reason the group has no extractor face, a blank
or pro-forma reason fails, a stale entry names itself, and `LEDGER_CEILING`
refuses growth the author did not edit into the same diff.

Deliberately NOT a derivation of the extractor from the resolvers: the two
enumerations answer different questions over different inputs. The unit is the
top-level group, the one name both sides can spell.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
`check:ratchet-remedy-authority` reached the new gate and was right (#8435): its
unwalked-group message offered two remedies and one of them EXPANDS a
shrink-only registry, which is the author excusing themselves from the check
they just failed. Emitting the group in the extractor stays the landing author's
remedy and is offered first; adding an exemption is now named with its owner.

Registers the gate in that gate's hand-classified control corpus as `marked`,
the same reading as check-auth-mount-ledger.mjs and check-exported-any-returns.mts:
there IS a legitimate act here — a group keyed by strings no stack config
declares genuinely has no extractor face — so refusal would be untrue.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 3, 2026
@github-actions github-actions Bot added size/l ci/cd dependencies Pull requests that update a dependency file labels Sep 3, 2026

hotlong commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Maintainer ruling (director seat, summon 13): A — all three unwalked groups enter KNOWN_NO_EXTRACTOR_FACE in this PR with their reasons (messages: ids composed at call sites, nothing enumerates them; settingsCommon: the console's own five words, ruled out of per-app bundles on #7646; settings: platform-code manifests no stack config carries, no per-app consumer today), LEDGER_CEILING 0 → 3, the "READ FIRST" paragraph replaced by the ruling. The ⛔ MAINTAINER-ONLY act is authorized by the maintainer for these three entries in this PR (record on card #14653, comment 5535827386; quote it in the body). No settings emitter now: its terminal state is held on finding #15178. packages/cli/** stays untouched. Then merge origin/main if behind, gate and --self-test green, Lint & Repo Gates green, and land through the normal path (ready, auto-merge squash). The director seat follows this PR to MERGED.


Generated by Claude Code

…iner ruling

The walk-parity gate's own first red named three declared translation groups
with no extractor face: `messages`, `settingsCommon` and `settings`. The
maintainer ruled (2026-09-04, #14653 comment 5535827386) that all three enter
the gate's exemption ledger in this PR, each with its reason, and that no
`settings` emitter is built now.

That act is ⛔ MAINTAINER-ONLY by the gate's own failure text, so it is taken
under that ruling and nowhere else:

  messages        keyed by arbitrary ids composed at the `i18n.t()` call sites;
                  no registry or stack config enumerates the id set.
  settingsCommon  the Settings UI's own five source-badge labels — the console's
                  words in every app, ruled out of per-app bundles on #7646.
  settings        keyed by `SettingsManifest.namespace`, which is platform code
                  rather than authored metadata. A DEFERRAL, not a fact: the
                  terminal state is held on #15178.

`LEDGER_CEILING` moves 0 -> 3 in the same diff, which is the mechanism working
as designed. No check is weakened: the reason floor, the placeholder refusal,
the both-direction size ratchet, the stale-entry detection, the empty-population
refusal and the MAINTAINER-ONLY remedy text are all untouched.

The self-test's `ledger is empty` pin is replaced by three that stay true and
still bite — the ledger's key set equals the recorded unwalked set, every
reason survives the gate's own reason checks, and its size is exactly the
ceiling under the real ratchet classifier, so an entry without a ceiling bump
(or a ceiling bump without an entry) reddens in the self-test as well as in
production.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXyGTWPbbreqXow7Z2pZCk

hotlong commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Landing — director seat, summon 13, session_01WXyGTWPbbreqXow7Z2pZCk, 2026-09-04 ~05:52Z, on the maintainer's direct dispatch 「这两个你帮我派发更新到合并。」 (claim 5535946497 on #14653).

Head 7e8b09ce0 read on origin/claude/issue-14653-i18n-walk-parity-gate: origin/main merged (zero conflicts, the single lint.yml step kept beside check:i18n-coverage); scripts/check-i18n-walk-parity.mjs carries the three KNOWN_NO_EXTRACTOR_FACE entries (messages, settingsCommon, settings) with their reasons and LEDGER_CEILING = 3, exactly the ruling recorded at 5535827386 / 5535829928 on #14653 (the MAINTAINER-ONLY act, taken under the maintainer's 「按照你的建议,跟进并处理完这两个pr。」); the self-test's empty-ledger pin is replaced by three shipped-ledger pins (keys = the recorded unwalked set, every reason passes the reason checks, size = ceiling); no check weakened; packages/** 0 files. The dev's report on #14653 quotes the gate green (11 declared group(s), 8 walked, 3 exempted), the self-test at 23 cases, a proven negative control (ceiling 2 ⇒ exit 1, restored byte-identical), and 45 of 47 derived gates green with 2 NOT MEASURED on prerequisites.

CI on 7e8b09ce0: Lint & Repo Gates success (05:37Z) — the new gate is green on this head; Type Check (workspace / source gates / consumer gates / debt ledger), Build Core, Test Core, Temporal Conformance, Governed Surface Queue Guard all success; Check Changeset skipped (skip-changeset).

Marked ready; squash auto-merge armed in the next stroke; followed to MERGED. #14653 closes with the merge. The out-of-scope prose finding the dev filed (#15189, the remedy-authority gate's "ships EMPTY" wording) is domain:devx triage's.


Generated by Claude Code

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

Labels

ci/cd dependencies Pull requests that update a dependency file size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants