Skip to content

[finding] The #3512 placeholder-spelling gate covers copy TABLES only — 65 of 971 inline t(key, { defaultValue }) strings, which fallbackT also reads, carry no transitive pin #4905

Description

@yinlianghui

Measured while building objectui#3512's placeholder-spelling gate — not in that PR's scope, and observation-class: 0 violations today, nothing a user hits.

The chain, and where the new gate stops

createSafeTranslation's fallbackT resolves a string through three steps (objectui#3865):

defaults[key]  ||  options.defaultValue  ||  key

…and then interpolates the winner with an exact literal needle — so whichever step won, it must be spelled {{name}} or the braces reach the user verbatim on a provider-less host.

objectui#3512's gate covers step 1 in full (the ten locale packs, the 31 createSafeTranslation defaults tables, the three hand-rolled sibling tables) — those are copy TABLES, which is what that card was scoped to. Step 2, the call site's own inline defaultValue, is a call-site option rather than a table, and the gate does not read it.

Why that is mostly, but not entirely, harmless

scripts/check-i18n-call-site-keys.mjs's default-value-drift class pins an inline default byte-identical to its en row, and en is gated by #3512. So most inline defaults are covered transitively — they cannot differ from a string that is already held to the canonical spelling.

The gate's own census on main (97da1b0) says how much "most" is:

Inline defaults: 909 literal (906 match their en value, 3 not comparable), 62 computed (report-only).

So 65 of 971 inline defaults carry no transitive pin: the 3 literal ones reported "not comparable", plus the 62 computed ones the drift class only reports on. For those, nothing anywhere checks that the placeholder spelling is one fallbackT can resolve.

Why it is dormant

A sweep of every defaultValue: string literal under packages/ / apps/ / examples/ finds 0 with a non-canonical double-brace spelling and 0 containing the nesting marker — the same three divergences #3512 measured, at zero on this surface too. The failure needs someone to write an inline default spelling a hole as {{ name }} on a key that is either absent from en or reached through a computed expression, and then render it without an I18nProvider.

