fix(i18n): name every written transaction type in the type prose, and gate it - #291
Merged
Merged
Conversation
… gate it
Three prose enumerations of the transaction types were hand-copied from
`TX_FILTER_TYPES` and never followed `expire` when gift expiry became a real
ledger row (C2050/C2051): the transactions page subtitle, the dashboard
"trades" subtitle and the wallet footnote all said the table carries
consume / earn / topup / gift / withdraw while the table itself printed an
"expired" row. The English values were worse than stale: they used derived
nouns ("Consumption, earnings, top-ups, withdrawals, gifts") rather than the
label the same screen uses for that type.
- name `expired` in all three keys, both packs, and switch the English values
to the `tx.type.*` label words so the prose and the table agree word for word;
- add `state_gate::the_transaction_type_prose_names_every_type_the_writers_write`
-- the roster of prose keys is derived (any pack value naming more than half
the written types must name all of them), the written set is derived from the
`type` column of every production `INSERT INTO transactions`, the accepted set
from `TX_FILTER_TYPES`, and the labels from the packs themselves. No type name
and no key name is written by hand. The sandwich written ⊆ accepted ⊆ labelled
catches both kinds of "one place missed" when a new type lands;
- declare the gate's three blind spots in `ui/README.md` rather than leaving them
implicit: the "more than half" threshold, the roster being derived (deleting a
commitment silences it -- that half belongs to the reachability gate and the
behaviour probe), and the requirement that write sites be literals.
The prose decision is "⊇ the written set": `withdraw` is accepted but has no
writer, so naming it is allowed and omitting it is fine; omitting a written type
is the defect. A teeth variant adds a `withdraw` writer and the prose goes red
the day that changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three hand-written prose enumerations of the transaction types each omitted
expire, and theEnglish ones also described the types with derived nouns instead of the labels the same screen
already uses for them:
view.transactions.subConsumption, earnings, top-ups, withdrawals, giftsdash.trades.subtop-up / consume / earn / giftwallet.hint.suffix— consumption, earnings, top-ups, withdrawals, giftsThe fact they are all copies of lives in two places the app already owns:
src/routes/wallet.rs:27pub const TX_FILTER_TYPES: [&str; 6] = ["consume", "earn", "topup", "gift", "expire", "withdraw"]INSERT INTO transactionsstatements(
billing.rs×2,gift.rs×2,routes/{admin,ops,raise}.rs×1 each) which put exactly{consume, earn, expire, gift, topup}into thetypecolumn.withdrawhas no writer.So an
expirerow is a real ledger line — gift expiry really debits the balance (that is theC2050/C2051 change,
gift.rs::expire_past_gifts), and it renders in the transactions table's typecolumn and in the CSV export — while three places on screen said such a row does not exist. One fact,
three carriers, the same item dropped in all three.
Provenance is drift, not a trade-off: the three strings were written before
expirebecame anemitted type, and nothing tied the enumeration back to the set it enumerates. The English ones had a
second drift of their own —
Consumption/earningsare not thetx.type.consume/tx.type.earnlabels the very same table column prints, so the prose and the column named one type two ways.
Related Issue
None — this came out of an internal audit of the transaction-type enumerations, not from an open
issue. The linked-issue field is expected to stay empty by design; the PR is based on the default
branch (
main).Changes
ui/js/i18n.js— six values (view.transactions.sub,dash.trades.sub,wallet.hint.suffix×zh/en): name
expire, and use the packs' owntx.type.<t>label vocabulary in English(
Consume, earn, top-up, withdraw, gift, expired) instead of derived nouns. Zero new i18n keys,zero key removals (both packs stay at 788 keys).
ui/index.html— the two staticdata-i18nfallbacks (:414wallet.hint.suffix,:424view.transactions.sub) follow the packs, plus thejs/i18n.js?v=cache-bust.ui/README.md— the page-list entry for the transactions view, plus a contract line for the new gate(including its honest scope, below).
src/state_gate.rs— new lanethe_transaction_type_prose_names_every_type_the_writers_write,entirely test-only.
The gate
Every expected value is derived from the artifacts — there is no hand-written type list:
scanned, ≥2 production write sites, ≥2 written types, ≥2 roster keys, and both packs' key sets
equal with ≥100 keys each.
(union over both packs); every roster key × every language must name all named written types.
The ruler is that pack's own
tx.type.<t>value (case-insensitive literal substring) — soConsumptiondoes not count as namingconsume, andexpirycannot stand in forExpired.tx.type.<t>must be non-empty in bothpacks (a label on one side only leaves that side's prose nothing to name).
written ⊆ accepted ⊆ labeled. The written set comes from the literal at thetypecolumn of each productionINSERT INTO transactions— column-aware, so an unrelatedliteral (
'成功','m') is never mistaken for a type; the accepted set comes fromTX_FILTER_TYPES, the array whose own docs call itself the single source for adding a type. Thiscatches both ways a new type gets missed: written-but-not-accepted (unfilterable in production) and
accepted-but-unlabeled (the table prints a bare key).
Companion tests: an extractor self-test on synthetic input (an anchor inside a full-line comment, one
inside the
#[cfg(test)]region, and a non-type-column literal must all be excluded, and thecorpus-boundary predicate must keep
*_gate.rs/i18n_pack.rsout of the corpus while keeping realsources in); a
#[should_panic]test that a writer binding thetypecolumn to a parameter (?1) isloud rather than silently skipped; and a teeth test where each rule has a variant that flips only
that rule.
Scope, stated honestly (also in
ui/README.md): the gate is lexical — it proves every rosterprose names the five types, not that the screen shows that sentence (that half is the jsdom probe).
Three declared blind spots: (①) "more than half" is a declaration, so a key naming ≤ half stays out
of the roster —
tx.summary.net.sub("收益 − 消费") is exactly that, it names a difference not anenumeration; (②) the roster is derived, so deleting an enumeration or its
[data-i18n]binding doesnot turn it red — that is making a promise disappear, and it is covered by the probe and by
i18n_pack::every_pack_key_reaches_a_consumer; (③) a writer must use a literal — a futureparameter-bound writer turns the gate red on the spot rather than being silently skipped.
Scope decision: "⊇ the written set", not "all six accepted values"
withdrawis accepted and has a selector option, but no writer — no withdrawal row can appear inthe transactions table. Listing it in prose would assert something that cannot happen, while missing
expireasserted something that did. So the gate requires ⊇{consume, earn, expire, gift, topup},and
dash.trades.sub(which names five and omits 提现) is left as it is: it omits nothing that can bewritten. The gate is forward-looking: the teeth test includes a variant that adds a
withdrawwriter and shows the prose then turns red, so the day withdrawal ships, the enumeration must follow.
Two repairs made while landing this
The lane's two self-proof tests had never been executed — earlier rounds type-checked the fragment
through
rustcand ranclippyover it, and neither reads a test's expectation at run time. Runningcargo testfor the first time exposed two defects, both confined to the self-proof layer (no rulechanged, so the gate's semantics — and therefore anything derived from it — are unaffected):
(
"{lang}{key}"instead of `` "{lang}{key}`" ``, the form the same file uses for its other twored-set declarations), so the assertion compared a correct measurement against a malformed
expectation;
#[cfg(test)](truncated away — itlooked like the extractor missing a write site when the fixture was simply not shaped like real Rust),
and asserted the corpus boundary (
*_gate.rs) throughr99_written, which does not do theexcluding — that lives in the disk walk. The boundary claim is now asserted against a named
predicate,
r99_is_corpus_file, which the walk itself calls, with both directions covered (realsources in,
state_gate.rs/body_limit_gate.rs/i18n_pack.rsout).Tests
cargo test— 373 passed / 0 failed (baseline 369; the lane adds four)cargo fmt --check— cleancargo clippy --all-targets -- -D warnings— no new diagnostics from this change. On thismachine the pinned toolchain (
1.95.0, the one with a workingrustc) reportsclippy::collapsible_matchatsrc/protocol.rs:662, which is pre-existing: a pristinegit archive HEADtree reports the same single error,protocol.rsis untouched by this PR, andCI on
mainat the parent commit is green under CI'sstabletoolchain (which is the commandci.ymlactually spells).* compile gate A/B — the landed tree must be green first, then six variants whose red sets
are asserted against the declared ones (pre-fix = all six
(lang, key); half-fixed = the threezh ones; new writer =
dash.trades.subboth packs; unmatched writer = R4 only; label droppedfrom both packs = R4 only; one-sided empty label = R3 + R4), plus the two declared blind spots
asserted silent;
* jsdom probe — 5 variants × 12 legs, all
as declared:landed12/12 green;basered onexactly the six
A1/A2/A3 × zh/en;m_halfexactly the three zh;m_en_derived(derivednouns +
expiredappended — the near miss that adds the missing type but not the missingword) red on
A1_en/A3_en;m_html_stalegreen by design. The probederives the written, accepted and roster sets from the artifacts and reads the two
[data-i18n]subtitles from the real DOM under a real boot, in each language's own window.* The asymmetry is deliberate and stated in
ui/README.md: the gate refuses the vocabulary-driftfix that the screen would accept, while the probe's
landedleg is what proves the shippedbytes; neither instrument is sufficient alone.
ui/index.htmlstatic fallbacks are not covered byeither instrument — the probe boots the app and
applyStatic()replaces the fallback text, sothe DOM never shows a stale value; and
i18n_pack::every_static_i18n_attribute_resolveschecksthat the attribute's key resolves, not that the fallback text matches the pack. They were
updated for consistency only; that drift axis is a known, separately decided one.
Checklist
fix/…)change is test-only)
config or schema change)