Skip to content

fix(ui): answer a too-short password with the length message - #287

Merged
argszero merged 1 commit into
mainfrom
fix/forgot-password-length-message
Sep 22, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/forgot-password-length-message

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

ui/js/app.js's forgot-password handler answers a non-empty but too-short password with the app's emptiness message:

if (Array.from(pw).length < MIN_PW_CHARS) { setFieldError($("#forgot-pass"), T("register.err.pass")); … }

register.err.pass is 「请输入密码」/ "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 through I18n.mapErr, whose ERR_MAP entry 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-pass length guard now uses err.weakPassword, the same sentence the server sends for the same rule. No other call site changes: register.err.pass keeps 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_server
    • the rule's voice is derived from src/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 through ERR_MAP the way I18n.mapErr does — longest match wins);
    • every if (<cond>) setFieldError(<field>, T("<key>")) site in ui/js/app.js is 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;
    • tests: the axis test, a shape test (the two forms differ by exactly the key token), a readers/positive-control test (the readers must read something on a known-green tree and nothing on an empty one), a per-rule teeth test (each synthetic mutant flips exactly one rule), and a variant table declaring each competing fix's verdict.
    • The same file also carries a repair to the R96 teeth test: its m_drop_client variant 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 reads app.js inline sites only; the value on screen belongs to the jsdom probe).
  • src/i18n_pack.rs — T_LITERAL_DISTINCT 428 → 429 (err.weakPassword becomes a T("…") 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 — clean

  • cargo clippy --all-targets -- -D warnings — clean

  • Gate-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:

    leg declared red actual red
    pre-fix tree {the_forgot_password_length_speaks…} same
    fixed tree {} same
    live tree (post-landing) {} same
    fixed tree + the rule's wording moved in src/routes/mod.rs {the_forgot_password_length_speaks…, the_r93_readers_are_real, the_r93_rules_have_teeth} same

    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 --test and clippy-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.html with 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 — in zh and en separately.

  • 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, fix and every variant are written out by the instrument that derives them, and the driver asserts the live leg's bytes equal the fix leg's bytes before it reads either.

Checklist

  • 分支命名符合约定(fix/)
  • Commit message 使用 Conventional Commits 格式
  • 单一职责,改动最小化

@argszero

Copy link
Copy Markdown
Owner Author

Self-review

Scope. One wrong token at one call site, plus the rule that keeps it right.

  • ui/js/app.js — the #forgot-pass length guard now speaks err.weakPassword. register.err.pass keeps its single, correct use as the register form's emptiness message.
  • src/state_gate.rs — the_forgot_password_length_speaks_the_message_the_same_rule_gets_from_the_server, a gate that derives the expected voice from src/routes/mod.rs instead of restating today's key, plus three rules over every inline setFieldError site in app.js.
  • Same file carries a repair to the R96 teeth test: its m_drop_client mutant deleted the client guard by replacing a needle that contained that line's own message key, so rewriting the key turned the mutant into a no-op. The mutant now deletes the guard line itself, key-agnostically.
  • ui/README.md — records the convention and the gate's scope.
  • src/i18n_pack.rs — T_LITERAL_DISTINCT 428 → 429 (the pair was already in both packs; no new keys, no pack changes).
  • ui/index.html — cache-bust only.

Verification on the landed tree (356 passed; 0 failed, baseline 352):

leg declared red actual red
pre-fix tree (materialized by reverse-applying the sheet — never re-typed) {the_forgot_password_length_speaks…} same
fixed tree {} same
live tree after landing {} same
fixed tree + the wording moved in src/routes/mod.rs {…, the_r93_readers_are_real, the_r93_rules_have_teeth} same

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 rustc --test and clippy-driver -D warnings (the lint leg was added after a previous round's gap where a fragment compiled clean but failed clippy). A negative control disarms one rule with a one-line edit and asserts the red sets change exactly as declared (37/37).

jsdom probe — real ui/index.html, the four real scripts, a real session, real submits: 7 variants × 26 checks, all as declared. Each leg prints the app.js md5 it evaluated. Two competing fixes are rejected by the probe and one is rejected only by the gate; that asymmetry is stated in the PR body rather than hidden.

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.

@argszero
argszero merged commit 118eea5 into main Sep 22, 2026
1 check passed
@argszero
argszero deleted the fix/forgot-password-length-message branch September 22, 2026 11:20
@argszero argszero mentioned this pull request Sep 24, 2026
10 tasks
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