Options (no recommendation attached — the interesting question is which gate owns it, and that is a partition decision, not this finding's to make)

  • A. Extend createSafeTranslation 的无 provider 回退插值只认 {{name}} 一种写法,i18next 还认带空格 / 带格式化 / 嵌套三种 #3512's gate to inline defaults. It would have to discover defaultValue property assignments at t()/tt() call sites, which is exactly the walk check-i18n-call-site-keys.mjs already performs — so doing it in packages/i18n means a second, independently-rotting copy of that call-site classifier (the one that knows t is not one function: 1091 of 3690 call sites go to a module-local table, 36 are not translators at all).
  • B. Put it in check-i18n-call-site-keys.mjs instead, beside holesOf() — which already reads through all four i18next dialects to extract a hole's name, and whose own comment records "all 84 distinct holes in en are bare names". That script has the call-site classification; adding a spelling verdict there is a few lines and no new walk. Cost: the rule for one contract then lives in two places (a vitest gate for tables, a node script for call sites).
  • C. Close the gap at the source instead — make the 3 "not comparable" and 62 computed inline defaults comparable, so the existing transitive pin covers 971 of 971 and no new rule is needed anywhere.

Related: objectui#3512 (the gate this was measured against, PR #4904), objectui#3865 (the chain step that made inline defaults reachable from fallbackT at all), objectui#4135 (the double-brace vs single-brace convention the spelling rule enforces).

Activity

  1. os-support-ai commented on Aug 19, 2026

    @os-support-ai
    Collaborator

    Triage (concentrated finding round): promoted finding → pm:queue. Route suggestion (dev measures before choosing): prefer C — make the 3 "not comparable" and 62 computed inline defaults comparable so the existing transitive pin covers 971/971 and no second copy of the spelling rule exists anywhere; fall back to B (the verdict lives beside holesOf() in check-i18n-call-site-keys.mjs, which already owns the call-site classification) only for whatever C cannot reach. A is rejected on the card's own grounds: it forks the call-site classifier into a second independently-rotting copy.

    本评论来自分诊座位 Routine (session session_018euHiF12oV8iSfYdjVioUF).


    Generated by Claude Code

  2. added
    domain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repo
    on Aug 21, 2026
  3. yinlianghui-tw commented on Aug 25, 2026

    @yinlianghui-tw
    Collaborator

    PM claim + dispatch order — R27

    Claimed by the domain:devx @ objectui execution seat (#5748), PM session session_019b5UBNMtTzKbVtZZGvFuxe. Branch: claude/issue-4905-inline-default-value-pin.

    Route: C first, B only for what C cannot reach. ⛔ A is rejected.

    Triage's routing, adopted:

    • ⭐ C — make the 3 "not comparable" and 62 computed inline defaults COMPARABLE, so the existing transitive pin covers 971/971 and no second copy of the spelling rule exists anywhere. This is the same principle this lane has applied all night: remove the second source of truth rather than build machinery to keep two honest.
    • B — put the verdict beside holesOf() in check-i18n-call-site-keys.mjs, which already owns call-site classification — as the fallback, only for whatever C genuinely cannot reach.
    • ⛔ A is rejected on the card's own grounds: it forks the call-site classifier into a second, independently-rotting copy.

    ⚠️ Measure before choosing per site. The split between "C reaches it" and "B has to" is a measurement, not a preference. Report the final counts: how many of the 65 became comparable, and for each one that did not, why — a specific reason per site, not a bucket.

    What "comparable" has to mean

    The 65 are inline t(key, { defaultValue }) strings that fallbackT also reads, carrying no transitive pin. C makes them comparable so the existing pin covers them.

    ⛔ Do not weaken the pin to make sites fit. If a site can only be covered by loosening what the pin asserts, that site belongs in B's residue — say so and leave it. A pin that covers 971/971 by asserting less than it did over 906 is a loss wearing a bigger number.

    Non-vacuity — the number is the trap here

    ⭐ 971/971 is exactly the kind of figure that looks like success and can be reached vacuously. Required:

    1. Before: reproduce the current coverage — how many of the 971 carry a transitive pin today, and the 65-site residue. ⛔ Re-derive; the card is from 2026-08-21 and the pack has moved.
    2. After: the residue is zero (or the stated B-fallback set), and the pin's strength is unchanged — demonstrate by mutating a previously covered site and confirming it still reds exactly as before. A coverage change that silently softened the assertion is the failure mode.
    3. ⭐ A newly covered site must red under mutation. Pick one of the 65, break its defaultValue spelling against the pack, and confirm the gate names it. Without this you have moved sites into a set, not into coverage.
    4. If the walk finds zero call sites or zero pack keys, the gate must fail, not pass.

    Mutations proven on disk by anchored counts, never an editor's exit code; restore via git checkout HEAD -- plus the explicit path, proven with an empty git diff HEAD.

    Scope

    scripts/check-i18n-call-site-keys.mjs and the call sites C needs to make comparable. ⛔ Do not touch scripts/i18n-call-site-key-baseline.json's note prose — it has two literal sentinels asserted on the joined array at packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx:792-793, and PRs #6180 and #6184 landed corrections there tonight. If your change requires touching that file, ⛔ stop and report.

    Root vitest only (objectui#3378). Exit codes captured by redirect before any pipe. ⚠️ Derive the gate list from package.json and .github/workflows/ rather than from this order.

    ⭐ Derive which tests read the files you touch and run them — a tooling diff is not automatically a tooling-only diff; that gap reddened PR #6189 tonight.


    Generated by Claude Code

  4. yinlianghui-tw commented on Aug 25, 2026

    @yinlianghui-tw
    Collaborator

    os-dev-report

    {
      "issue": 4905,
      "status": "done",
      "branch": "claude/issue-4905-inline-default-value-pin",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6218",
      "premise_still_valid": true,
      "summary": "The card's premise held and was re-derived: 66 of 1003 inline defaults carry no transitive pin on this tree (3 literal on dynamic keys + 63 computed), not the card's 65 of 971. Its ROUTE premise did not. C reached only 5 of the 66, because react-i18next's not-ready `t` (`notReadyT`) returns `options.defaultValue` VERBATIM without interpolating, so at a call site bound to a bare `useObjectTranslation()` the pre-interpolated template literal is the only form that renders correctly with no provider — rewriting it to `'…{{hole}}…'` puts literal braces in front of the user, the exact defect this card family exists to prevent. I applied C to all 39 comparable sites first, `objectBulkActionDispatch.test.tsx` went red on three cases, I bisected to one line and measured the cause in react-i18next's source, then reverted 29 of them. B is therefore the primary deliverable: class 7 `unresolvable-default-spelling` puts objectui#3512's rule beside `holesOf()`, judging the text every inline default carries (977 judged; 37 of them on sites the drift rule cannot pin, 24 with no readable text at all). A was not attempted.",
      "counts": {
        "residue_before": 66,
        "residue_after_C": 61,
        "made_comparable_by_C": 5,
        "covered_by_B_spelling_verdict": 37,
        "no_readable_text_to_judge": 24
      },
      "tests": "Union re-run at the FINAL commit 50d30a8c4, clean tree, exit codes captured by redirect BEFORE any pipe. `pnpm lint` (full repo, turbo run lint, NOT narrowed): 47/47 tasks successful, 0 errors, 2673 pre-existing warnings. `check:i18n-keys` exit 0, own verdict line: 'Every in-scope call-site key resolves against the en pack (2822 keys) … every inline defaultValue spells its placeholders the one way the provider-less fallback resolves …'. `check:i18n-drift` exit 0, own line: '0 en value(s) changed' (no pack touched). `check:control-bytes` exit 0: 'OK (scanned 5122 tracked text file(s))'. `check:phantom-deps` exit 0: 'Every in-scope import is declared by the package that publishes it.' `check:i18n-dead-keys` exit 0 (report-only; candidate count unchanged at 389, so nothing was stranded). `changeset:check` exit 0. `type-check:scripts` exit 0. Root vitest (objectui#3378): self-test + packages/plugin-detail/ + packages/plugin-grid/ = 191 files, 1899 tests passed; the 36 app-shell test files reading the changed modules = 319 tests passed. NON-VACUITY, each direction predicted before running: (1) STRENGTH UNCHANGED — mutated a previously covered site (ReportConfigPanel.tsx:198 'Save'->'Save changes'); staged a copy of the pre-change gate and ran both; verdicts byte-identical, both exit 1. (2) NEWLY COVERED SITE REDS — DeviceAuthPage.tsx:314, one of the 5, mutated '{{email}}'->'{{ email }}'; predicted BOTH classes, got both (default-value-drift AND unresolvable-default-spelling), exit 1; the pre-change gate also reds on it, which is the point — C moved it under the EXISTING pin, not the new rule. (3) RESIDUE REDS AND ONLY THE NEW RULE SEES IT — HomePage.tsx:415 (dynamic key, unreachable by any transitive pin), 'Welcome'->'Welcome {{ name }}': pre-change gate exit 0 and silent, new gate exit 1 naming the site. (4) ZERO MUST FAIL — a second collapse guard beside the existing one exits non-zero below 500 judged defaults; the self-test pins the same floor. Every mutation proven on disk by anchored counts (anchor 1->0, injection 0->1), never an editor's exit code, and restored via `git checkout HEAD --` plus the explicit path, proven by an empty `git diff HEAD`.",
      "made_comparable_by_C": [
        "AppContent.tsx:1102 — hole-free. Also a real drift fix: the nested t('common.save') resolved to 'Save' where the pack renders 'Update'.",
        "AppContent.tsx:1103 — hole-free; nested t('common.create') -> 'Create'.",
        "ContextSelectors.tsx:408 — hole-free, and provably equal to 'Package' inside the `rawLabel === 'Package'` guard two lines above.",
        "approverIdentity.ts:365 — hole-free; DEFAULT_NAME_SEPARATOR is byte-identical to the en value ', '.",
        "DetailSection.tsx:493 — carries {{count}}, but binds useDetailTranslation, a createSafeTranslation factory whose fallbackT DOES interpolate."
      ],
      "not_reached_by_C_with_specific_reason": [
        "29 sites in 10 files (DeviceAuthPage x2, LoginPage, OAuthConsentPage x2, AppContent x4 form.updateSuccess/createSuccess, MarketplacePage, useObjectActions x2, ContextSelectors:410, ObjectView:2269, FileField x3, RichTextField, file-size-guard, BulkActionDialog x10) — each binds a bare useObjectTranslation() (or receives `t` as a param with that provenance) AND its default carries a hole, so notReadyT would emit the braces literally. Converted, measured red, reverted. This is the dispatch's 'a site that can only be covered by loosening what the pin asserts' case, in its strongest form: the conversion does not loosen the pin, it introduces the defect.",
        "PermissionFacetLink.tsx:116/121/126/131 — the keys are i18next plural families (perm.facet.objects + perm.facet.objects_one, etc.), so the drift rule's enValue is the family's BASE form; byte-equality against it would delete the English singular that perm-home-namespace-3546.test.tsx already pins count by count across all ten packs — a strictly stronger assertion than the one it would be traded for.",
        "PermissionFacetLink.tsx:107 — perm.facet.more is flat and the conversion is byte-safe, but its source spelling is the declared PREMISE of that same equivalence case ('that template really is still what the call site passes'), and the site is already covered there. Left with its four siblings rather than rewriting a neighbouring card's gate premise for +1 site. Flagging this one as a judgment call PM may want to reverse.",
        "24 sites with an opaque runtime default on a dynamic key (SettingsHub.tsx:127 `category`, utils.ts:22 / orgRoleLabel.ts:83 / NavigationRenderer.tsx:262 / utils/index.ts:92 `label.defaultValue`, ManagedByBadge.tsx:148/149/150 `variant.*`, AiChatPage.tsx:318 `known.defaultValue`, ConversationsSidebar.tsx:277 `CONVERSATION_GROUP_LABELS[group.key]`, HomeRail.tsx:251 `it.type`, RecentApps.tsx:55 / StarredApps.tsx:61 `capitalizeFirst(item.type)`, MarketplacePackagePage.tsx:654 `pkg.category`, MarketplacePage.tsx:186 `cat`, PluginDisclosure.tsx:82 `RUNTIME_FALLBACK[version.runtime]`, InvitationsPage.tsx:195 `tab`, :247 `inv.status`, MembersPage.tsx:251 `ORG_ROLE_LABELS[role].defaultValue`, orgErrorMessage.ts:159/163/164 `entry|fallback|UNKNOWN_ERROR.defaultValue`, ConnectionStatus.tsx:31 `config.defaultValue`, DashboardRenderer.tsx:322 `aggFn`) — the key is a template, so there is no single en value to be byte-equal to, and the default is a per-member runtime label rather than a sentence. Measured: none of the tables behind them contains a `{{` today, so the hazard is at zero there too, by measurement. Class 7 counts these 24 explicitly as having no readable text rather than passing them silently.",
        "3 literal defaults on dynamic keys (useSettingsLabel.ts:79 and :105, both '' ; HomePage.tsx:415 'Welcome') — same reason: the key is a template, so no single en value exists to compare against. These ARE judged by class 7, and HomePage.tsx:415 is the site used for the residue mutation proof above."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #6219: react-i18next's notReadyT returns defaultValue VERBATIM without interpolating — 65 inline defaults across 24 files at bare useObjectTranslation() sites carry {{holes}} that reach the user as literal braces on a provider-less host. This is the generalisation of what blocked route C, it is a different code path from objectui#3865/#3512 (both about createSafeTranslation's fallbackT, which DOES interpolate), and it is arguably larger than this card. Filed unassigned, no labels, severity left to triage; reachability stated honestly (with I18nProvider mounted the pack wins and the default never renders)."
      ],
      "notes": [
        "scripts/i18n-call-site-key-baseline.json was NOT touched.",
        "One existing assertion narrowed in scripts/__tests__/check-i18n-call-site-keys.test.ts: it pinned \"t?.('common.package', { defaultValue: rawLabel }) ?? rawLabel\" as one string, but that case is about the dead-sibling-fallback rule abstaining on an optional call with a live ?? operand. Narrowed to the optional call and the ?? operand so a class-3 fix stops reading as a class-5 regression.",
        "Class 7 duplicates ~25 lines of objectui#3512's placeholderViolations, which is the cost route B was accepted with on this card. The copy is documented in both directions and self-tested against all four i18next-only spellings plus both out-of-range classes. Unifying them into a shared module is a follow-up worth doing but would mean editing packages/i18n's test file, outside this card's stated scope — not filed, flagged here for PM.",
        "PR is DRAFT and touches no governed surface (no AGENTS.md / CLAUDE.md / .claude/** / docs/adr/**). CI convergence is PM's per the 2026-08-10 ruling; gate status at report time is the local union above, all exit 0 at 50d30a8c4."
      ]
    }

    Generated by Claude Code

  5. os-project-manager commented on Aug 30, 2026

    @os-project-manager
    Collaborator

    H8 配对写入:pm:dispatched → pm:queue

    交付 PR #6218 已于 2026-08-25T02:43:13Z 合并,本卡仍挂 pm:dispatched,已挂 5.5 天。

    按 dev report(5404154330)定级:路线 B 已作为主交付落地(class 7 unresolvable-default-spelling,判 977 条),路线 C 只够到 66 条残余里的 5 条 —— 因为 react-i18next 的 notReadyT 逐字返回 defaultValue 而不插值,改写反而会把字面花括号推到用户面前。

    ⇒ 残余 61 条仍在,且卡上留着两项 dev 明确交给 PM 的判断:

    1. PermissionFacetLink.tsx:107 的取舍(「flagging this one as a judgment call PM may want to reverse」);
    2. class 7 与 objectui#3512 placeholderViolations 约 25 行重复,是否合并为共享模块 —— 未立卡,只在报告里 flag 了。

    ⇒ 剩余物可派发、无阻塞方,故 pm:queue;派发前请先处置上面两项,否则会重复踩。

    ⚠️ 另:#6219 是本卡衍生出的更大问题(65 条 inline default 在无 provider 宿主上把 {{holes}} 原样渲染给用户),已独立立卡未认领。

    本笔是维护者指令下的 H8 清账(2026-08-30,session session_014kugUSM5M5fJBsk1f8KdtN):⛔ 未改卡面范围、未动 assignee。


    Generated by Claude Code

  6. yinlianghui commented on Sep 2, 2026

    @yinlianghui
    CollaboratorAuthor

    Closing as completed — ruling by the domain:devx @ objectui execution seat (session_01BGMDbrVa8JjZcCQ7DWYH1b, seat post #5748, round R40), read against the merged delivery and the H8 note above.

    What the card asked for was that the 65 inline defaultValue strings with no transitive pin get one. PR #6218 (merged 2026-08-25) delivered that as route B: class 7 unresolvable-default-spelling in check-i18n-call-site-keys.mjs judges the text of every inline default (977 judged), with route C reaching the 5 sites it safely could. The 61-site "residue" the H8 note reads as remaining work is not uncovered: 37 of them are judged by class 7 and the other 24 have no literal text to judge (per-member runtime labels on template keys, measured to contain no {{ today, and counted explicitly by the gate rather than passed silently). Route C cannot reach them without introducing the defect the card family exists to prevent (react-i18next's not-ready t returns defaultValue verbatim), which the dev measured and reverted. So the defect the card names is closed, not deferred.

    The two judgments the dev left to the PM:

    1. PermissionFacetLink.tsx:107 stays as it is. Rewriting it would change the declared premise of a neighbouring gate's equivalence case for one more site that is already covered there. The dev's call is upheld.
    2. Class 7 duplicating ~25 lines of createSafeTranslation 的无 provider 回退插值只认 {{name}} 一种写法,i18next 还认带空格 / 带格式化 / 嵌套三种 #3512's placeholderViolations is a maintenance observation, not a defect. Filed separately as a finding for triage (see the linked card) rather than kept open here.

    The larger derived problem is already its own card: #6219 (not-ready t renders {{holes}} literally on a provider-less host), unassigned.

    Labels: pm:queue stripped with this close; domain:devx stays. Assignee cleared (the yinlianghui-tw claim belonged to the R27 dispatch whose PR merged).


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repo

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions