tooling(i18n): gate that every declared translation group is walked by the extractor - #14763
Draft
claude[bot] wants to merge 3 commits into
Draft
tooling(i18n): gate that every declared translation group is walked by the extractor#14763claude[bot] wants to merge 3 commits into
claude[bot] wants to merge 3 commits into
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
This was referenced Sep 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #14653
main, by construction, and that red is the deliverable's first measurementpnpm check:i18n-walk-parityexits 1 on this branch and onorigin/main, naming three declared translation groups thatcollectExpectedEntriesproduces no key for:The exemption ledger ships EMPTY, per the triage ruling, and I did not put these three in it to reach green: "this group legitimately has no extractor face" is a decision with a reason and an owner, not a way past a red.
packages/cli/src/**is untouched, per the card's scope, so the repair is not in this PR either. Do not mark this ready and do not merge it until that is ruled on — merging as-is turnsLint & Repo Gatesred onmain. The three groups, with what I measured about each, are in "The first measurement" below.What lands
scripts/check-i18n-walk-parity.mjs(new, 587 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. ⛔ PR #14651's fourth walk is not folded in.
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 EMPTY (
KNOWN_NO_EXTRACTOR_FACE = Object.freeze({}),LEDGER_CEILING = 0), in theKNOWN_IMPORT_UNSAFEshape fromscripts/check-entry-guard.mjs: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.LEDGER_CEILINGrefuses growth the author did not edit into the same diff, and refuses slack above the real size, so the number can only be walked down.⛔ MAINTAINER-ONLYin the failure text. Emitting the group is the landing author's remedy and is offered first, freely; exempting one is not.Declared / walked, on this branch
node scripts/check-i18n-walk-parity.mjs --list: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
Reported, not repaired, and not ledgered. What I measured about each, so whoever rules on it is not starting from zero:
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.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").Reading, offered rather than taken:
settingswants an emitter (registry-driven, themetadataFormsprecedent);messagesandsettingsCommonwant ledger entries with those reasons. Both acts are outside this card's scope — the first ispackages/cli, the second is⛔ MAINTAINER-ONLYby this gate's own rule.Verification
Every gate below was run at
856fac666, the head of this branch. Exit codes captured before any pipe; the verdict quoted is the gate's own line.Self-test —
node scripts/check-i18n-walk-parity.mjs --self-test, exit 0:The case count is counted at runtime, not transcribed. Case 21 is the recorded sample: today's real declared and walked names, pinned so that weakening the rule reddens here even on a tree where production is red for its own reasons.
Ablations
Each mutation was proved on disk before the reading was taken (anchor text gone, injected marker present,
git hash-objectmoved), rebuilt where the gate reads built output, proved to have reacheddist/withscripts/ablation-dist-preflight.mjs, and restored to a byte-identical tree (git hash-objectback to the HEAD blob, whole-treegit status --porcelainempty, marker absent from the rebuiltdist/).(a) delete a group's emitter → red naming that group. Removed the
walkDatasets(config, out);call fromcollectExpectedEntries, rebuilt@objectstack/cli.mutate leg:
✓ dist/: marker present in 1 built file;✓ dist/: marker absent from all 468 built filesfor the deleted call; dist call-count 0, declaration-count 1.gate: exit 1,
4 declared translation group(s) that the extractor does not walk:—datasets, messages, settings, settingsCommon.restore leg: hash back to
13dc74a6d…,✓ tree: working tree clean against HEAD,✓ dist/: marker absent from all 468 built files, dist call-count back to 1, gate back to the 3-group baseline.walkDatasets(config, out)(no semicolon) as the absent-marker, which also matches the function declaration indist, so its mutate-leg preflight could never go absent and reported✗. The reading was discarded and the whole ablation re-run with a discriminating marker; the numbers above are from the re-run.(b) add a group to
TranslationDataSchemawith no walker change → red naming it. InjectedablationSyntheticGroupintotranslationDataShape(), rebuilt@objectstack/spec(whole-subtree restore, because the spec build regenerates committed artifacts).✓ dist/: marker present in 18 built files.4 declared translation group(s) that the extractor does not walk:—ablationSyntheticGroup, messages, settings, settingsCommon.738d284dc…, whole-treegit status --porcelainempty,✓ dist/: marker absent from all 89 built files, gate back to the 3-group baseline.(c) a ledger entry without a reason → red. Two mutations of the shipped ledger, each proved on disk and restored byte-identically:
{ messages: '' }→ exit 1,messages — carries no reason — the reason IS the entry{ messages: 'n/a' }→ exit 1,messages — reason is a placeholder ("n/a"), not a reason(d) a stale ledger entry → STALE red. Both directions:
{ datasets: REASON }(a group the walker DOES emit) → exit 1,datasets — the walker DOES emit it now; the exemption is spent{ validationMessages: REASON }(retired in 17.0.0, no longer declared) → exit 1,validationMessages — no longer declared by TranslationDataSchema(e) ledger growth past the ceiling → red.
{ messages: VALID_REASON }withLEDGER_CEILING = 0→ exit 1,the ledger holds 1 entry against a ceiling of 0 — growth is a reviewed act.Gate union
Derived in this worktree with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (the 5 changed paths)— 37 commands. All run at856fac666.Green (33), including the ones that judge a new
scripts/gate:check-self-test-wired(every one of the 165 scripts CI runs that ship a--self-testhas that self-test run by CI),check-self-test-workflow-commands,check:entry-guard(54 cases),check:parse-guard(46 cases),check:declared-population-live(159 of 203 families declare a path population, and every one reaches this tree's 8061 tracked files),check:watch-hint-literal(57 cases),check:required-contexts,check-aggregator-roster,check-step-collectors,check-shard-attestation,check:pm-dispatch-gates(1241 cases),check:ratchet-remedy-authority,check:pnpm-filter-targets,check:type-check-coverage, and the rest.Red (1) —
pnpm check:i18n-walk-parity, exit 1: this PR's own gate, on the three groups above. Its--self-testleg passes; the production leg is the finding.NOT MEASURED (3) — all three refuse on a missing full-workspace build and say so themselves with exit code 3, distinct from a finding's 1. None is a finding and none can be moved by this diff (one new
.mjs, one JSON fixture, onepackage.jsonscript line, one workflow step):check-test-completeness.mjs— "Nothing was measured: this gate exited before parsing a single summary line"check:dual-build-cjs-loads— "PREREQUISITE NOT MET — this gate reads built output, and some package has nodist/. ⛔ This is NOT a pass"check:type-check-debt— "--re-measurecannot run: 1 workspace dependency … has no built type entry point on disk"CI runs all three after the build step.
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). Both were re-run green afterwards.Not in scope
No changeset —
scripts/**+ root manifest + workflow publish nothing from any package, soskip-changeset.content/docs/releases/**untouched.lint.ymlcarries exactly one added step, adjacent tocheck:i18n-coverage, disjoint from the region draft PR #14277 holds.🤖 Generated with Claude Code
https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
Generated by Claude Code