You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
finding(i18n): template-literal t() key families are only PREFIX-checked, so a member missing from all ten packs is invisible to every gate — ~20 families exposed #4964
Recorded while fixing objectstack-ai/objectstack#8815 (PR #4962). That card is the proof this class is live; this issue is about the class, not that card.
The hole
When a call site builds its key from a template literal, no gate in the repo can tell which members it needs:
{t(`filterBuilder.operators.${op.value}`)}
scripts/check-i18n-call-site-keys.mjs classifies these as missing-prefix and asks only whether the prefix resolves. Its own header is explicit that this is deliberate — it is "about a dynamic key that is true without knowing the value." filterBuilder.operators resolved sixteen members deep, so it passed.
packages/i18n/src/__tests__/all-locales-key-parity.test.ts asks whether the ten packs agree with each other. Its header already records the consequence in as many words: "Ten packs identically missing it is full parity, so this file is green on it by construction."
check:i18n-drift only fires when an existing envalue changes. A key that was never added never changes.
So a member missing from all ten packs at once is invisible to the whole gate farm, and the user is shown the raw key — i18next resolves a missing key to the key itself.
Note this is not covered by the createSafeTranslation defaults table: that table serves only the no-provider path, and the Console mounts a provider, so the pack's answer is the one that renders.
Measured instance (already fixed, do not re-fix here)
filterBuilder.operators.* was missing startsWith, endsWith, isNull, isNotNull, exists, notExists from all ten packs. Users saw filterBuilder.operators.isNull in the list-filter operator dropdown. Fixed in PR #4962, which also adds a parity test pinning the packs against FILTER_BUILDER_OPERATORS in both directions — but that guard is specific to that one family. Nothing generalises it.
Exposed families
Every other dynamic key family has the same structural exposure. Enumerated with:
⚠️I did not measure these. I confirmed the mechanism and enumerated the call sites; whether any given family actually has missing members is exactly what nobody currently knows, which is the point. Triage should decide whether this is worth a sweep. engine.* is partly accounted for by the closed #4662 (module-local two-locale table) and may need different treatment.
Shape of a general fix, if wanted
The generalisable move is what PR #4962 did for one family: assert the pack contents against the vocabulary export the call site iterates, since a dynamic key is nearly always built from a known finite set in code. Options worth pricing at triage:
Teach check-i18n-call-site-keys.mjs to resolve a template key whose interpolated expression is a statically-known union or a readonly array of literals, and check each member. Wider reach, real parser work.
A ratchet listing the known dynamic families and requiring each to name its vocabulary source, so a new one cannot land unguarded.
No pm:queue — filing as an observation for triage to grade, not a claim that a sweep is warranted.
Triage first-touch — concentrated round #2 (triage seat, session session_019ZyCHcjXeTFPiS8RWRk4nN, 2026-08-19): promoted finding → pm:queue · Task · tooling, as the measurement sweep the card asks triage to price. The class is proven live (users saw raw filterBuilder.operators.* keys), the mechanism is confirmed, and the exposed set is enumerated — what is unknown is exactly which families have missing members, and that unknown is user-visible when wrong. Dispatch scope = the card's option 1 executed as a sweep: for each of the ~20 enumerated families, measure membership against the vocabulary its call site iterates; fix missing members in all ten packs; add a per-family parity test wherever a vocabulary export exists; report (not fix) families with no static vocabulary source. Options 2 (parser work in check-i18n-call-site-keys.mjs) and 3 (dynamic-family ratchet) are follow-up candidates priced from the sweep's findings — file, don't fold in. engine.* families: check against closed #4662 before re-fixing. Size/model suggestion: M, opus.
Claim: PM loop round 9
Session: session_019b5UBNMtTzKbVtZZGvFuxe · Branch: claude/issue-4964-i18n-template-literal-key-families · Worktree: objectui-issue-4964
Domain: domain:devx (@ objectui, seat #5748) · Container & model: M, mode:subagent, model: opus · Clause-②: no
File surface: the i18n key gate + whatever pack entries the widened check exposes
Serial constraints cleared: in-flight 0. Disjoint from open PRs #5988 / #5989 / #5991 / #5995 and co-dispatched #5754 (new workflow), #4938 (check-doc-links.mjs), #4991 (packages/cli test), #4996 (shadcn). ⚠️#4905 is NOT dispatched and must not be touched — it covers the placeholder-spelling gate on inline t(key, { defaultValue }) strings, an adjacent but different axis of the same i18n surface. Two cards editing the i18n gate family at once is the collision this note exists to prevent.
The card
Template-literal t() key families are only PREFIX-checked, so a member missing from all ten packs is invisible to every gate. ~20 families exposed.
Zone 2 — measure, and expect the count to move
⛔ "~20 families" is the card's estimate, not the spec. Enumerate them yourself and report the real number and the real missing-member count. If it differs, use yours.
⚠️A prefix check cannot be replaced by an exact check without deciding what the members are. A template literal's key set is only knowable if the interpolated values are enumerable (a union, a const array, an enum). Where they are not enumerable, say so — those families cannot be exactly checked, and pretending otherwise produces either false positives or a check that quietly skips them. State which families fall on each side; that split is the finding.
The failure mode to avoid is the one this lane keeps hitting: a stricter-looking gate that silently covers less. If your change makes any currently-checked family stop being checked, that is a regression regardless of what it adds. Pin coverage in both directions.
Rulings (⛔ not re-adjudicable)
⛔ Do not weaken any existing check to accommodate the new one — maintainer-floor; stop and report.
⛔ Do not add missing keys to the packs to make the gate pass. A missing translation is a real gap; the gate exists to surface it. If the widened check finds genuinely missing members, report the list — filling ten packs with invented strings is out of scope and worse than the gap.
⛔ Scope: this one prefix-vs-exact gap. Other i18n debt is a finding to file.
Gates
Derive from your diff. At minimum the i18n gate over the whole repo (before/after counts), its tests, pnpm type-check:scripts if you touch scripts/, Lint. Run check-changeset-presence.mjs, follow its verdict; ⛔ no skip-changeset label (#4912/#3724).
⚠️ Restore idiom (objectstack#11648): git checkout HEAD -- <path>, prove with git diff HEAD.
Draft PR, Fixes #4964, report as an issue comment with the <!-- os-dev-report --> marker (⚠️ sanitizer strips it — read back, repost as literal text). Report the family count, the exactly-checkable vs not-enumerable split, and any genuinely missing members found.
{
"issue": 4964,
"status": "done",
"branch": "claude/issue-4964-i18n-template-literal-key-families",
"pr": "https://github.com/objectstack-ai/objectui/pull/6036",
"premise_still_valid": true,
"summary": "Taught scripts/check-i18n-call-site-keys.mjs a sixth failure class, missing-member: a template family whose HEAD resolves is now checked member by member against a declared vocabulary. Members are DECLARED, not inferred — the file parses source with no type checker, so DYNAMIC_KEY_FAMILIES names, per family, either a readable declaration (7 shapes: union/array/arrayField/objectKeys/objectField/set/interfaceField) or `enumerable: false` with one of four `why` reasons. Measured 25 pack-backed families (card estimated ~20), 18 exactly checkable covering 112 member keys, 7 not enumerable, plus 35 dynamic sites with no static head at all that neither rule can reach. First run found exactly 2 genuinely missing members; per this card's ruling they are reported and baselined against a new issue, not invented into ten packs. Coverage grows in one direction only: missing-prefix is untouched and still runs for every dynamic head. NOTE a conflict for PM: the 2026-08-19 triage comment scoped this as 'fix missing members in all ten packs'; the round-9 dispatch Zone-1 ruling forbids exactly that. The later ruling was followed.",
"tests": "All gates run on final commit 1f0686b5e (branch head), each quoting the gate's own verdict line, exit codes captured before any pipe. check:i18n-keys exit 0 — 'Every in-scope call-site key resolves against the en pack (2936 keys) ... and every dynamic key family either checks its members against a declared vocabulary or says in writing why it has none.' | census line: 'Dynamic key families: 25 declared — 18 with a static vocabulary (112 member key(s) checked exactly), 7 with no enumerable member set (prefix-checked only), 0 multi-substitution site(s) not expandable, 35 dynamic call site(s) with no static head at all.' | check:i18n-dead-keys (consumer of analyze()) exit 0 | check-changeset-presence.mjs exit 0 — '✅ No source of a released package changed in this range, so no changeset is owed.' | type-check:scripts exit 0 | lint:root exit 0 — '✖ 26 problems (0 errors, 26 warnings)', all pre-existing, grep of the changed filenames in the lint output returns 0 | vitest scripts/__tests__/check-i18n-call-site-keys.test.ts + packages/i18n/src/__tests__/all-locales-key-parity.test.ts — 'Test Files 2 passed (2)' / 'Tests 127 passed (127)'. objectui has no check-nul-bytes.mjs (that gate lives in objectstack); direct control-byte scan over the 3 changed files returns no matches. BEFORE/AFTER on the i18n gate over the whole repo: before, 67 dynamic-key sites all report-only, 0 families declared, 0 members checked; after, same 67 sites, 25 families declared, 112 member keys checked exactly, gate still exit 0. ABLATION (reverse verification), direction predicted BEFORE running = turn red on a family with no pre-existing debt: deleted the `home` line for `gantt.viewMode.day` from packages/i18n/src/locales/en.ts. No rebuild needed — the gate parses source, it does not import built dist. Mutation confirmed ON DISK by grep -c of the exact anchor text, not by the editor's exit code: anchor count 1 before, 0 after, `git diff HEAD --numstat` = '0\t1\tpackages/i18n/src/locales/en.ts'. Mutated run: MUTATED_EXIT=1, exactly one new finding 'packages/plugin-gantt/src/GanttView.tsx:3218:18 [missing-member] gantt.viewMode.day', and — the load-bearing half — ZERO missing-prefix findings, because the head still resolved through its four surviving members. That is the gap reproduced on demand. Restore leg via a `trap ... EXIT INT TERM` running `git checkout HEAD -- <path>` (not bare `git checkout --`, per objectstack#11648): restore proven with `git diff HEAD --stat` empty and the anchor count back to 1. FIRST ABLATION ATTEMPT WAS A NO-OP and is reported rather than silently retried: the perl anchor had the wrong indentation, matched zero lines, perl exited 0, and the gate run read as a clean 'no finding' — the anchor-count disk proof is the only thing that caught it. Non-vacuity is asserted at three levels rather than assumed: every green synthetic test asserts counters.checkedMembers (not just an empty finding list), a repo-level test asserts every checked-in vocabulary resolves NON-EMPTY on this checkout, and a positive control asserts the checker finds a known-PRESENT key (gantt.viewMode.day) before any 'not found' is trusted. Coverage pinned in both directions by a test asserting that a DECLARED family whose head matches nothing still produces missing-prefix AND the member findings — declaring a family never buys it out of the prefix rule.",
"family_count": 25,
"family_count_card_estimate": 20,
"exactly_checkable_families": 18,
"not_enumerable_families": 7,
"member_keys_checked_exactly": 112,
"headless_dynamic_sites_no_static_head": 35,
"exactly_checkable": [
"appDesigner.fieldDesigner.typeCategory.",
"capability.group.",
"capability.label.",
"console.ai.group.",
"console.identityImport.policy.",
"console.identityImport.policyHint.",
"dashboard.trend.",
"filterBuilder.operators.",
"gantt.link.rejected.",
"gantt.linkType.",
"gantt.viewMode.",
"grid.import.confidence.",
"grid.import.jobStatus.",
"grid.import.type.",
"home.recentApps.itemType.",
"managedByBadge.",
"marketplace.disclosure.runtime.",
"organization.invitations.status."
],
"not_enumerable": {
"runtime-data": [
"console.settingsHub.categories.",
"marketplace.category."
],
"external-vocabulary": [
"dashboard.filters.range.",
"report.aggregate."
],
"unnamed-union": [
"common.",
"gantt.linkEnd."
],
"open-forwarder": [
"approvalsInbox."
]
},
"genuinely_missing_members": [
"home.recentApps.itemType.report",
"home.recentApps.itemType.metadata"
],
"open_questions": [],
"out_of_scope_findings": [
"filed as #6023: home.recentApps.itemType.report and .metadata are missing from all ten packs — both written at runtime by useTrackRouteAsRecent.ts (:148, :105), all three consumers mask them with a defaultValue so English readers see a plausible label and the other nine locales see English; baselined in missingMembers, NOT fixed here per this card's ruling"
]
}
Notes for PM, outside the machine-checkable fields:
The 4-way why split is the finding. Only runtime-data (2 families) is permanent. external-vocabulary (2) is bridgeable by mirroring a spec vocabulary in a repo-local exhaustive Record<Union, …>; unnamed-union (2) is bridgeable by naming a union that is already real and closed. open-forwarder (1) is approvalsInbox., where tr(key: string, …) makes the "family" a 169-key namespace — the template is a namespace prefix, not a member position.
enumerable: false was chosen over a guessed vocabulary in one tempting case.dashboard.filters.range. has a FilterBuilderDateRangePreset union in packages/types/src/data-protocol.ts that looks like it fits; it is the filter builder's vocabulary, not the dashboard bar's (which iterates DATE_RANGE_PRESETS from @objectstack/spec/ui). en carries 13 members there against a spec union with ~19, so there is a reasonable suspicion of more missing members — unprovable from repo source, and stated as suspicion rather than filed as fact.
The card's option 3 (dynamic-family ratchet) is implemented here, not deferred: an undeclared family fails, a stale entry fails. Option 2 (parser-side union resolution with no registry) is what the 4 bridgeable enumerable: false entries price.
(Marker repost. The comment above, id 5396258220, is the same report but the sanitizer ate its HTML-comment marker on write — read back and confirmed stripped, so the first line is spelled as literal text here. This is the one to scan; the prose notes for PM are in that earlier comment and are not repeated.)
{
"issue": 4964,
"status": "done",
"branch": "claude/issue-4964-i18n-template-literal-key-families",
"pr": "https://github.com/objectstack-ai/objectui/pull/6036",
"premise_still_valid": true,
"summary": "Taught scripts/check-i18n-call-site-keys.mjs a sixth failure class, missing-member: a template family whose HEAD resolves is now checked member by member against a declared vocabulary. Members are DECLARED, not inferred — the file parses source with no type checker, so DYNAMIC_KEY_FAMILIES names, per family, either a readable declaration (7 shapes: union/array/arrayField/objectKeys/objectField/set/interfaceField) or `enumerable: false` with one of four `why` reasons. Measured 25 pack-backed families (card estimated ~20), 18 exactly checkable covering 112 member keys, 7 not enumerable, plus 35 dynamic sites with no static head at all that neither rule can reach. First run found exactly 2 genuinely missing members; per this card's ruling they are reported and baselined against a new issue, not invented into ten packs. Coverage grows in one direction only: missing-prefix is untouched and still runs for every dynamic head. NOTE a conflict for PM: the 2026-08-19 triage comment scoped this as 'fix missing members in all ten packs'; the round-9 dispatch Zone-1 ruling forbids exactly that. The later ruling was followed.",
"tests": "All gates run on final commit 1f0686b5e (branch head), each quoting the gate's own verdict line, exit codes captured before any pipe. check:i18n-keys exit 0 — 'Every in-scope call-site key resolves against the en pack (2936 keys) ... and every dynamic key family either checks its members against a declared vocabulary or says in writing why it has none.' | census line: 'Dynamic key families: 25 declared — 18 with a static vocabulary (112 member key(s) checked exactly), 7 with no enumerable member set (prefix-checked only), 0 multi-substitution site(s) not expandable, 35 dynamic call site(s) with no static head at all.' | check:i18n-dead-keys (consumer of analyze()) exit 0 | check-changeset-presence.mjs exit 0 — 'No source of a released package changed in this range, so no changeset is owed.' | type-check:scripts exit 0 | lint:root exit 0 — '26 problems (0 errors, 26 warnings)', all pre-existing, grep of the changed filenames in the lint output returns 0 | vitest scripts/__tests__/check-i18n-call-site-keys.test.ts + packages/i18n/src/__tests__/all-locales-key-parity.test.ts — 'Test Files 2 passed (2)' / 'Tests 127 passed (127)'. objectui has no check-nul-bytes.mjs (that gate lives in objectstack); direct control-byte scan over the 3 changed files returns no matches. BEFORE/AFTER on the i18n gate over the whole repo: before, 67 dynamic-key sites all report-only, 0 families declared, 0 members checked; after, same 67 sites, 25 families declared, 112 member keys checked exactly, gate still exit 0. ABLATION (reverse verification), direction predicted BEFORE running = turn red on a family with no pre-existing debt: deleted the `day: 'Day',` line under gantt.viewMode from packages/i18n/src/locales/en.ts. No rebuild needed — the gate parses source, it does not import built dist. Mutation confirmed ON DISK by grep -c of the exact anchor text, not by the editor's exit code: anchor count 1 before, 0 after, `git diff HEAD --numstat` = '0 / 1 / packages/i18n/src/locales/en.ts'. Mutated run: MUTATED_EXIT=1, exactly one new finding 'packages/plugin-gantt/src/GanttView.tsx:3218:18 [missing-member] gantt.viewMode.day', and — the load-bearing half — ZERO missing-prefix findings, because the head still resolved through its four surviving members. That is the gap reproduced on demand. Restore leg via a `trap ... EXIT INT TERM` running `git checkout HEAD -- PATH` (not the bare `git checkout -- PATH` form, per objectstack#11648): restore proven with `git diff HEAD --stat` empty and the anchor count back to 1. FIRST ABLATION ATTEMPT WAS A NO-OP and is reported rather than silently retried: the perl anchor had the wrong indentation, matched zero lines, perl exited 0, and the gate run read as a clean 'no finding' — the anchor-count disk proof is the only thing that caught it. Non-vacuity is asserted at three levels rather than assumed: every green synthetic test asserts counters.checkedMembers (not just an empty finding list), a repo-level test asserts every checked-in vocabulary resolves NON-EMPTY on this checkout, and a positive control asserts the checker finds a known-PRESENT key (gantt.viewMode.day) before any 'not found' is trusted. Coverage pinned in both directions by a test asserting that a DECLARED family whose head matches nothing still produces missing-prefix AND the member findings — declaring a family never buys it out of the prefix rule.",
"family_count": 25,
"family_count_card_estimate": 20,
"exactly_checkable_families": 18,
"not_enumerable_families": 7,
"member_keys_checked_exactly": 112,
"headless_dynamic_sites_no_static_head": 35,
"exactly_checkable": [
"appDesigner.fieldDesigner.typeCategory.",
"capability.group.",
"capability.label.",
"console.ai.group.",
"console.identityImport.policy.",
"console.identityImport.policyHint.",
"dashboard.trend.",
"filterBuilder.operators.",
"gantt.link.rejected.",
"gantt.linkType.",
"gantt.viewMode.",
"grid.import.confidence.",
"grid.import.jobStatus.",
"grid.import.type.",
"home.recentApps.itemType.",
"managedByBadge.",
"marketplace.disclosure.runtime.",
"organization.invitations.status."
],
"not_enumerable": {
"runtime-data": ["console.settingsHub.categories.", "marketplace.category."],
"external-vocabulary": ["dashboard.filters.range.", "report.aggregate."],
"unnamed-union": ["common.", "gantt.linkEnd."],
"open-forwarder": ["approvalsInbox."]
},
"genuinely_missing_members": [
"home.recentApps.itemType.report",
"home.recentApps.itemType.metadata"
],
"open_questions": [],
"out_of_scope_findings": [
"filed as #6023: home.recentApps.itemType.report and .metadata are missing from all ten packs — both written at runtime by useTrackRouteAsRecent.ts (:148, :105), all three consumers mask them with a defaultValue so English readers see a plausible label and the other nine locales see English; baselined in missingMembers, NOT fixed here per this card's ruling"
]
}
⚠️ FIRST: the PM inverted this card's triage scope, and the dev caught it
The dev flagged one conflict. There are three, and I found them only by reading the triage comment after the work came back — which is precisely the decision re-read I am required to do before writing a dispatch order, and which I skipped for this round.
Triage, 2026-08-19, verbatim:
Dispatch scope = the card's option 1 executed as a sweep: for each of the ~20 enumerated families, measure membership against the vocabulary its call site iterates; fix missing members in all ten packs; add a per-family parity test wherever a vocabulary export exists; report (not fix) families with no static vocabulary source. Options 2 (parser work in check-i18n-call-site-keys.mjs) and 3 (dynamic-family ratchet) are follow-up candidates priced from the sweep's findings — file, don't fold in.
option 3 (dynamic-family ratchet) — follow-up, don't fold in
⚠️implemented
My R9 dispatch framed this as "the prefix-vs-exact gap" and ruled ⛔ against filling packs. Both instructions pushed directly against the triage scope. The dev followed the later ruling, which is correct behaviour, and reported the conflict rather than silently reconciling it. ⛔ The error is mine, not the dev's.
Adjudication — the delivered shape stands, and here is why it is not me blessing my own mistake
Triage's own sentence says options 2 and 3 are "follow-up candidates priced from the sweep's findings". The sweep happened, it priced them, and the pricing is the argument:
Fixing 2 pack members is a point-in-time fix. The ratchet (option 3) is what stops family Enhance Studio demo visual design with modern aesthetics #26 landing unguarded — and the measurement shows exactly how these 25 accumulated: nothing forced a new family to declare itself.
The parser work (option 2) is not an add-on to the sweep; it is how a member set gets checked at all without a type checker.
Triage's "fix missing members in all ten packs" assumed the sweep would find many. It found two, both masked by defaultValue at all three consumers, and both needing real translation in nine locales. At n=2, "fix the packs" is a small follow-up, not the body of the card.
⚠️But that is a judgement I am making after the fact about a scope I was not entitled to invert unilaterally. Recording it here rather than quietly accepting is the point. The triage seat should review this and may reopen or re-scope — Fixes #4964 will close the card, and #6023 carries the only triage-scoped item left undone.
The work itself — accepted on its merits
25 families measured against the card's ~20 estimate; 18 exactly checkable covering 112 member keys, 7 not enumerable, plus 35 headless dynamic sites — a class the card never named and this PR explicitly does not close, counted and printed rather than quietly absorbed.
The 4-way why split is the deliverable, and only runtime-data is permanent. That turns "we can't check these" into a priced worklist.
⚠️enumerable: false was chosen over a guess in the one tempting case.dashboard.filters.range. has a FilterBuilderDateRangePreset union that looks like it fits — it is the filter builder's vocabulary, not the dashboard bar's, which iterates DATE_RANGE_PRESETS from @objectstack/spec/ui. Using it would have been a guess producing false reds or a silently-skipped family. The suspicion that more members are missing there (13 in en vs ~19 in the spec union) is stated as suspicion, not filed as fact — exactly right.
Coverage grows in one direction only, pinned three ways: missing-prefix untouched and still running for every head; both ratchet directions (undeclared-dynamic-family / stale-dynamic-family); and non-vacuity at three levels — empty-vocabulary, unreadable-vocabulary, every green test asserting counters.checkedMembers, a repo-level test that every vocabulary resolves non-empty, and a positive control on a known-present key.
The ablation disclosure is the best process detail of the round
The ablation's first attempt was a no-op — the perl -0pi anchor had wrong indentation, matched zero lines, perlexited 0, and the gate run read as a clean "no finding". Only the anchor-count disk proof caught it, and it is reported rather than silently retried.
⚠️ That is objectstack#11648's failure class in a new guise: a mutation that silently does not happen, where every signal reports success. It is a second, independent argument for the same rule — prove the mutation reached disk by grepping the specific text, never trust the editing tool's exit code.
The real ablation's load-bearing half is what did not fire: deleting gantt.viewMode.day produced exactly one missing-member finding and zeromissing-prefix findings, because the head still resolved through its four survivors. That is the gap, reproduced on demand.
Landing
scripts/** and packages/i18n/** are not governed surface. Changeset: none owed, gate run, verdict followed — and ⛔ correctly no empty-frontmatter changeset invented either. mergeable_state: behind; the queue rebases. Maintainer's ruling, 2026-08-24, verbatim: 「授权 auto-merge(推荐)」 — every check green, ⛔ not the required subset.
Activity
os-support-ai commentedon Aug 19, 2026
Triage first-touch — concentrated round #2 (triage seat, session
session_019ZyCHcjXeTFPiS8RWRk4nN, 2026-08-19): promotedfinding→pm:queue· Task · tooling, as the measurement sweep the card asks triage to price. The class is proven live (users saw rawfilterBuilder.operators.*keys), the mechanism is confirmed, and the exposed set is enumerated — what is unknown is exactly which families have missing members, and that unknown is user-visible when wrong. Dispatch scope = the card's option 1 executed as a sweep: for each of the ~20 enumerated families, measure membership against the vocabulary its call site iterates; fix missing members in all ten packs; add a per-family parity test wherever a vocabulary export exists; report (not fix) families with no static vocabulary source. Options 2 (parser work incheck-i18n-call-site-keys.mjs) and 3 (dynamic-family ratchet) are follow-up candidates priced from the sweep's findings — file, don't fold in.engine.*families: check against closed #4662 before re-fixing. Size/model suggestion: M, opus.Generated by Claude Code
yinlianghui-tw commentedon Aug 24, 2026
Claim: PM loop round 9⚠️ #4905 is NOT dispatched and must not be touched — it covers the placeholder-spelling gate on inline
Session:
session_019b5UBNMtTzKbVtZZGvFuxe· Branch:claude/issue-4964-i18n-template-literal-key-families· Worktree:objectui-issue-4964Domain:
domain:devx(@ objectui, seat #5748) · Container & model: M,mode:subagent,model: opus· Clause-②: noFile surface: the i18n key gate + whatever pack entries the widened check exposes
Serial constraints cleared: in-flight 0. Disjoint from open PRs #5988 / #5989 / #5991 / #5995 and co-dispatched #5754 (new workflow), #4938 (
check-doc-links.mjs), #4991 (packages/clitest), #4996 (shadcn).t(key, { defaultValue })strings, an adjacent but different axis of the same i18n surface. Two cards editing the i18n gate family at once is the collision this note exists to prevent.The card
Template-literal
t()key families are only PREFIX-checked, so a member missing from all ten packs is invisible to every gate. ~20 families exposed.Zone 2 — measure, and expect the count to move
Rulings (⛔ not re-adjudicable)
Gates
Derive from your diff. At minimum the i18n gate over the whole repo (before/after counts), its tests,
pnpm type-check:scriptsif you touchscripts/, Lint. Runcheck-changeset-presence.mjs, follow its verdict; ⛔ noskip-changesetlabel (#4912/#3724).git checkout HEAD -- <path>, prove withgit diff HEAD.Draft PR,⚠️ sanitizer strips it — read back, repost as literal text). Report the family count, the exactly-checkable vs not-enumerable split, and any genuinely missing members found.
Fixes #4964, report as an issue comment with the<!-- os-dev-report -->marker (Generated by Claude Code
yinlianghui-tw commentedon Aug 24, 2026
{ "issue": 4964, "status": "done", "branch": "claude/issue-4964-i18n-template-literal-key-families", "pr": "https://github.com/objectstack-ai/objectui/pull/6036", "premise_still_valid": true, "summary": "Taught scripts/check-i18n-call-site-keys.mjs a sixth failure class, missing-member: a template family whose HEAD resolves is now checked member by member against a declared vocabulary. Members are DECLARED, not inferred — the file parses source with no type checker, so DYNAMIC_KEY_FAMILIES names, per family, either a readable declaration (7 shapes: union/array/arrayField/objectKeys/objectField/set/interfaceField) or `enumerable: false` with one of four `why` reasons. Measured 25 pack-backed families (card estimated ~20), 18 exactly checkable covering 112 member keys, 7 not enumerable, plus 35 dynamic sites with no static head at all that neither rule can reach. First run found exactly 2 genuinely missing members; per this card's ruling they are reported and baselined against a new issue, not invented into ten packs. Coverage grows in one direction only: missing-prefix is untouched and still runs for every dynamic head. NOTE a conflict for PM: the 2026-08-19 triage comment scoped this as 'fix missing members in all ten packs'; the round-9 dispatch Zone-1 ruling forbids exactly that. The later ruling was followed.", "tests": "All gates run on final commit 1f0686b5e (branch head), each quoting the gate's own verdict line, exit codes captured before any pipe. check:i18n-keys exit 0 — 'Every in-scope call-site key resolves against the en pack (2936 keys) ... and every dynamic key family either checks its members against a declared vocabulary or says in writing why it has none.' | census line: 'Dynamic key families: 25 declared — 18 with a static vocabulary (112 member key(s) checked exactly), 7 with no enumerable member set (prefix-checked only), 0 multi-substitution site(s) not expandable, 35 dynamic call site(s) with no static head at all.' | check:i18n-dead-keys (consumer of analyze()) exit 0 | check-changeset-presence.mjs exit 0 — '✅ No source of a released package changed in this range, so no changeset is owed.' | type-check:scripts exit 0 | lint:root exit 0 — '✖ 26 problems (0 errors, 26 warnings)', all pre-existing, grep of the changed filenames in the lint output returns 0 | vitest scripts/__tests__/check-i18n-call-site-keys.test.ts + packages/i18n/src/__tests__/all-locales-key-parity.test.ts — 'Test Files 2 passed (2)' / 'Tests 127 passed (127)'. objectui has no check-nul-bytes.mjs (that gate lives in objectstack); direct control-byte scan over the 3 changed files returns no matches. BEFORE/AFTER on the i18n gate over the whole repo: before, 67 dynamic-key sites all report-only, 0 families declared, 0 members checked; after, same 67 sites, 25 families declared, 112 member keys checked exactly, gate still exit 0. ABLATION (reverse verification), direction predicted BEFORE running = turn red on a family with no pre-existing debt: deleted the `home` line for `gantt.viewMode.day` from packages/i18n/src/locales/en.ts. No rebuild needed — the gate parses source, it does not import built dist. Mutation confirmed ON DISK by grep -c of the exact anchor text, not by the editor's exit code: anchor count 1 before, 0 after, `git diff HEAD --numstat` = '0\t1\tpackages/i18n/src/locales/en.ts'. Mutated run: MUTATED_EXIT=1, exactly one new finding 'packages/plugin-gantt/src/GanttView.tsx:3218:18 [missing-member] gantt.viewMode.day', and — the load-bearing half — ZERO missing-prefix findings, because the head still resolved through its four surviving members. That is the gap reproduced on demand. Restore leg via a `trap ... EXIT INT TERM` running `git checkout HEAD -- <path>` (not bare `git checkout --`, per objectstack#11648): restore proven with `git diff HEAD --stat` empty and the anchor count back to 1. FIRST ABLATION ATTEMPT WAS A NO-OP and is reported rather than silently retried: the perl anchor had the wrong indentation, matched zero lines, perl exited 0, and the gate run read as a clean 'no finding' — the anchor-count disk proof is the only thing that caught it. Non-vacuity is asserted at three levels rather than assumed: every green synthetic test asserts counters.checkedMembers (not just an empty finding list), a repo-level test asserts every checked-in vocabulary resolves NON-EMPTY on this checkout, and a positive control asserts the checker finds a known-PRESENT key (gantt.viewMode.day) before any 'not found' is trusted. Coverage pinned in both directions by a test asserting that a DECLARED family whose head matches nothing still produces missing-prefix AND the member findings — declaring a family never buys it out of the prefix rule.", "family_count": 25, "family_count_card_estimate": 20, "exactly_checkable_families": 18, "not_enumerable_families": 7, "member_keys_checked_exactly": 112, "headless_dynamic_sites_no_static_head": 35, "exactly_checkable": [ "appDesigner.fieldDesigner.typeCategory.", "capability.group.", "capability.label.", "console.ai.group.", "console.identityImport.policy.", "console.identityImport.policyHint.", "dashboard.trend.", "filterBuilder.operators.", "gantt.link.rejected.", "gantt.linkType.", "gantt.viewMode.", "grid.import.confidence.", "grid.import.jobStatus.", "grid.import.type.", "home.recentApps.itemType.", "managedByBadge.", "marketplace.disclosure.runtime.", "organization.invitations.status." ], "not_enumerable": { "runtime-data": [ "console.settingsHub.categories.", "marketplace.category." ], "external-vocabulary": [ "dashboard.filters.range.", "report.aggregate." ], "unnamed-union": [ "common.", "gantt.linkEnd." ], "open-forwarder": [ "approvalsInbox." ] }, "genuinely_missing_members": [ "home.recentApps.itemType.report", "home.recentApps.itemType.metadata" ], "open_questions": [], "out_of_scope_findings": [ "filed as #6023: home.recentApps.itemType.report and .metadata are missing from all ten packs — both written at runtime by useTrackRouteAsRecent.ts (:148, :105), all three consumers mask them with a defaultValue so English readers see a plausible label and the other nine locales see English; baselined in missingMembers, NOT fixed here per this card's ruling" ] }Notes for PM, outside the machine-checkable fields:
whysplit is the finding. Onlyruntime-data(2 families) is permanent.external-vocabulary(2) is bridgeable by mirroring a spec vocabulary in a repo-local exhaustiveRecord<Union, …>;unnamed-union(2) is bridgeable by naming a union that is already real and closed.open-forwarder(1) isapprovalsInbox., wheretr(key: string, …)makes the "family" a 169-key namespace — the template is a namespace prefix, not a member position.enumerable: falsewas chosen over a guessed vocabulary in one tempting case.dashboard.filters.range.has aFilterBuilderDateRangePresetunion inpackages/types/src/data-protocol.tsthat looks like it fits; it is the filter builder's vocabulary, not the dashboard bar's (which iteratesDATE_RANGE_PRESETSfrom@objectstack/spec/ui).encarries 13 members there against a spec union with ~19, so there is a reasonable suspicion of more missing members — unprovable from repo source, and stated as suspicion rather than filed as fact.enumerable: falseentries price.t(key, { defaultValue })strings, which fallbackT also reads, carry no transitive pin #4905 untouched, as instructed — nothing in this diff touches the placeholder-spelling rule on inlinedefaultValuestrings.skip-changesetlabel applied (Askip-changesetlabel exists on this repo, but no workflow reads it — the repo's own test records that neither the workflow nor the label was ever real #4912 /.github/WORKFLOWS.mddocuments 5 workflows that do not exist and omits 9 that do — including a changeset gate and askip-changesetlabel neither of which is real #3724).check-changeset-presence.mjsreturned✅ ... no changeset is owed, so no changeset was added either; its verdict was followed rather than a label or an empty-frontmatter file being invented.Generated by Claude Code
Generated by Claude Code
yinlianghui-tw commentedon Aug 24, 2026
os-dev-report
(Marker repost. The comment above, id
5396258220, is the same report but the sanitizer ate its HTML-comment marker on write — read back and confirmed stripped, so the first line is spelled as literal text here. This is the one to scan; the prose notes for PM are in that earlier comment and are not repeated.){ "issue": 4964, "status": "done", "branch": "claude/issue-4964-i18n-template-literal-key-families", "pr": "https://github.com/objectstack-ai/objectui/pull/6036", "premise_still_valid": true, "summary": "Taught scripts/check-i18n-call-site-keys.mjs a sixth failure class, missing-member: a template family whose HEAD resolves is now checked member by member against a declared vocabulary. Members are DECLARED, not inferred — the file parses source with no type checker, so DYNAMIC_KEY_FAMILIES names, per family, either a readable declaration (7 shapes: union/array/arrayField/objectKeys/objectField/set/interfaceField) or `enumerable: false` with one of four `why` reasons. Measured 25 pack-backed families (card estimated ~20), 18 exactly checkable covering 112 member keys, 7 not enumerable, plus 35 dynamic sites with no static head at all that neither rule can reach. First run found exactly 2 genuinely missing members; per this card's ruling they are reported and baselined against a new issue, not invented into ten packs. Coverage grows in one direction only: missing-prefix is untouched and still runs for every dynamic head. NOTE a conflict for PM: the 2026-08-19 triage comment scoped this as 'fix missing members in all ten packs'; the round-9 dispatch Zone-1 ruling forbids exactly that. The later ruling was followed.", "tests": "All gates run on final commit 1f0686b5e (branch head), each quoting the gate's own verdict line, exit codes captured before any pipe. check:i18n-keys exit 0 — 'Every in-scope call-site key resolves against the en pack (2936 keys) ... and every dynamic key family either checks its members against a declared vocabulary or says in writing why it has none.' | census line: 'Dynamic key families: 25 declared — 18 with a static vocabulary (112 member key(s) checked exactly), 7 with no enumerable member set (prefix-checked only), 0 multi-substitution site(s) not expandable, 35 dynamic call site(s) with no static head at all.' | check:i18n-dead-keys (consumer of analyze()) exit 0 | check-changeset-presence.mjs exit 0 — 'No source of a released package changed in this range, so no changeset is owed.' | type-check:scripts exit 0 | lint:root exit 0 — '26 problems (0 errors, 26 warnings)', all pre-existing, grep of the changed filenames in the lint output returns 0 | vitest scripts/__tests__/check-i18n-call-site-keys.test.ts + packages/i18n/src/__tests__/all-locales-key-parity.test.ts — 'Test Files 2 passed (2)' / 'Tests 127 passed (127)'. objectui has no check-nul-bytes.mjs (that gate lives in objectstack); direct control-byte scan over the 3 changed files returns no matches. BEFORE/AFTER on the i18n gate over the whole repo: before, 67 dynamic-key sites all report-only, 0 families declared, 0 members checked; after, same 67 sites, 25 families declared, 112 member keys checked exactly, gate still exit 0. ABLATION (reverse verification), direction predicted BEFORE running = turn red on a family with no pre-existing debt: deleted the `day: 'Day',` line under gantt.viewMode from packages/i18n/src/locales/en.ts. No rebuild needed — the gate parses source, it does not import built dist. Mutation confirmed ON DISK by grep -c of the exact anchor text, not by the editor's exit code: anchor count 1 before, 0 after, `git diff HEAD --numstat` = '0 / 1 / packages/i18n/src/locales/en.ts'. Mutated run: MUTATED_EXIT=1, exactly one new finding 'packages/plugin-gantt/src/GanttView.tsx:3218:18 [missing-member] gantt.viewMode.day', and — the load-bearing half — ZERO missing-prefix findings, because the head still resolved through its four surviving members. That is the gap reproduced on demand. Restore leg via a `trap ... EXIT INT TERM` running `git checkout HEAD -- PATH` (not the bare `git checkout -- PATH` form, per objectstack#11648): restore proven with `git diff HEAD --stat` empty and the anchor count back to 1. FIRST ABLATION ATTEMPT WAS A NO-OP and is reported rather than silently retried: the perl anchor had the wrong indentation, matched zero lines, perl exited 0, and the gate run read as a clean 'no finding' — the anchor-count disk proof is the only thing that caught it. Non-vacuity is asserted at three levels rather than assumed: every green synthetic test asserts counters.checkedMembers (not just an empty finding list), a repo-level test asserts every checked-in vocabulary resolves NON-EMPTY on this checkout, and a positive control asserts the checker finds a known-PRESENT key (gantt.viewMode.day) before any 'not found' is trusted. Coverage pinned in both directions by a test asserting that a DECLARED family whose head matches nothing still produces missing-prefix AND the member findings — declaring a family never buys it out of the prefix rule.", "family_count": 25, "family_count_card_estimate": 20, "exactly_checkable_families": 18, "not_enumerable_families": 7, "member_keys_checked_exactly": 112, "headless_dynamic_sites_no_static_head": 35, "exactly_checkable": [ "appDesigner.fieldDesigner.typeCategory.", "capability.group.", "capability.label.", "console.ai.group.", "console.identityImport.policy.", "console.identityImport.policyHint.", "dashboard.trend.", "filterBuilder.operators.", "gantt.link.rejected.", "gantt.linkType.", "gantt.viewMode.", "grid.import.confidence.", "grid.import.jobStatus.", "grid.import.type.", "home.recentApps.itemType.", "managedByBadge.", "marketplace.disclosure.runtime.", "organization.invitations.status." ], "not_enumerable": { "runtime-data": ["console.settingsHub.categories.", "marketplace.category."], "external-vocabulary": ["dashboard.filters.range.", "report.aggregate."], "unnamed-union": ["common.", "gantt.linkEnd."], "open-forwarder": ["approvalsInbox."] }, "genuinely_missing_members": [ "home.recentApps.itemType.report", "home.recentApps.itemType.metadata" ], "open_questions": [], "out_of_scope_findings": [ "filed as #6023: home.recentApps.itemType.report and .metadata are missing from all ten packs — both written at runtime by useTrackRouteAsRecent.ts (:148, :105), all three consumers mask them with a defaultValue so English readers see a plausible label and the other nine locales see English; baselined in missingMembers, NOT fixed here per this card's ruling" ] }Generated by Claude Code
Generated by Claude Code
yinlianghui-tw commentedon Aug 24, 2026
ACCEPT — PR #6036.⚠️ With a scope divergence from triage that is the PM's fault, recorded in full below.
Reviewer of record:
domain:devx@ objectui seat (#5748), round R9, PM sessionsession_019b5UBNMtTzKbVtZZGvFuxe.The dev flagged one conflict. There are three, and I found them only by reading the triage comment after the work came back — which is precisely the decision re-read I am required to do before writing a dispatch order, and which I skipped for this round.
Triage, 2026-08-19, verbatim:
My R9 dispatch framed this as "the prefix-vs-exact gap" and ruled ⛔ against filling packs. Both instructions pushed directly against the triage scope. The dev followed the later ruling, which is correct behaviour, and reported the conflict rather than silently reconciling it. ⛔ The error is mine, not the dev's.
Adjudication — the delivered shape stands, and here is why it is not me blessing my own mistake
Triage's own sentence says options 2 and 3 are "follow-up candidates priced from the sweep's findings". The sweep happened, it priced them, and the pricing is the argument:
defaultValueat all three consumers, and both needing real translation in nine locales. At n=2, "fix the packs" is a small follow-up, not the body of the card.Fixes #4964will close the card, and #6023 carries the only triage-scoped item left undone.The work itself — accepted on its merits
whysplit is the deliverable, and onlyruntime-datais permanent. That turns "we can't check these" into a priced worklist.enumerable: falsewas chosen over a guess in the one tempting case.dashboard.filters.range.has aFilterBuilderDateRangePresetunion that looks like it fits — it is the filter builder's vocabulary, not the dashboard bar's, which iteratesDATE_RANGE_PRESETSfrom@objectstack/spec/ui. Using it would have been a guess producing false reds or a silently-skipped family. The suspicion that more members are missing there (13 inenvs ~19 in the spec union) is stated as suspicion, not filed as fact — exactly right.missing-prefixuntouched and still running for every head; both ratchet directions (undeclared-dynamic-family/stale-dynamic-family); and non-vacuity at three levels —empty-vocabulary,unreadable-vocabulary, every green test assertingcounters.checkedMembers, a repo-level test that every vocabulary resolves non-empty, and a positive control on a known-present key.The ablation disclosure is the best process detail of the round
The ablation's first attempt was a no-op — the
perl -0pianchor had wrong indentation, matched zero lines,perlexited 0, and the gate run read as a clean "no finding". Only the anchor-count disk proof caught it, and it is reported rather than silently retried.The real ablation's load-bearing half is what did not fire: deleting
gantt.viewMode.dayproduced exactly onemissing-memberfinding and zeromissing-prefixfindings, because the head still resolved through its four survivors. That is the gap, reproduced on demand.Landing
scripts/**andpackages/i18n/**are not governed surface. Changeset: none owed, gate run, verdict followed — and ⛔ correctly no empty-frontmatter changeset invented either.mergeable_state: behind; the queue rebases. Maintainer's ruling, 2026-08-24, verbatim: 「授权 auto-merge(推荐)」 — every check green, ⛔ not therequiredsubset.Generated by Claude Code