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

Draft
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate
Draft

tooling(i18n): gate that every declared translation group is walked by the extractor#14763
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14653

⚠️ READ FIRST — this gate is RED on main, by construction, and that red is the deliverable's first measurement

pnpm check:i18n-walk-parity exits 1 on this branch and on origin/main, naming three declared translation groups that collectExpectedEntries produces no key for:

❌ check:i18n-walk-parity — 3 declared translation group(s) that the extractor does not walk:
messages — declared in TranslationDataSchema, produced by no emitter
settings — declared in TranslationDataSchema, produced by no emitter
settingsCommon — declared in TranslationDataSchema, produced by no emitter

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 turns Lint & Repo Gates red on main. The three groups, with what I measured about each, are in "The first measurement" below.

What lands

filewhat
scripts/check-i18n-walk-parity.mjs (new, 587 lines)the gate
scripts/fixtures/i18n-walk-parity/every-group.stack.json (new)a stack config authoring one member of every group the walker can reach
package.jsoncheck:i18n-walk-parity, beside check:i18n / check:i18n-coverage
.github/workflows/lint.ymlone 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 | 587 +++++++++++++++
scripts/check-ratchet-remedy-authority.mjs | 14 +
scripts/fixtures/i18n-walk-parity/every-group.stack.json | 163 ++++++
5 files changed, 786 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 builtTranslationDataSchema'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 emptywarnedGroups 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. ⛔ 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 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 EMPTY (KNOWN_NO_EXTRACTOR_FACE = Object.freeze({}), LEDGER_CEILING = 0), in the KNOWN_IMPORT_UNSAFE shape from scripts/check-entry-guard.mjs:

  • 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.
  • shrink-only, both directions. 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 the number can only be walked down.
  • the ledger path is ⛔ MAINTAINER-ONLY in 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:

 declared (11) — top-level keys of TranslationDataSchema
apps walked objects walked
dashboards walked pages walked
datasets walked settings UNWALKED
flows walked settingsCommon UNWALKED
globalActions walked
messages UNWALKED
metadataForms walked
walked (8) — path[0] over scripts/fixtures/i18n-walk-parity/every-group.stack.json
apps, dashboards, datasets, flows, globalActions, metadataForms, objects, pages
ledger (0/0)
(empty — as it shipped)

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

Reported, not repaired, and not ledgered. What I measured about each, so whoever rules on it is not starting from zero:

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.

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").

Reading, offered rather than taken: settings wants an emitter (registry-driven, the metadataForms precedent); messages and settingsCommon want ledger entries with those reasons. Both acts are outside this card's scope — the first is packages/cli, the second is ⛔ MAINTAINER-ONLY by 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-testnode scripts/check-i18n-walk-parity.mjs --self-test, exit 0:

✓ check-i18n-walk-parity self-test: 21 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, and the recorded sample of today's real group names reproduces its verdict.

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-object moved), rebuilt where the gate reads built output, proved to have reached dist/ with scripts/ablation-dist-preflight.mjs, and restored to a byte-identical tree (git hash-object back to the HEAD blob, whole-tree git status --porcelain empty, marker absent from the rebuilt dist/).

(a) delete a group's emitter → red naming that group. Removed the walkDatasets(config, out); call from collectExpectedEntries, rebuilt @objectstack/cli.

  • mutate leg: ✓ dist/: marker present in 1 built file; ✓ dist/: marker absent from all 468 built files for 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.

    ⚠️ Recorded because it changes how the first run should be read: the first attempt used walkDatasets(config, out) (no semicolon) as the absent-marker, which also matches the function declaration in dist, 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 TranslationDataSchema with no walker change → red naming it. Injected ablationSyntheticGroup into translationDataShape(), rebuilt @objectstack/spec (whole-subtree restore, because the spec build regenerates committed artifacts).

  • mutate leg: ✓ dist/: marker present in 18 built files.
  • gate: exit 1, 4 declared translation group(s) that the extractor does not walk:ablationSyntheticGroup, messages, settings, settingsCommon.
  • restore leg: hash back to 738d284dc…, whole-tree git status --porcelain empty, ✓ 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 } with LEDGER_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 at 856fac666.

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-test has 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-test leg 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, one package.json script 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 no dist/. ⛔ This is NOT a pass"
  • check:type-check-debt"--re-measure cannot 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 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). Both were re-run green afterwards.

Not in scope

