Skip to content

[finding] The interpolation-parity gate cannot see an unfilled {{count}} hole: count is reserved in BOTH directions, not just the one that needs it #4206

Description

@yinlianghui

Measured while fixing objectui#4157 (PR #4205) — not in that PR's scope, since scripts/check-i18n-call-site-keys.mjs belongs to the objectui#3845 gate family. Observation-class: once #4205 lands, nothing a user hits today depends on this (measured below).

The blind spot

check:i18n-call-site-keys runs an interpolation-parity check that is explicitly bidirectional — inert (an option passed that the en value has no hole for) and unfilled (a hole the call site passes nothing for). That is exactly the shape of #4157: t('gantt.conflict.body') passed no options while the en value read …Auto-reschedule {{count}} affected task(s)?.

The gate was green on it. Mechanism, scripts/check-i18n-call-site-keys.mjs:325:

export const RESERVED_OPTION_NAMES = new Set([
  // plural / context selection — and `count` is also its own `{{count}}` hole
  'count',

and at the comparison (:1101):

const holes = new Set(
  [...holesOf(enValue)].filter((hole) => !RESERVED_OPTION_NAMES.has(hole) && !downstream.has(hole)),
);
const inert = [...options.names].filter((option) => !holes.has(option)).sort();
const unfilled = [...holes].filter((hole) => !options.names.has(hole)).sort();

The reservation is applied to holes before both directions are computed, so a {{count}} hole is removed from the comparison entirely and unfilled can never contain count.

The reservation is correct for the direction it was written for. count is i18next's plural selector, so passing it is legitimate even when the resolved en form shows no visible {{count}} — suppressing that inert report is right. What is over-broad is that the same filter also suppresses the opposite direction, where the en value does carry the hole and the call site fills nothing.

Measurement

Re-ran the gate's own analyze(root) with count deleted from RESERVED_OPTION_NAMES, against two trees:

tree with count reserved without newly revealed
origin/main @ b1e42d09b 0 1 1
#4205's fix applied 0 0 0

The one finding revealed at main is #4157 itself, exactly:

packages/plugin-gantt/src/GanttView.tsx:4984  gantt.conflict.body
    unfilled=[count] inert=[]
    en: "This move conflicts with dependency constraints. Auto-reschedule {{count}} affected task(s)?"

So the narrower reservation would have caught #4157 at authoring time, and across all 2277 judged call sites it produces zero other reports — no false-positive cost to absorb, and no second live defect of this class hiding behind the reservation today. That is what makes this observation-class rather than a queued defect: the blind spot is real and the class recurs, but after #4205 there is nothing currently unfilled.

Option

Apply the reservation per direction rather than to the shared holes set: keep count filtered out when computing inert, and leave it in when computing unfilled. Plural families are already unjudged upstream (enValue === undefined — "including the plural families, where there is no one form to compare against"), so the case the reservation exists to protect does not reach this comparison in the first place.

Worth noting what this would and would not have bought. It catches the general class — an en value with a hole and a call site that fills nothing. It does not, on its own, catch the narrower thing #4157 actually was: a call site that fills the hole downstream, with a literal .replace() in a different brace syntax than the pack uses. A t(key).replace('{count}', n) chain still reads to this gate as a call site passing no options; it is only visible because the pack spells the hole {{count}} while the replace targets {count}. Whether the gate should also learn to read the .replace() chain is a larger question and adjacent to objectui#4135's EXTERNALLY_INTERPOLATED_HOLES registry, which is the same family from the other side (a hole i18next must deliberately NOT fill).

No recommendation on timing attached — the change is inside the #3845 gate's own contract, so it belongs to whoever owns that gate's next pass.

Related: objectui#3845 (the gate and its bidirectional parity check), objectui#4135 (same family — downstream-filled holes and their two spellings), objectui#4157 / #4205 (the defect that measured this).


Generated by Claude Code

Activity

  1. claude commented on Aug 11, 2026

    @claude
    Contributor

    Findings triage (first grade; premise check @ origin/main e1ade8f): PROMOTED — finding removed, pm:queue applied.

    This is the one card in this round's set where nothing is left to rule, and its stated precondition has now landed.

    Anchors verified live (2-line drift from the body, substance exact): RESERVED_OPTION_NAMES at scripts/check-i18n-call-site-keys.mjs:325, and the shared-holes filter the card indicts at :1103 (body says :1101):

    [...holesOf(enValue)].filter((hole) => !RESERVED_OPTION_NAMES.has(hole) && !downstream.has(hole)),

    The reservation is applied to holes before both directions are derived, so unfilled structurally cannot contain count — exactly as filed. The set is also consulted at :821 and :839 on the options side, which is the direction the reservation is genuinely for.

    Why promote rather than hold. It is a scoped gate fix with a named landing site, a single option, and a measurement that already answers the only question a dev would have to ask: with count un-reserved on the unfilled direction, the tree yields 1 report at the old main (#4157 itself) and 0 with the fix applied, across 2277 judged call sites. That is the false-positive cost, measured, and it is zero. The plural family the reservation protects never reaches this comparison (enValue === undefined upstream), so the narrowing cannot regress it.

    The staged precondition has fired. PR #4205 merged 2026-08-10T23:45:43Z, and packages/plugin-gantt/src/GanttView.tsx:5058 now reads t('gantt.conflict.body', { count: pendingConflict.length }) — so the one call site that would have gone red under the narrower reservation is already fixed on main. The gate can be tightened without a red tree. Had #4205 still been open, this would have stayed held.

    Scope for whoever takes it — deliberately narrow: apply the reservation per direction (keep count filtered when computing inert, leave it in when computing unfilled); do not extend the gate to read .replace() chains. That larger question is adjacent to ui#4135's EXTERNALLY_INTERPOLATED_HOLES registry and is its own card; the card body says so and this promotion does not widen it.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. self-assigned this
    on Aug 15, 2026
  3. yinlianghui commented on Aug 15, 2026

    @yinlianghui
    CollaboratorAuthor

    Claiming — PM dispatch (unblocked: PR #4714 merged, the hot-file serial hold on check-i18n-call-site-keys.mjs is lifted). Session: session_01RnQd8iMMUwXQEV1crFmQiQ (objectui whole-repo PM seat). Branch: claude/issue-4206-count-reservation-per-direction.

    Scope per the 2026-08-11 triage promotion, deliberately narrow: apply the count reservation per direction — keep it filtered when computing inert, leave it in when computing unfilled — in scripts/check-i18n-call-site-keys.mjs (+ self-test). ⛔ Do NOT extend the gate to read .replace() chains (its own card territory). The promotion's measurement stands as acceptance basis: 1 revealed finding at old main (already fixed by PR #4205), 0 on current trees, across 2277 judged call sites. Note the file changed since the card: PR #4714 retired the EXTERNALLY_INTERPOLATED_HOLES entry — re-derive line anchors on current main. PR will be Fixes #4206.


    Generated by Claude Code

  4. yinlianghui commented on Aug 15, 2026

    @yinlianghui
    CollaboratorAuthor

    <!-- os-dev-report -->

    {
      "issue": 4206,
      "status": "done",
      "branch": "claude/issue-4206-count-reservation-per-direction",
      "pr": "https://github.com/objectstack-ai/objectui/pull/4719",
      "premise_still_valid": true,
      "summary": "Applied the RESERVED_OPTION_NAMES reservation per direction in scripts/check-i18n-call-site-keys.mjs, matching the 2026-08-11 triage promotion. interpolationOptions() now returns a call site's option names raw (reserved names included); the comparison filters count/ns/lng/etc. out of the option-name set only when judging inert, and keeps them when judging unfilled, so a real {{count}} miss (the objectui#4157 shape) is caught while a call site that actually passes count is still correctly recognised as filling it. First-draft attempt (filtering only the shared holes set, per the dispatch's literal mechanism-assumption-1 wording) regressed: it made every count-passing call site read as unfilled=[count], caught by main's own real corpus in the self-test (65 tests, 3 failed) before push — corrected to filter the NAMES side instead, which is the only shape that keeps both directions correct simultaneously.",
      "tests": "At HEAD 5da80f105: `pnpm exec vitest run scripts/__tests__/check-i18n-call-site-keys.test.ts --maxWorkers=2` -> 65/65 passed (added two direction-specific cases: count passed against a holed key stays silent; count omitted against the same key now reports unfilled=[count]). `node scripts/check-i18n-call-site-keys.mjs` (whole-repo gate) -> 0 interpolation-parity findings across 2383 judged call sites (the card measured 2277 on 2026-08-10/11; corpus grew since). Positive control: uncommitted strip of `{ count: pendingConflict.length }` from packages/plugin-gantt/src/GanttView.tsx:5058 made the gate report `unfilled (hole, no argument): count` on that exact site; reverted via `git checkout --`, gate back to 0 findings. `pnpm exec eslint scripts/__tests__/check-i18n-call-site-keys.test.ts scripts/check-i18n-call-site-keys.mjs` -> clean. `pnpm run type-check:scripts` -> clean. `node scripts/check-control-bytes.mjs` -> OK. `node scripts/check-changeset-presence.mjs` -> no changeset owed (scripts-only, not under a released package's src/); PR labeled skip-changeset, labels read back as [\"tests\",\"skip-changeset\"].",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  5. yinlianghui commented on Aug 15, 2026

    @yinlianghui
    CollaboratorAuthor
    {
      "issue": 4206,
      "status": "done",
      "branch": "claude/issue-4206-count-reservation-per-direction",
      "pr": "https://github.com/objectstack-ai/objectui/pull/4719",
      "premise_still_valid": true,
      "summary": "Applied the RESERVED_OPTION_NAMES reservation per direction in scripts/check-i18n-call-site-keys.mjs, matching the 2026-08-11 triage promotion. interpolationOptions() now returns a call site's option names raw (reserved names included); the comparison filters count/ns/lng/etc. out of the option-name set only when judging inert, and keeps them when judging unfilled, so a real {{count}} miss (the objectui#4157 shape) is caught while a call site that actually passes count is still correctly recognised as filling it. First-draft attempt (filtering only the shared holes set, per the dispatch's literal mechanism-assumption-1 wording) regressed: it made every count-passing call site read as unfilled=[count], caught by main's own real corpus in the self-test (65 tests, 3 failed) before push — corrected to filter the NAMES side instead, which is the only shape that keeps both directions correct simultaneously.",
      "tests": "At HEAD 5da80f105: `pnpm exec vitest run scripts/__tests__/check-i18n-call-site-keys.test.ts --maxWorkers=2` -> 65/65 passed (added two direction-specific cases: count passed against a holed key stays silent; count omitted against the same key now reports unfilled=[count]). `node scripts/check-i18n-call-site-keys.mjs` (whole-repo gate) -> 0 interpolation-parity findings across 2383 judged call sites (the card measured 2277 on 2026-08-10/11; corpus grew since). Positive control: uncommitted strip of `{ count: pendingConflict.length }` from packages/plugin-gantt/src/GanttView.tsx:5058 made the gate report `unfilled (hole, no argument): count` on that exact site; reverted via `git checkout --`, gate back to 0 findings. `pnpm exec eslint scripts/__tests__/check-i18n-call-site-keys.test.ts scripts/check-i18n-call-site-keys.mjs` -> clean. `pnpm run type-check:scripts` -> clean. `node scripts/check-control-bytes.mjs` -> OK. `node scripts/check-changeset-presence.mjs` -> no changeset owed (scripts-only, not under a released package's src/); PR labeled skip-changeset, labels read back as [\"tests\",\"skip-changeset\"].",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    (Supersedes the previous report comment on this issue, which mis-escaped the marker as literal &lt;!-- entities instead of the real characters.)


    Generated by Claude Code

  6. added a commit that references this issue on Aug 17, 2026
    966235e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions