tooling(i18n): gate that every declared translation group is walked by the extractor - #14763
Conversation
…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
…8n-walk-parity-gate
`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
|
Maintainer ruling (director seat, summon 13): A — all three unwalked groups enter Generated by Claude Code |
…8n-walk-parity-gate
…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
|
Landing — director seat, summon 13, session_01WXyGTWPbbreqXow7Z2pZCk, 2026-09-04 ~05:52Z, on the maintainer's direct dispatch 「这两个你帮我派发更新到合并。」 (claim 5535946497 on #14653). Head CI on 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 Generated by Claude Code |
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
collectExpectedEntriesproduces no key for:messages,settingsCommonandsettings. That red was reported rather than repaired, because the ledger path this gate offers is⛔ MAINTAINER-ONLYby 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_CEILINGmoves 0 → 3; nosettingsemitter is built now;packages/cli/**stays untouched. The maintainer's reply adopting that recommendation, verbatim and untranslated:and the direct dispatch of the follow-through, verbatim and untranslated:
So the
⛔ MAINTAINER-ONLYact 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:settingsis a DEFERRAL, not a finding of fact. Its terminal state — a registry-driven emitter on themetadataFormsprecedent, or removal from the per-app schema — is held on [finding]TranslationDataSchema.settingsis 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 ispackages/spec/**: 0 files. The emitter question is not answered in this PR by design.⛔ MAINTAINER-ONLYremedy 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-parityis green on this branch's head.What lands
scripts/check-i18n-walk-parity.mjs(new, 633 lines)scripts/fixtures/i18n-walk-parity/every-group.stack.json(new)package.jsoncheck:i18n-walk-parity, besidecheck:i18n/check:i18n-coverage.github/workflows/lint.ymlcheck:i18n-coveragestepscripts/check-ratchet-remedy-authority.mjsgit diff --statagainst the merge-base, 5 files, all additions: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 builtTranslationDataSchema's zod shape (packages/spec/dist/system/index.mjs). The contract, not the source text: a gate that greppedtranslation.zod.tswould agree with a comment and disagree with the schema.Walked side — the
path[0]of every entrycollectExpectedEntriesproduces, 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 emptywarnedGroupsset on purpose: the default skips groups the liveness ledger warns authors away from (flowstoday), which is right foros lintand 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.mjsstates one file over, in the same shape: aPREREQUISITE NOT METfailure that says it measured nothing, never a skip. An empty declared or walked set is refused for the same reason.Assertion —
declared \ (walked ∪ ledger) = ∅,ledger \ declared = ∅, andledger ∩ 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 az.recordkeyed 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.tsare for.The exemption ledger
Ships with three entries (
LEDGER_CEILING = 3), all three ruled in on 2026-09-04, in theKNOWN_IMPORT_UNSAFEshape fromscripts/check-entry-guard.mjs. Every constraint that made an empty ledger honest still holds, and now has something to hold: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.LEDGER_CEILINGrefuses 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. Whensettingsgets its emitter under [finding]TranslationDataSchema.settingsis 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.⛔ MAINTAINER-ONLYin 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, at7e8b09ce0, pasted verbatim:The three families #14253 declared (
bulkActionsunderobjects._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 bySettingsManifest.namespace. Manifests are platform code, not authored metadata:packages/services/service-settings/src/manifests/*.manifest.ts, with a convenience aggregate exported from that directory'sindex.ts, andsettingsis not inMetadataCategoryEnum. So a stack config cannot declare one and the walker's input never carries them. But that is exactly the shapemetadataFormsalready has, and the walker emits that group —walkMetadataFormsiteratesMETADATA_FORM_REGISTRYunconditionally, 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.messages—z.record(z.string(), z.string()), keyed by arbitrary message id and consumed throughi18n.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 fixedstrictObjectof 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 afterorigin/mainwas merged in (merge-base7bc5d37e4). Exit codes captured before any pipe — redirect first, then read$?— and every verdict below is the gate's own printed line.Self-test —
node scripts/check-i18n-walk-parity.mjs --self-test, exit 0:The case count is counted at runtime, never transcribed. It moved 21 → 23 because the single
ledger is emptypin — 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 equalsRECORDED_UNWALKED(every recorded unwalked group is ledgered, and nothing else is), every shipped reason passesledgerShapeProblems, and the shipped ledger's size is exactlyLEDGER_CEILINGunder the real ratchet classifier.The gate —
pnpm check:i18n-walk-parity(self-test leg then production leg), exit 0:Negative control — the ledger is live, not merely green.
LEDGER_CEILINGwas temporarily set to2and the mutation proved on disk before any reading was taken (git hash-objectmoved64a7179f9…→e55e6fe06…; injectedLEDGER_CEILING = 2count 1, removedLEDGER_CEILING = 3count 0):git checkout HEAD -- (absolute path), hash back to64a7179f9…(the HEAD blob),git diff HEADempty,git status --porcelainempty, 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'sorigin; the change set is derived by the tool itself from the merge base, five paths) — 47 runnable commands, all run at7e8b09ce0.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-testleg 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 withNODE_OPTIONS=--use-env-proxyfor 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 siblingpnpm 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/mainwas merged into the branch as its own commit (the merge brought 199 commits; no conflicts, and the branch's single addedlint.ymlstep survived besidecheck: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 wereUNMARKEDandUNCLASSIFIED, 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-ONLYin the gate's own text, and the gate is registered in that gate's hand-classified control corpus asmarked— the same reading ascheck-auth-mount-ledger.mjsandcheck-exported-any-returns.mts, and for the same reason (refusedwould 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.whystring 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 isexpect: '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, soskip-changeset(already on the PR).content/docs/releases/**untouched.lint.ymlcarries exactly one added step, adjacent tocheck:i18n-coverage.🤖 Generated with Claude Code