No changeset — scripts/** + root manifest + workflow publish nothing from any package, so skip-changeset. content/docs/releases/** untouched. lint.yml carries exactly one added step, adjacent to check:i18n-coverage, disjoint from the region draft PR #14277 holds.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

claudeBotand 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant

@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

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

Draft
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate
Draft

tooling(i18n): gate that every declared translation group is walked by the extractor#14763
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14653

⚠️ READ FIRST — this gate is RED on main, by construction, and that red is the deliverable's first measurement

pnpm check:i18n-walk-parity exits 1 on this branch and on origin/main, naming three declared translation groups that collectExpectedEntries produces no key for:

❌ check:i18n-walk-parity — 3 declared translation group(s) that the extractor does not walk:
messages — declared in TranslationDataSchema, produced by no emitter
settings — declared in TranslationDataSchema, produced by no emitter
settingsCommon — declared in TranslationDataSchema, produced by no emitter

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 turns Lint & Repo Gates red on main. The three groups, with what I measured about each, are in "The first measurement" below.

What lands

filewhat
scripts/check-i18n-walk-parity.mjs (new, 587 lines)the gate
scripts/fixtures/i18n-walk-parity/every-group.stack.json (new)a stack config authoring one member of every group the walker can reach
package.jsoncheck:i18n-walk-parity, beside check:i18n / check:i18n-coverage
.github/workflows/lint.ymlone 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 | 587 +++++++++++++++
scripts/check-ratchet-remedy-authority.mjs | 14 +
scripts/fixtures/i18n-walk-parity/every-group.stack.json | 163 ++++++
5 files changed, 786 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 builtTranslationDataSchema'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 emptywarnedGroups 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. ⛔ 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 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 EMPTY (KNOWN_NO_EXTRACTOR_FACE = Object.freeze({}), LEDGER_CEILING = 0), in the KNOWN_IMPORT_UNSAFE shape from scripts/check-entry-guard.mjs:

  • 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.
  • shrink-only, both directions. 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 the number can only be walked down.
  • the ledger path is ⛔ MAINTAINER-ONLY in 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:

 declared (11) — top-level keys of TranslationDataSchema
apps walked objects walked
dashboards walked pages walked
datasets walked settings UNWALKED
flows walked settingsCommon UNWALKED
globalActions walked
messages UNWALKED
metadataForms walked
walked (8) — path[0] over scripts/fixtures/i18n-walk-parity/every-group.stack.json
apps, dashboards, datasets, flows, globalActions, metadataForms, objects, pages
ledger (0/0)
(empty — as it shipped)

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

Reported, not repaired, and not ledgered. What I measured about each, so whoever rules on it is not starting from zero:

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.

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").

Reading, offered rather than taken: settings wants an emitter (registry-driven, the metadataForms precedent); messages and settingsCommon want ledger entries with those reasons. Both acts are outside this card's scope — the first is packages/cli, the second is ⛔ MAINTAINER-ONLY by 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-testnode scripts/check-i18n-walk-parity.mjs --self-test, exit 0:

✓ check-i18n-walk-parity self-test: 21 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, and the recorded sample of today's real group names reproduces its verdict.

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-object moved), rebuilt where the gate reads built output, proved to have reached dist/ with scripts/ablation-dist-preflight.mjs, and restored to a byte-identical tree (git hash-object back to the HEAD blob, whole-tree git status --porcelain empty, marker absent from the rebuilt dist/).

(a) delete a group's emitter → red naming that group. Removed the walkDatasets(config, out); call from collectExpectedEntries, rebuilt @objectstack/cli.

  • mutate leg: ✓ dist/: marker present in 1 built file; ✓ dist/: marker absent from all 468 built files for 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.

    ⚠️ Recorded because it changes how the first run should be read: the first attempt used walkDatasets(config, out) (no semicolon) as the absent-marker, which also matches the function declaration in dist, 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 TranslationDataSchema with no walker change → red naming it. Injected ablationSyntheticGroup into translationDataShape(), rebuilt @objectstack/spec (whole-subtree restore, because the spec build regenerates committed artifacts).

  • mutate leg: ✓ dist/: marker present in 18 built files.
  • gate: exit 1, 4 declared translation group(s) that the extractor does not walk:ablationSyntheticGroup, messages, settings, settingsCommon.
  • restore leg: hash back to 738d284dc…, whole-tree git status --porcelain empty, ✓ 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 } with LEDGER_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 at 856fac666.

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-test has 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-test leg 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, one package.json script 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 no dist/. ⛔ This is NOT a pass"
  • check:type-check-debt"--re-measure cannot 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 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). Both were re-run green afterwards.

Not in scope

No changeset — scripts/** + root manifest + workflow publish nothing from any package, so skip-changeset. content/docs/releases/** untouched. lint.yml carries exactly one added step, adjacent to check:i18n-coverage, disjoint from the region draft PR #14277 holds.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

claudeBotand 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant

@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Draft
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate
Draft

tooling(i18n): gate that every declared translation group is walked by the extractor#14763
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14653

⚠️ READ FIRST — this gate is RED on main, by construction, and that red is the deliverable's first measurement

pnpm check:i18n-walk-parity exits 1 on this branch and on origin/main, naming three declared translation groups that collectExpectedEntries produces no key for:

❌ check:i18n-walk-parity — 3 declared translation group(s) that the extractor does not walk:
messages — declared in TranslationDataSchema, produced by no emitter
settings — declared in TranslationDataSchema, produced by no emitter
settingsCommon — declared in TranslationDataSchema, produced by no emitter

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 turns Lint & Repo Gates red on main. The three groups, with what I measured about each, are in "The first measurement" below.

What lands

filewhat
scripts/check-i18n-walk-parity.mjs (new, 587 lines)the gate
scripts/fixtures/i18n-walk-parity/every-group.stack.json (new)a stack config authoring one member of every group the walker can reach
package.jsoncheck:i18n-walk-parity, beside check:i18n / check:i18n-coverage
.github/workflows/lint.ymlone 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 | 587 +++++++++++++++
scripts/check-ratchet-remedy-authority.mjs | 14 +
scripts/fixtures/i18n-walk-parity/every-group.stack.json | 163 ++++++
5 files changed, 786 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 builtTranslationDataSchema'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 emptywarnedGroups 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. ⛔ 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 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 EMPTY (KNOWN_NO_EXTRACTOR_FACE = Object.freeze({}), LEDGER_CEILING = 0), in the KNOWN_IMPORT_UNSAFE shape from scripts/check-entry-guard.mjs:

  • 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.
  • shrink-only, both directions. 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 the number can only be walked down.
  • the ledger path is ⛔ MAINTAINER-ONLY in 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:

 declared (11) — top-level keys of TranslationDataSchema
apps walked objects walked
dashboards walked pages walked
datasets walked settings UNWALKED
flows walked settingsCommon UNWALKED
globalActions walked
messages UNWALKED
metadataForms walked
walked (8) — path[0] over scripts/fixtures/i18n-walk-parity/every-group.stack.json
apps, dashboards, datasets, flows, globalActions, metadataForms, objects, pages
ledger (0/0)
(empty — as it shipped)

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

Reported, not repaired, and not ledgered. What I measured about each, so whoever rules on it is not starting from zero:

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.

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").

Reading, offered rather than taken: settings wants an emitter (registry-driven, the metadataForms precedent); messages and settingsCommon want ledger entries with those reasons. Both acts are outside this card's scope — the first is packages/cli, the second is ⛔ MAINTAINER-ONLY by 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-testnode scripts/check-i18n-walk-parity.mjs --self-test, exit 0:

✓ check-i18n-walk-parity self-test: 21 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, and the recorded sample of today's real group names reproduces its verdict.

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-object moved), rebuilt where the gate reads built output, proved to have reached dist/ with scripts/ablation-dist-preflight.mjs, and restored to a byte-identical tree (git hash-object back to the HEAD blob, whole-tree git status --porcelain empty, marker absent from the rebuilt dist/).

(a) delete a group's emitter → red naming that group. Removed the walkDatasets(config, out); call from collectExpectedEntries, rebuilt @objectstack/cli.

  • mutate leg: ✓ dist/: marker present in 1 built file; ✓ dist/: marker absent from all 468 built files for 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.

    ⚠️ Recorded because it changes how the first run should be read: the first attempt used walkDatasets(config, out) (no semicolon) as the absent-marker, which also matches the function declaration in dist, 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 TranslationDataSchema with no walker change → red naming it. Injected ablationSyntheticGroup into translationDataShape(), rebuilt @objectstack/spec (whole-subtree restore, because the spec build regenerates committed artifacts).

  • mutate leg: ✓ dist/: marker present in 18 built files.
  • gate: exit 1, 4 declared translation group(s) that the extractor does not walk:ablationSyntheticGroup, messages, settings, settingsCommon.
  • restore leg: hash back to 738d284dc…, whole-tree git status --porcelain empty, ✓ 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 } with LEDGER_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 at 856fac666.

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-test has 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-test leg 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, one package.json script 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 no dist/. ⛔ This is NOT a pass"
  • check:type-check-debt"--re-measure cannot 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 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). Both were re-run green afterwards.

Not in scope

No changeset — scripts/** + root manifest + workflow publish nothing from any package, so skip-changeset. content/docs/releases/** untouched. lint.yml carries exactly one added step, adjacent to check:i18n-coverage, disjoint from the region draft PR #14277 holds.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

claudeBotand 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant

@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Draft
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate
Draft

tooling(i18n): gate that every declared translation group is walked by the extractor#14763
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14653

⚠️ READ FIRST — this gate is RED on main, by construction, and that red is the deliverable's first measurement

pnpm check:i18n-walk-parity exits 1 on this branch and on origin/main, naming three declared translation groups that collectExpectedEntries produces no key for:

❌ check:i18n-walk-parity — 3 declared translation group(s) that the extractor does not walk:
messages — declared in TranslationDataSchema, produced by no emitter
settings — declared in TranslationDataSchema, produced by no emitter
settingsCommon — declared in TranslationDataSchema, produced by no emitter

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 turns Lint & Repo Gates red on main. The three groups, with what I measured about each, are in "The first measurement" below.

What lands

filewhat
scripts/check-i18n-walk-parity.mjs (new, 587 lines)the gate
scripts/fixtures/i18n-walk-parity/every-group.stack.json (new)a stack config authoring one member of every group the walker can reach
package.jsoncheck:i18n-walk-parity, beside check:i18n / check:i18n-coverage
.github/workflows/lint.ymlone 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 | 587 +++++++++++++++
scripts/check-ratchet-remedy-authority.mjs | 14 +
scripts/fixtures/i18n-walk-parity/every-group.stack.json | 163 ++++++
5 files changed, 786 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 builtTranslationDataSchema'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 emptywarnedGroups 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. ⛔ 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 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 EMPTY (KNOWN_NO_EXTRACTOR_FACE = Object.freeze({}), LEDGER_CEILING = 0), in the KNOWN_IMPORT_UNSAFE shape from scripts/check-entry-guard.mjs:

  • 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.
  • shrink-only, both directions. 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 the number can only be walked down.
  • the ledger path is ⛔ MAINTAINER-ONLY in 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:

 declared (11) — top-level keys of TranslationDataSchema
apps walked objects walked
dashboards walked pages walked
datasets walked settings UNWALKED
flows walked settingsCommon UNWALKED
globalActions walked
messages UNWALKED
metadataForms walked
walked (8) — path[0] over scripts/fixtures/i18n-walk-parity/every-group.stack.json
apps, dashboards, datasets, flows, globalActions, metadataForms, objects, pages
ledger (0/0)
(empty — as it shipped)

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

Reported, not repaired, and not ledgered. What I measured about each, so whoever rules on it is not starting from zero:

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.

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").

Reading, offered rather than taken: settings wants an emitter (registry-driven, the metadataForms precedent); messages and settingsCommon want ledger entries with those reasons. Both acts are outside this card's scope — the first is packages/cli, the second is ⛔ MAINTAINER-ONLY by 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-testnode scripts/check-i18n-walk-parity.mjs --self-test, exit 0:

✓ check-i18n-walk-parity self-test: 21 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, and the recorded sample of today's real group names reproduces its verdict.

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-object moved), rebuilt where the gate reads built output, proved to have reached dist/ with scripts/ablation-dist-preflight.mjs, and restored to a byte-identical tree (git hash-object back to the HEAD blob, whole-tree git status --porcelain empty, marker absent from the rebuilt dist/).

(a) delete a group's emitter → red naming that group. Removed the walkDatasets(config, out); call from collectExpectedEntries, rebuilt @objectstack/cli.

  • mutate leg: ✓ dist/: marker present in 1 built file; ✓ dist/: marker absent from all 468 built files for 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.

    ⚠️ Recorded because it changes how the first run should be read: the first attempt used walkDatasets(config, out) (no semicolon) as the absent-marker, which also matches the function declaration in dist, 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 TranslationDataSchema with no walker change → red naming it. Injected ablationSyntheticGroup into translationDataShape(), rebuilt @objectstack/spec (whole-subtree restore, because the spec build regenerates committed artifacts).

  • mutate leg: ✓ dist/: marker present in 18 built files.
  • gate: exit 1, 4 declared translation group(s) that the extractor does not walk:ablationSyntheticGroup, messages, settings, settingsCommon.
  • restore leg: hash back to 738d284dc…, whole-tree git status --porcelain empty, ✓ 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 } with LEDGER_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 at 856fac666.

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-test has 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-test leg 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, one package.json script 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 no dist/. ⛔ This is NOT a pass"
  • check:type-check-debt"--re-measure cannot 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 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). Both were re-run green afterwards.

Not in scope

No changeset — scripts/** + root manifest + workflow publish nothing from any package, so skip-changeset. content/docs/releases/** untouched. lint.yml carries exactly one added step, adjacent to check:i18n-coverage, disjoint from the region draft PR #14277 holds.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

claudeBotand 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant

@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

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

Draft
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate
Draft

tooling(i18n): gate that every declared translation group is walked by the extractor#14763
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14653

⚠️ READ FIRST — this gate is RED on main, by construction, and that red is the deliverable's first measurement

pnpm check:i18n-walk-parity exits 1 on this branch and on origin/main, naming three declared translation groups that collectExpectedEntries produces no key for:

❌ check:i18n-walk-parity — 3 declared translation group(s) that the extractor does not walk:
messages — declared in TranslationDataSchema, produced by no emitter
settings — declared in TranslationDataSchema, produced by no emitter
settingsCommon — declared in TranslationDataSchema, produced by no emitter

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 turns Lint & Repo Gates red on main. The three groups, with what I measured about each, are in "The first measurement" below.

What lands

filewhat
scripts/check-i18n-walk-parity.mjs (new, 587 lines)the gate
scripts/fixtures/i18n-walk-parity/every-group.stack.json (new)a stack config authoring one member of every group the walker can reach
package.jsoncheck:i18n-walk-parity, beside check:i18n / check:i18n-coverage
.github/workflows/lint.ymlone 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 | 587 +++++++++++++++
scripts/check-ratchet-remedy-authority.mjs | 14 +
scripts/fixtures/i18n-walk-parity/every-group.stack.json | 163 ++++++
5 files changed, 786 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 builtTranslationDataSchema'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 emptywarnedGroups 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. ⛔ 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 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 EMPTY (KNOWN_NO_EXTRACTOR_FACE = Object.freeze({}), LEDGER_CEILING = 0), in the KNOWN_IMPORT_UNSAFE shape from scripts/check-entry-guard.mjs:

  • 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.
  • shrink-only, both directions. 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 the number can only be walked down.
  • the ledger path is ⛔ MAINTAINER-ONLY in 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:

 declared (11) — top-level keys of TranslationDataSchema
apps walked objects walked
dashboards walked pages walked
datasets walked settings UNWALKED
flows walked settingsCommon UNWALKED
globalActions walked
messages UNWALKED
metadataForms walked
walked (8) — path[0] over scripts/fixtures/i18n-walk-parity/every-group.stack.json
apps, dashboards, datasets, flows, globalActions, metadataForms, objects, pages
ledger (0/0)
(empty — as it shipped)

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

Reported, not repaired, and not ledgered. What I measured about each, so whoever rules on it is not starting from zero:

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.

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").

Reading, offered rather than taken: settings wants an emitter (registry-driven, the metadataForms precedent); messages and settingsCommon want ledger entries with those reasons. Both acts are outside this card's scope — the first is packages/cli, the second is ⛔ MAINTAINER-ONLY by 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-testnode scripts/check-i18n-walk-parity.mjs --self-test, exit 0:

✓ check-i18n-walk-parity self-test: 21 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, and the recorded sample of today's real group names reproduces its verdict.

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-object moved), rebuilt where the gate reads built output, proved to have reached dist/ with scripts/ablation-dist-preflight.mjs, and restored to a byte-identical tree (git hash-object back to the HEAD blob, whole-tree git status --porcelain empty, marker absent from the rebuilt dist/).

(a) delete a group's emitter → red naming that group. Removed the walkDatasets(config, out); call from collectExpectedEntries, rebuilt @objectstack/cli.

  • mutate leg: ✓ dist/: marker present in 1 built file; ✓ dist/: marker absent from all 468 built files for 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.

    ⚠️ Recorded because it changes how the first run should be read: the first attempt used walkDatasets(config, out) (no semicolon) as the absent-marker, which also matches the function declaration in dist, 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 TranslationDataSchema with no walker change → red naming it. Injected ablationSyntheticGroup into translationDataShape(), rebuilt @objectstack/spec (whole-subtree restore, because the spec build regenerates committed artifacts).

  • mutate leg: ✓ dist/: marker present in 18 built files.
  • gate: exit 1, 4 declared translation group(s) that the extractor does not walk:ablationSyntheticGroup, messages, settings, settingsCommon.
  • restore leg: hash back to 738d284dc…, whole-tree git status --porcelain empty, ✓ 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 } with LEDGER_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 at 856fac666.

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-test has 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-test leg 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, one package.json script 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 no dist/. ⛔ This is NOT a pass"
  • check:type-check-debt"--re-measure cannot 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 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). Both were re-run green afterwards.

Not in scope

No changeset — scripts/** + root manifest + workflow publish nothing from any package, so skip-changeset. content/docs/releases/** untouched. lint.yml carries exactly one added step, adjacent to check:i18n-coverage, disjoint from the region draft PR #14277 holds.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

claudeBotand 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant

@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Draft
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate
Draft

tooling(i18n): gate that every declared translation group is walked by the extractor#14763
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14653

⚠️ READ FIRST — this gate is RED on main, by construction, and that red is the deliverable's first measurement

pnpm check:i18n-walk-parity exits 1 on this branch and on origin/main, naming three declared translation groups that collectExpectedEntries produces no key for:

❌ check:i18n-walk-parity — 3 declared translation group(s) that the extractor does not walk:
messages — declared in TranslationDataSchema, produced by no emitter
settings — declared in TranslationDataSchema, produced by no emitter
settingsCommon — declared in TranslationDataSchema, produced by no emitter

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 turns Lint & Repo Gates red on main. The three groups, with what I measured about each, are in "The first measurement" below.

What lands

filewhat
scripts/check-i18n-walk-parity.mjs (new, 587 lines)the gate
scripts/fixtures/i18n-walk-parity/every-group.stack.json (new)a stack config authoring one member of every group the walker can reach
package.jsoncheck:i18n-walk-parity, beside check:i18n / check:i18n-coverage
.github/workflows/lint.ymlone 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 | 587 +++++++++++++++
scripts/check-ratchet-remedy-authority.mjs | 14 +
scripts/fixtures/i18n-walk-parity/every-group.stack.json | 163 ++++++
5 files changed, 786 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 builtTranslationDataSchema'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 emptywarnedGroups 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. ⛔ 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 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 EMPTY (KNOWN_NO_EXTRACTOR_FACE = Object.freeze({}), LEDGER_CEILING = 0), in the KNOWN_IMPORT_UNSAFE shape from scripts/check-entry-guard.mjs:

  • 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.
  • shrink-only, both directions. 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 the number can only be walked down.
  • the ledger path is ⛔ MAINTAINER-ONLY in 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:

 declared (11) — top-level keys of TranslationDataSchema
apps walked objects walked
dashboards walked pages walked
datasets walked settings UNWALKED
flows walked settingsCommon UNWALKED
globalActions walked
messages UNWALKED
metadataForms walked
walked (8) — path[0] over scripts/fixtures/i18n-walk-parity/every-group.stack.json
apps, dashboards, datasets, flows, globalActions, metadataForms, objects, pages
ledger (0/0)
(empty — as it shipped)

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

Reported, not repaired, and not ledgered. What I measured about each, so whoever rules on it is not starting from zero:

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.

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").

Reading, offered rather than taken: settings wants an emitter (registry-driven, the metadataForms precedent); messages and settingsCommon want ledger entries with those reasons. Both acts are outside this card's scope — the first is packages/cli, the second is ⛔ MAINTAINER-ONLY by 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-testnode scripts/check-i18n-walk-parity.mjs --self-test, exit 0:

✓ check-i18n-walk-parity self-test: 21 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, and the recorded sample of today's real group names reproduces its verdict.

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-object moved), rebuilt where the gate reads built output, proved to have reached dist/ with scripts/ablation-dist-preflight.mjs, and restored to a byte-identical tree (git hash-object back to the HEAD blob, whole-tree git status --porcelain empty, marker absent from the rebuilt dist/).

(a) delete a group's emitter → red naming that group. Removed the walkDatasets(config, out); call from collectExpectedEntries, rebuilt @objectstack/cli.

  • mutate leg: ✓ dist/: marker present in 1 built file; ✓ dist/: marker absent from all 468 built files for 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.

    ⚠️ Recorded because it changes how the first run should be read: the first attempt used walkDatasets(config, out) (no semicolon) as the absent-marker, which also matches the function declaration in dist, 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 TranslationDataSchema with no walker change → red naming it. Injected ablationSyntheticGroup into translationDataShape(), rebuilt @objectstack/spec (whole-subtree restore, because the spec build regenerates committed artifacts).

  • mutate leg: ✓ dist/: marker present in 18 built files.
  • gate: exit 1, 4 declared translation group(s) that the extractor does not walk:ablationSyntheticGroup, messages, settings, settingsCommon.
  • restore leg: hash back to 738d284dc…, whole-tree git status --porcelain empty, ✓ 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 } with LEDGER_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 at 856fac666.

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-test has 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-test leg 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, one package.json script 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 no dist/. ⛔ This is NOT a pass"
  • check:type-check-debt"--re-measure cannot 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 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). Both were re-run green afterwards.

Not in scope

No changeset — scripts/** + root manifest + workflow publish nothing from any package, so skip-changeset. content/docs/releases/** untouched. lint.yml carries exactly one added step, adjacent to check:i18n-coverage, disjoint from the region draft PR #14277 holds.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

claudeBotand 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant

@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Draft
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate
Draft

tooling(i18n): gate that every declared translation group is walked by the extractor#14763
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14653

⚠️ READ FIRST — this gate is RED on main, by construction, and that red is the deliverable's first measurement

pnpm check:i18n-walk-parity exits 1 on this branch and on origin/main, naming three declared translation groups that collectExpectedEntries produces no key for:

❌ check:i18n-walk-parity — 3 declared translation group(s) that the extractor does not walk:
messages — declared in TranslationDataSchema, produced by no emitter
settings — declared in TranslationDataSchema, produced by no emitter
settingsCommon — declared in TranslationDataSchema, produced by no emitter

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 turns Lint & Repo Gates red on main. The three groups, with what I measured about each, are in "The first measurement" below.

What lands

filewhat
scripts/check-i18n-walk-parity.mjs (new, 587 lines)the gate
scripts/fixtures/i18n-walk-parity/every-group.stack.json (new)a stack config authoring one member of every group the walker can reach
package.jsoncheck:i18n-walk-parity, beside check:i18n / check:i18n-coverage
.github/workflows/lint.ymlone 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 | 587 +++++++++++++++
scripts/check-ratchet-remedy-authority.mjs | 14 +
scripts/fixtures/i18n-walk-parity/every-group.stack.json | 163 ++++++
5 files changed, 786 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 builtTranslationDataSchema'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 emptywarnedGroups 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. ⛔ 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 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 EMPTY (KNOWN_NO_EXTRACTOR_FACE = Object.freeze({}), LEDGER_CEILING = 0), in the KNOWN_IMPORT_UNSAFE shape from scripts/check-entry-guard.mjs:

  • 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.
  • shrink-only, both directions. 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 the number can only be walked down.
  • the ledger path is ⛔ MAINTAINER-ONLY in 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:

 declared (11) — top-level keys of TranslationDataSchema
apps walked objects walked
dashboards walked pages walked
datasets walked settings UNWALKED
flows walked settingsCommon UNWALKED
globalActions walked
messages UNWALKED
metadataForms walked
walked (8) — path[0] over scripts/fixtures/i18n-walk-parity/every-group.stack.json
apps, dashboards, datasets, flows, globalActions, metadataForms, objects, pages
ledger (0/0)
(empty — as it shipped)

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

Reported, not repaired, and not ledgered. What I measured about each, so whoever rules on it is not starting from zero:

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.

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").

Reading, offered rather than taken: settings wants an emitter (registry-driven, the metadataForms precedent); messages and settingsCommon want ledger entries with those reasons. Both acts are outside this card's scope — the first is packages/cli, the second is ⛔ MAINTAINER-ONLY by 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-testnode scripts/check-i18n-walk-parity.mjs --self-test, exit 0:

✓ check-i18n-walk-parity self-test: 21 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, and the recorded sample of today's real group names reproduces its verdict.

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-object moved), rebuilt where the gate reads built output, proved to have reached dist/ with scripts/ablation-dist-preflight.mjs, and restored to a byte-identical tree (git hash-object back to the HEAD blob, whole-tree git status --porcelain empty, marker absent from the rebuilt dist/).

(a) delete a group's emitter → red naming that group. Removed the walkDatasets(config, out); call from collectExpectedEntries, rebuilt @objectstack/cli.

  • mutate leg: ✓ dist/: marker present in 1 built file; ✓ dist/: marker absent from all 468 built files for 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.

    ⚠️ Recorded because it changes how the first run should be read: the first attempt used walkDatasets(config, out) (no semicolon) as the absent-marker, which also matches the function declaration in dist, 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 TranslationDataSchema with no walker change → red naming it. Injected ablationSyntheticGroup into translationDataShape(), rebuilt @objectstack/spec (whole-subtree restore, because the spec build regenerates committed artifacts).

  • mutate leg: ✓ dist/: marker present in 18 built files.
  • gate: exit 1, 4 declared translation group(s) that the extractor does not walk:ablationSyntheticGroup, messages, settings, settingsCommon.
  • restore leg: hash back to 738d284dc…, whole-tree git status --porcelain empty, ✓ 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 } with LEDGER_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 at 856fac666.

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-test has 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-test leg 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, one package.json script 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 no dist/. ⛔ This is NOT a pass"
  • check:type-check-debt"--re-measure cannot 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 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). Both were re-run green afterwards.

Not in scope

No changeset — scripts/** + root manifest + workflow publish nothing from any package, so skip-changeset. content/docs/releases/** untouched. lint.yml carries exactly one added step, adjacent to check:i18n-coverage, disjoint from the region draft PR #14277 holds.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

claudeBotand 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant

@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

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

Draft
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate
Draft

tooling(i18n): gate that every declared translation group is walked by the extractor#14763
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-14653-i18n-walk-parity-gate

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14653

⚠️ READ FIRST — this gate is RED on main, by construction, and that red is the deliverable's first measurement

pnpm check:i18n-walk-parity exits 1 on this branch and on origin/main, naming three declared translation groups that collectExpectedEntries produces no key for:

❌ check:i18n-walk-parity — 3 declared translation group(s) that the extractor does not walk:
messages — declared in TranslationDataSchema, produced by no emitter
settings — declared in TranslationDataSchema, produced by no emitter
settingsCommon — declared in TranslationDataSchema, produced by no emitter

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 turns Lint & Repo Gates red on main. The three groups, with what I measured about each, are in "The first measurement" below.

What lands

filewhat
scripts/check-i18n-walk-parity.mjs (new, 587 lines)the gate
scripts/fixtures/i18n-walk-parity/every-group.stack.json (new)a stack config authoring one member of every group the walker can reach
package.jsoncheck:i18n-walk-parity, beside check:i18n / check:i18n-coverage
.github/workflows/lint.ymlone 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 | 587 +++++++++++++++
scripts/check-ratchet-remedy-authority.mjs | 14 +
scripts/fixtures/i18n-walk-parity/every-group.stack.json | 163 ++++++
5 files changed, 786 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 builtTranslationDataSchema'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 emptywarnedGroups 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. ⛔ 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 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 EMPTY (KNOWN_NO_EXTRACTOR_FACE = Object.freeze({}), LEDGER_CEILING = 0), in the KNOWN_IMPORT_UNSAFE shape from scripts/check-entry-guard.mjs:

  • 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.
  • shrink-only, both directions. 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 the number can only be walked down.
  • the ledger path is ⛔ MAINTAINER-ONLY in 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:

 declared (11) — top-level keys of TranslationDataSchema
apps walked objects walked
dashboards walked pages walked
datasets walked settings UNWALKED
flows walked settingsCommon UNWALKED
globalActions walked
messages UNWALKED
metadataForms walked
walked (8) — path[0] over scripts/fixtures/i18n-walk-parity/every-group.stack.json
apps, dashboards, datasets, flows, globalActions, metadataForms, objects, pages
ledger (0/0)
(empty — as it shipped)

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

Reported, not repaired, and not ledgered. What I measured about each, so whoever rules on it is not starting from zero:

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.

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").

Reading, offered rather than taken: settings wants an emitter (registry-driven, the metadataForms precedent); messages and settingsCommon want ledger entries with those reasons. Both acts are outside this card's scope — the first is packages/cli, the second is ⛔ MAINTAINER-ONLY by 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-testnode scripts/check-i18n-walk-parity.mjs --self-test, exit 0:

✓ check-i18n-walk-parity self-test: 21 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, and the recorded sample of today's real group names reproduces its verdict.

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-object moved), rebuilt where the gate reads built output, proved to have reached dist/ with scripts/ablation-dist-preflight.mjs, and restored to a byte-identical tree (git hash-object back to the HEAD blob, whole-tree git status --porcelain empty, marker absent from the rebuilt dist/).

(a) delete a group's emitter → red naming that group. Removed the walkDatasets(config, out); call from collectExpectedEntries, rebuilt @objectstack/cli.

  • mutate leg: ✓ dist/: marker present in 1 built file; ✓ dist/: marker absent from all 468 built files for 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.

    ⚠️ Recorded because it changes how the first run should be read: the first attempt used walkDatasets(config, out) (no semicolon) as the absent-marker, which also matches the function declaration in dist, 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 TranslationDataSchema with no walker change → red naming it. Injected ablationSyntheticGroup into translationDataShape(), rebuilt @objectstack/spec (whole-subtree restore, because the spec build regenerates committed artifacts).

  • mutate leg: ✓ dist/: marker present in 18 built files.
  • gate: exit 1, 4 declared translation group(s) that the extractor does not walk:ablationSyntheticGroup, messages, settings, settingsCommon.
  • restore leg: hash back to 738d284dc…, whole-tree git status --porcelain empty, ✓ 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 } with LEDGER_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 at 856fac666.

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-test has 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-test leg 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, one package.json script 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 no dist/. ⛔ This is NOT a pass"
  • check:type-check-debt"--re-measure cannot 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 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). Both were re-run green afterwards.

Not in scope

No changeset — scripts/** + root manifest + workflow publish nothing from any package, so skip-changeset. content/docs/releases/** untouched. lint.yml carries exactly one added step, adjacent to check:i18n-coverage, disjoint from the region draft PR #14277 holds.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

claudeBotand 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant

@claude