fix(ui): answer a too-short password with the length message - #287
Conversation
Self-reviewScope. One wrong token at one call site, plus the rule that keeps it right.
Verification on the landed tree (
That last leg is the "derived, not a snapshot" claim: the gate's expectation moves with the source, and the two self-checking tests refuse to measure on a degenerate derivation. Each leg runs under jsdom probe — real Honest scope. The gate is lexical: it proves which key each site names, not what the user sees. The pixels are the probe's half. And a same-wording dedicated key would satisfy the probe while the gate rejects it — deliberately one notch stricter, because one rule spoken by two keys can drift. |
Summary
ui/js/app.js's forgot-password handler answers a non-empty but too-short password with the app's emptiness message:register.err.passis 「请输入密码」/ "Please enter a password" (i18n.js:77/:903) — the message for "you left this blank". The sister register form uses it that way and only that way (app.js:3770,if (!pw) { … }). The forgot branch, though, fires when the field is visibly full: it puts "Please enter a password" under a field that contains a password. The app already has the right sentence for that condition —err.weakPassword(「密码至少 8 位」/ "Password must be at least 8 characters"), which is exactly what the register form renders when the server rejects the same too-short input (the 400 goes throughI18n.mapErr, whoseERR_MAPentry is derived from the backend literal).So this is a condition ↔ message mismatch, not a missing sentence: the length rule must speak the length voice, and one key must not serve two conditions. The change is one token at the call site.
Related Issue
(no linked issue — found by an audit of the inline field-error convention)
Changes
ui/js/app.js— the#forgot-passlength guard now useserr.weakPassword, the same sentence the server sends for the same rule. No other call site changes:register.err.passkeeps its single, correct use as the emptiness message.src/state_gate.rs— a rule that derives the contract instead of restating today's key:the_forgot_password_length_speaks_the_message_the_same_rule_gets_from_the_serversrc/routes/mod.rs(find the helper whose body bounds a character count against a constant, collect the"error"literal of every branch that enforces it, resolve it throughERR_MAPthe wayI18n.mapErrdoes — longest match wins);if (<cond>) setFieldError(<field>, T("<key>"))site inui/js/app.jsis classified emptiness-vs-other and checked against three rules: at least one site bounds a length; every length-bounding site uses the derived key; no key serves both classes;m_drop_clientvariant deleted the client guard by replacing a needle that contained that line's own message key. Rewriting the key turns the replacement into a no-op — a variant that silently stops mutating anything — so the variant now deletes the guard line, key-agnostically.ui/README.md— the inline-field-error convention gains the rule it was missing: a field message must describe the condition its own guard tests, the same rule must use the same sentence on the client and on the server, and a new same-wording key must not be invented. The scope of the gate is recorded next to it (it readsapp.jsinline sites only; the value on screen belongs to the jsdom probe).src/i18n_pack.rs—T_LITERAL_DISTINCT428 → 429 (err.weakPasswordbecomes aT("…")literal for the first time; the pair itself was already in both packs, so no pack changes and no new keys).ui/index.html— cache-bust token?v=20260922-9→?v=20260922-10(app.js changed).Tests
cargo test— 356 passed / 0 failed (baseline 352)cargo fmt --check— cleancargo clippy --all-targets -- -D warnings— cleanGate-level A/B (37 declared legs, both directions). The pre-fix tree is materialized by reverse-applying the sheet's own edit — never re-typed — because after landing the live file is the fix:
{the_forgot_password_length_speaks…}{}{}src/routes/mod.rs{the_forgot_password_length_speaks…, the_r93_readers_are_real, the_r93_rules_have_teeth}The last leg is the "derived, not a snapshot" statement: the gate's expectation moves with the source, and the two self-checking tests refuse to measure on a degenerate derivation. Every leg compiles and runs under
rustc --testandclippy-driver -D warnings. A negative control disarms one rule with a one-line edit and asserts the red sets change exactly as declared (37/37): the pre-fix tree stays red (it violates two rules, so disarming one cannot green it) and on the fixed tree exactly the two tests that measure the rules go red.jsdom probe — the real
ui/index.htmlwith the four real scripts, a real session, real submits: 7 variants × 26 checks, all as declared. The reference is derived, not hardcoded: what the app shows for an empty password (sister form, client-side) versus what it shows for a too-short one (sister form, where the server answers and the answer is translated). Before the fix the forgot form's message equals the emptiness message and differs from the rule's own sentence; after it, they match — inzhandenseparately.Two competing fixes are ruled out by the probe and not by opinion: rewriting the value of the emptiness key so it also reads like the length message (the app stops distinguishing the two conditions, and the field-level message stays wrong) and deleting the client-side check so the server answers (the length guard disappears; the field error, its focus and its no-toast behaviour go with it). A third — adding a dedicated key carrying the same wording — is accepted by the probe and rejected by the gate, which is recorded honestly: one rule spoken by two keys can drift, and the gate is deliberately one notch stricter than the pixels.
The probe is pointed at files, not at the live tree:
base,fixand every variant are written out by the instrument that derives them, and the driver asserts theliveleg's bytes equal thefixleg's bytes before it reads either.Checklist
fix/)