Skip to content

fix(i18n): stop filling the marketplace success-rate placeholder with a dash - #308

Merged
argszero merged 1 commit into
mainfrom
fix/marketplace-detail-rate-without-a-value
Sep 27, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/marketplace-detail-rate-without-a-value

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The marketplace row's expanding detail printed its availability line as

当前可用 · 成功率 —%      Available · success —%

mkDetailHtml(m) computed const succ = m.success == null ? "—" : m.success; and passed
succ into the {p} placeholder of mk.detail.availOn / mk.detail.availOff, whose messages
are 当前可用 · 成功率 {p}% / Available · success {p}%. The display sentinel — ("no such
value") was therefore rendered as if it were the number, together with the % that follows
the placeholder.

Both rendering paths reach that branch: D.MARKET (guest rows) has no success field at all,
and modelsToView() hard-codes success: null for live rows — so every row takes it.

The same condition already exists elsewhere in the same row (renderMarketplace guards its cell
with m.success != null), and the repository already contains the correct shape right next to the
defect: admin.org.stats.used only calls its {p} template when there is a value, and puts
the dash outside the template.

Related Issue

(none)

Changes

  • ui/js/app.js — mkDetailHtml picks between a placeholder-free message and a
    rate-carrying one on m.success == null; the sentinel never enters a placeholder.
  • ui/js/i18n.js — splits the two messages into a placeholder-free form
    (mk.detail.availOn / availOff) and a form that carries the rate
    (mk.detail.availOnRate / availOffRate), zh and en. Two new keys per pack.
  • src/i18n_pack.rs — new compile-time gate
    a_placeholder_is_never_filled_by_a_display_sentinel, plus its teeth test. The shared
    TCallSite scanner now also returns each interpolation's value source (obj_var_entries
    is the single pass; vars is derived from it, so "who was fed what" cannot drift from "who
    was fed"), and the positive-control key/T-literal counts move to the values the gates report
    (794 → 796 keys, 544 → 546 T(...) call sites).
  • ui/README.md — records the convention and the gate's scope.
  • ui/index.html — cache-bust for i18n.js and app.js.

The gate's predicate, and why it is narrow

The rule fires when a value bound to a placeholder is a string literal, or a bare identifier
that the enclosing function declares from a conditional/logical expression one of whose
top-level operands is itself a whole string literal
:

m.success == null ? "—" : m.success   // fires  — the dash is a whole operand
raw || T("common.unnamed")            // passes  — the fallback branch is a call
v === "" ? T("common.unassigned") : x // passes  — the literal is a comparison operand
typeof x.total === "number" && x.total// passes

A wider reading ("the initializer mentions a string literal somewhere") was measured against the
real corpus and reports 4–5 false positives; resolving identifiers file-wide instead of
per-function adds 5 more, because n / name / model / theme are reused across
ui/js/app.js. The narrow reading reports exactly the two defective call sites on the pre-fix
tree and zero on the fixed tree
, with 95 interpolation slots scanned.

Tests

  • cargo test — 409 passed / 0 failed (baseline 407; the two new tests are the gate and
    its teeth test)
  • cargo fmt --check — clean
  • cargo clippy --all-targets -- -D warnings — clean
  • New tests added:
    • a_placeholder_is_never_filled_by_a_display_sentinel — with a positive control on the number
      of interpolation slots actually scanned (95 measured, floor 90), so the gate cannot pass by
      silently reading nothing.
    • the_sentinel_rule_has_teeth — four shapes that must fire, four look-alikes that must pass,
      plus an end-to-end synthetic corpus (defect reports, clean version does not) and a
      cross-function tooth (a sentinel declared in another function must not leak in).

A/B evidence

Compiling the new gate against the pre-fix tree (4314851's ui/js/app.js +
ui/js/i18n.js, with the positive-control counts set back to their pre-fix values) fails
exactly one test — the new gate — with 408 passing. Restoring the fixed tree gives
409 / 0 (the two cache-bust strings in ui/index.html are bumped in the committed tree).

Checklist

  • Branch name follows the convention (fix/…)
  • Commit message uses Conventional Commits
  • Single responsibility, minimal change

Scope, stated honestly

The gate is lexical: it proves the value fed into a placeholder is not a display sentinel —
it does not prove the number rendered on screen is correct (that half belongs to a DOM
probe), and it does not see the competing "fix" of deleting the rate clause entirely. This is
recorded in ui/README.md next to the convention.

One pre-existing staleness is not touched here: the same README still says the packs hold
"806 键 ×2", which does not match the gate's own key count (now 796). Fixing a declared count is
its own change — it needs the authority (which collection the number counts) settled first — and
is left out of this PR deliberately.

… a dash

The marketplace expanding row rendered the availability line as
"可用 · 成功率 —%": mkDetailHtml() turned a missing m.success into the
display sentinel "—" and passed it into the {p} placeholder of
mk.detail.availOn/availOff, whose messages read "… 成功率 {p}%". The dash
was therefore printed as if it were the number, together with the unit.

The same condition (m.success == null) already exists elsewhere in the row
(renderMarketplace guards its cell with m.success != null), and the
repository already has the right shape next to it: render admin.org.stats.used
only calls the {p} template when there is a value.

The fix splits the message into a placeholder-free form (mk.detail.availOn /
availOff) and a form that carries the rate (mk.detail.availOnRate /
availOffRate), and picks between them on m.success == null — the sentinel
never enters a placeholder.

A new compile-time gate, a_placeholder_is_never_filled_by_a_display_sentinel
(gate module ui/js/app.js is already inside), rejects any T(key, {…}) call
site whose placeholder is fed by a string literal, or by an identifier the
enclosing function declares from a conditional whose top-level operand is
itself a string literal. The predicate is deliberately narrow — the literal
must be a whole operand — so the look-alike but legitimate forms
(raw || T("common.unnamed"), typeof x === "number",
v === "" ? T("common.unassigned") : …) keep passing; measured on the real
corpus they would otherwise produce 4-5 false positives, and whole-file name
resolution produces 5 more because n/name/model are reused all over app.js.
Scope: the gate is lexical — it proves the value fed to a placeholder is not
a display sentinel, not that the rendered number is correct (that half belongs
to a DOM probe).
@argszero
argszero merged commit 96df83a into main Sep 27, 2026
2 checks passed
@argszero
argszero deleted the fix/marketplace-detail-rate-without-a-value branch September 27, 2026 06:03
@argszero argszero mentioned this pull request Sep 30, 2026
12 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant