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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions