From 08d34df66573aff6745ad402d794f896ac90595e Mon Sep 17 00:00:00 2001 From: argszero Date: Fri, 25 Sep 2026 06:45:54 +0800 Subject: [PATCH] fix(ui): land a refused view switch on the identity's own home view `switchView()` refused a view the current identity may not enter with a bare `return`, and `enterApp()` ignored the outcome. That is right while a view is on screen (the Back-button case) but wrong at session establishment: C2136 made boot render no view at all and C2137 removed the boot's unconditional `renderView("dashboard")`, so at that moment nothing has been rendered and the DOM is still the static skeleton. `enterApp()` takes its destination from the URL hash, so a hash naming a gated view (`#/admin` for a non-admin, `#/ops` for everyone -- no production code ever writes role 'ops') left the user on a dashboard that was never rendered: empty cards, no request afterwards, one toast, and no self-heal, because `enterApp()` runs only once. Reachable by ordinary use -- `exitGuest()` does not reset `location.hash`, so logging out from `#/admin` and back in as a lower-role account reproduces it. Report the outcome instead of swallowing it: every refusal returns `false` and the success path ends with `return true;`. `enterApp()` falls back to `homeView()` when the destination was refused, and the fallback target is derived from the identity (a guest's home is the marketplace, not the dashboard). The refusal keeps toasting, so the user is still told why. Gate `state_gate::the_refused_view_switch_never_leaves_an_unrendered_view` (four rules; the switcher, the caller and the fallback are all derived from the code, so no function name is pinned), plus a roster test with the two-halves defect as a control and four single-rule mutation legs. `ui/README.md` states the contract and the gate's scope. The R173 gate's "every guard precedes the keyboard clear" rule is tightened to exempt only the return that is the function's last statement -- with a new mutation leg proving the exemption did not hollow the rule out. Cache-bust bumped for `ui/js/app.js`. --- src/state_gate.rs | 532 +++++++++++++++++++++++++++++++++++++++++++++- ui/README.md | 37 ++++ ui/index.html | 2 +- ui/js/app.js | 23 +- 4 files changed, 587 insertions(+), 7 deletions(-) diff --git a/src/state_gate.rs b/src/state_gate.rs index 46d796a..6a5bad4 100644 --- a/src/state_gate.rs +++ b/src/state_gate.rs @@ -15306,6 +15306,9 @@ fn r171_variant_drop_slots(app: &str) -> String { // R2 边界清除:`switchView` 必须调用**键盘清除器**,且位置在它**每一个** `return` 守卫 // **之后**(被拒绝的切换不得清除)。清除器从代码推导:唯一那个「把状态变量复位成空字面量、 // 移除 `row-active`、且**不**添加 `row-active`」的函数。 +// ⚠️ 「守卫」是字面意思:R163 之后成功路径也 `return true;`(函数的最后一条语句), +// 它落在清除之后是**对的** —— 判据只豁免**最后一条语句上的那个** `return` +// (见 `r173_clear_after_guards` 的注释;末尾之后的任何 `return` 仍是守卫,仍要判红)。 // R3 反向:唯一的高亮**装填者**(`add("row-active")` 的归属函数)必须仍在、必须与清除器不同、 // 必须仍被调用 —— 否则「干脆什么都不武装」的树也能过 R1/R2(控制腿 `C1` 的静态对应物)。 // @@ -15562,10 +15565,34 @@ fn r173_enter_guards_highlight(body: &str, row: &str, click_at: usize) -> bool { fn r173_clear_after_guards(sv: &str, clearer: &str) -> (usize, bool) { let calls = r173_keyword_positions(sv, clearer); let returns = r173_keyword_positions(sv, "return"); - let after_guards = calls.len() == 1 && returns.iter().all(|&r| r < calls[0]); + // 「守卫」= 除**函数体最后一条语句**上的那个 `return` 之外的全部 `return`。 + // + // ⚠️ 原实现要求**每一个** `return` 都在清除调用之前(`returns.iter().all(|&r| r < calls[0])`)。 + // 那在「函数只在守卫上提前返回」的旧形状下与声明等价,但 R163 让成功路径也有了返回值 + // (`return true;`,函数的最后一条语句)—— 它**天然落在**清除调用之后 ⇒ 一条被拒绝了清除 + // 语义完全正确的树会被判红。实测(R163 落地轮):本轴的轴测试读数 `r2=false`, + // `armer=Some("kbdSet") clearer=Some("kbdClear") clear_calls=1`,而 `kbdClear()` 一行未动。 + // 判据因此按**声明原话**收紧(「每一个 `return` **守卫**」):末尾的成功返回不是守卫。 + let guards = match returns.last().copied() { + Some(last) if r173_is_last_statement(sv, last) => &returns[..returns.len() - 1], + _ => &returns[..], + }; + let after_guards = calls.len() == 1 && guards.iter().all(|&r| r < calls[0]); (calls.len(), after_guards) } +/// `at` 处的 `return` 是不是函数体**最后一条语句** —— 它自己那个 `;` 之后除空白再无 `;`。 +/// +/// 修前的形状(`switchView` 只在守卫上 `return;`)判 false(末尾那个 `return;` 之后还有 +/// `activeView = id; …` 等语句),于是全部 `return` 都算守卫,行为与旧实现逐字相同; +/// R163 之后末尾是 `return true;` ⇒ 判 true ⇒ 只豁免那一个。两条形状都成立,互不耦合。 +fn r173_is_last_statement(sv: &str, at: usize) -> bool { + match sv[at..].find(';') { + Some(semi) => !sv[at + semi + 1..].contains(';'), + None => false, + } +} + // ── 判词 ──────────────────────────────────────────────────────────────────────────────── /// 一次读取的全部证据与三条判词。 @@ -15754,6 +15781,17 @@ fn the_r173_rules_have_teeth() { "删掉唯一装填点只应翻掉 R3:{}", rd.report() ); + + // ④ R2 的牙(R163 起豁免「末尾那个成功返回」,须证明这不是把规则改空): + // 在清除调用**之后**再放一个守卫 `return` ⇒ 只翻 R2。 + let v = r173_variant_guard_after_clear(APP_JS); + let rd = r173_read(&v); + assert_eq!( + rd.verdicts(), + (true, false, true), + "把守卫搬到清除之后只应翻掉 R2:{}", + rd.report() + ); } /// 变异体①:删掉 `kbdEnter` 里的高亮检验(原缺陷的那一半)。 @@ -15781,3 +15819,495 @@ fn r173_variant_drop_armer(app: &str) -> String { fn r173_variant_defect(app: &str) -> String { r173_variant_drop_clear(&r173_variant_drop_highlight_test(app)) } + +/// 变异体④:把一个守卫的 `return` 搬到清除调用**之后** —— R2 的那条牙(R163 之后仍有牙)。 +fn r173_variant_guard_after_clear(app: &str) -> String { + let line = " $(\"#main\").scrollTop = 0;\n"; + assert_eq!(app.matches(line).count(), 1, "插入锚点不唯一"); + app.replace( + line, + " $(\"#main\").scrollTop = 0;\n if (!opts) return false; // mutated: a guard after the clear\n", + ) +} + +// --------------------------------------------------------------------------------------- +// R163 · 被拒绝的视图切换不得把用户留在「没人渲染过」的界面上 +// (`switchView` 报告结果 + `enterApp` 兜底 + 兜底目标按身份取) +// --------------------------------------------------------------------------------------- +// +// `switchView(id, opts)` 是「换视图」的唯一入口:先过身份守卫(游客只进市场;管理/运营视图 +// 只对相应角色开放),守卫拒绝时 toast 一句然后**直接返回**。屏幕上有东西时这是对的(用户 +// 点了一个进不去的入口,留在原地看着提示);**会话建立那一刻**不一样 —— +// +// * C2136 之后 boot 只装外壳(导航 / 事件 / 余额占位),**不渲染任何视图**; +// * C2137 删掉了 boot 里那句无条件 `renderView("dashboard")`; +// * `enterApp()` 的目的地取自 URL hash(刷新 / 换账号都带上一次的 hash)。 +// +// ⇒ 一个指向「当前身份进不去」的 hash(非管理员的 `#/admin`、**所有人**的 `#/ops` —— 后者 +// 因为 `ops` 角色没有生产者而恒真)会让用户**用一个 toast 换来一块从没人渲染过的骨架屏**: +// `#dash-stats`/`#dash-trend`/`#dash-month-changes`/`#dash-sharings` 全空、此后**零请求**、 +// 整会话不自愈(`enterApp` 只在会话建立那一刻跑一次)。 +// +// 规则(每条都从代码**派生**,不写死函数名 —— 切换器与兜底函数的名字由「谁以 hash 解析器为 +// 实参被调用」这条链读出,#469): +// R1 报告结果:切换器的**每一个拒绝**(`toast(...)` 之后紧跟的那个 `return`)必须返回 +// `false`,且成功路径以 `return true;` 收尾 —— 否则调用方**无法**知道切换有没有发生。 +// (只数 `return false;` 的个数不够:漏掉一个拒绝,其余仍在,读数照样绿 —— 必须逐对配对。) +// R2 调用方消费它:把 hash 解析结果交给切换器的那**唯一**一条语句必须带兜底分支 +// (`if (!(…)) (())`),且该语句里恰有两次切换器调用。 +// R3 兜底目标按身份取:兜底函数的分支判据必须读**身份**(`isGuest`),不能是无条件的常量 —— +// 游客的家不是仪表盘(它在 `GUEST_VIEWS` 之外)。 +// R4 反向:切换器**不得**在自己的体内再调用自己 —— 「拒绝就重定向到仪表盘」会重入同一批 +// 守卫,游客下直接递归(探针 `E1`/`E3` 实测 `RangeError: Maximum call stack size exceeded`)。 +// +// 射程(如实):本门禁是**静态词法**的 —— 它证「拒绝被报告」「调用方兜底」「兜底按身份取」 +// 三件**形状**;**不证**屏幕上真的渲染出了内容(那需要真 DOM),也不证 `#/ops` 对所有人恒真的 +// 前提(`ops` 角色的可达性是另一条在册轴,方向待裁定)。浏览器事实与症状归 jsdom 探针 +// `r163_probe.js` —— 两台仪器各自能看见对方看不见的东西(C2148/R168/R173 同款分工)。 + +// ── 扫描器 ────────────────────────────────────────────────────────────────────────────── + +/// 文件里所有 `function NAME(` 的名字(取自**代码文本** ⇒ 注释里的 `function` 不算边界)。 +fn r163_fn_names(src: &str) -> Vec { + let mut out: Vec = Vec::new(); + for line in code_text_by_line(src) { + if let Some(n) = function_name(&line) { + if !out.iter().any(|x| x == n) { + out.push(n.to_string()); + } + } + } + out +} + +/// 一个函数的**代码文本**(注释已剥离)—— 同时支持**单行**与**多行**两种声明形状。 +/// +/// [`code_body`] 只支持多行(收尾行恰为 ` }`):`homeView()` 是单行函数(修法把整条声明写在 +/// 一行里),用它会一路吞到文件末尾的某个 ` }` ⇒ 判据从「这个函数分支于身份」退化成 +/// 「文件里某处出现过 `isGuest`」,任何树都能过(#605 家族的假绿)。 +fn r163_fn_code(src: &str, name: &str) -> Option { + let text = code_text_by_line(src); + let mut started = false; + let mut out: Vec = Vec::new(); + for (i, raw) in src.lines().enumerate() { + let code = text.get(i).cloned().unwrap_or_default(); + if !started { + if function_name(&code) != Some(name) { + continue; + } + started = true; + let trimmed = code.trim_end(); + if !code.is_empty() { + out.push(code.clone()); + } + // 单行声明:整条语句都在这一行上(收尾的 `}` 也在)。 + if trimmed.ends_with('}') { + return Some(out.join("\n")); + } + continue; + } + // 多行声明按本文件的缩进约定收尾(与 `code_body`/`js_function_body` 同款: + // 恰为两空格的 ` }`;体内 4 空格缩进的 ` }` 是块闭合,不是函数收尾)。 + if raw == " }" { + break; + } + if !code.is_empty() { + out.push(code); + } + } + if started && !out.is_empty() { + Some(out.join("\n")) + } else { + None + } +} + +/// 把一段 JS 体按 `;` 切成语句(压平空白,便于「toast 与它的 return」逐对配对)。 +/// +/// **不做词法分析**:射程内那几个函数体里没有含 `;` 的字符串字面量,也没有 +/// `for (…;…;…)`(`app.js` 现状如此)。如实记录这个射程 —— 它是本门禁唯一的近似。 +fn r163_statements(code: &str) -> Vec { + code.split(';') + .map(|s| s.split_whitespace().collect::>().join(" ")) + .filter(|s| !s.is_empty()) + .collect() +} + +/// 压平一段 JS(所有空白串成一个空格)。 +fn r163_flat(code: &str) -> String { + code.split_whitespace().collect::>().join(" ") +} + +/// 取一段文本**末尾**的标识符(`… if (!switchView(` → `switchView`)。 +fn r163_trailing_ident(s: &str) -> String { + let mut out: Vec = s + .chars() + .rev() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '$') + .collect(); + out.reverse(); + out.into_iter().collect() +} + +/// 一条语句里**第 2 次**调用 `callee(` 时,实参的那个函数名 +/// (`… )) switchView(homeView());` → `homeView`)。 +fn r163_second_callee(stmt: &str, callee: &str) -> Option { + let needle = format!("{callee}("); + let first = stmt.find(&needle)?; + let rest = &stmt[first + needle.len()..]; + let second = rest.find(&needle)?; + let after = &rest[second + needle.len()..]; + let name: String = after + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '$') + .collect(); + if name.is_empty() { + None + } else { + Some(name) + } +} + +/// 声明之后的**函数体文本**(跳过 `function NAME(…)`,免得把函数名自己算成「自调用」)。 +fn r163_body_after_head(code: &str) -> &str { + match code.find('{') { + Some(a) => &code[a + 1..], + None => code, + } +} + +/// 「会话建立时的目的地语句」:唯一一条**以 hash 解析器为实参**调用切换器的语句。 +/// +/// 返回 (所属函数名, 该语句, 切换器函数名)。**零或多于一条都返回 `None`** —— 后者与 +/// 探针的原实现同款(`len(dest) == 1`):两条各自独立的目的地意味着兜底可能只挂在其中一条上。 +fn r163_destination_statement(src: &str) -> Option<(String, String, String)> { + let mut hits: Vec<(String, String, String)> = Vec::new(); + for name in r163_fn_names(src) { + let body = match r163_fn_code(src, &name) { + Some(b) => b, + None => continue, + }; + for st in r163_statements(&body) { + let at = match st.find("(viewFromHash(") { + Some(a) => a, + None => continue, + }; + let callee = r163_trailing_ident(&st[..at]); + if !callee.is_empty() { + hits.push((name.clone(), st, callee)); + } + } + } + if hits.len() == 1 { + hits.pop() + } else { + None + } +} + +/// (拒绝 toast 数, 会「掉下去」的拒绝数, 成功路径是否以 `return true;` 收尾)。 +/// +/// 「掉下去」= 该 toast 之后紧跟的语句不是 `return false`(`return;` / `return <别的>` / 干脆 +/// 继续往下走)。逐对配对是本规则的关键:只数 `return false;` 的个数,漏掉一个拒绝时其余仍在 +/// ⇒ 读数照样绿(探针原实现的注释里记着这次实测)。 +fn r163_reports_entry(body: &str) -> (usize, usize, bool) { + let segs = r163_statements(body); + let toasts: Vec = segs + .iter() + .enumerate() + .filter(|(_, s)| s.contains("toast(")) + .map(|(i, _)| i) + .collect(); + let falling = toasts + .iter() + .filter(|&&i| segs.get(i + 1).map(String::as_str) != Some("return false")) + .count(); + let tail_ok = r163_flat(body).ends_with("return true;"); + (toasts.len(), falling, tail_ok) +} + +/// 兜底函数是否**按身份**取目标(`isGuest` + 二选一的分支形状)。 +fn r163_identity_aware(body: &str) -> bool { + let flat = r163_flat(body); + mentions_identifier(&flat, "isGuest") + && (flat.contains("GUEST_VIEWS") || (flat.contains('?') && flat.contains(':'))) +} + +// ── 判词 ──────────────────────────────────────────────────────────────────────────────── + +/// 一次读取的全部证据与四条判词。 +struct R163Reading { + sw: Option, + ent: Option, + home: Option, + toasts: usize, + falling: usize, + tail_ok: bool, + dest: Option, + self_calls: usize, + r1: bool, + r2: bool, + r3: bool, + r4: bool, +} + +impl R163Reading { + fn verdicts(&self) -> (bool, bool, bool, bool) { + (self.r1, self.r2, self.r3, self.r4) + } + + fn report(&self) -> String { + format!( + "r1={} r2={} r3={} r4={} | sw={:?} ent={:?} home={:?} toasts={} falling={} tail={} self_calls={} dest={:?}", + self.r1, + self.r2, + self.r3, + self.r4, + self.sw, + self.ent, + self.home, + self.toasts, + self.falling, + self.tail_ok, + self.self_calls, + self.dest + ) + } +} + +/// 读 `ui/js/app.js`,导出四条判词。函数名全部**派生**,期望值不写死。 +fn r163_read(app: &str) -> R163Reading { + // 剥注释、留字符串:本门禁要读 `toast(` 的实参与 `.contains(...)` 的那个实参 + // (坑 #296/#309:注释里正会写下被检验的表达式)。 + let bare = r173_bare(app); + + let dest = r163_destination_statement(&bare); + let ent = dest.as_ref().map(|(e, _, _)| e.clone()); + let stmt = dest.as_ref().map(|(_, s, _)| s.clone()); + let sw = dest.as_ref().map(|(_, _, c)| c.clone()); + + let sw_body = sw.as_deref().and_then(|n| r163_fn_code(&bare, n)); + let (toasts, falling, tail_ok) = match &sw_body { + Some(b) => r163_reports_entry(b), + None => (0, 0, false), + }; + + let home = match (&stmt, &sw) { + (Some(s), Some(c)) => r163_second_callee(s, c), + _ => None, + }; + let home_body = home.as_deref().and_then(|n| r163_fn_code(&bare, n)); + + let r1 = toasts >= 1 && falling == 0 && tail_ok; + let r2 = match (&stmt, &sw) { + (Some(s), Some(c)) => { + s.contains(&format!("if (!{c}(")) && s.matches(&format!("{c}(")).count() == 2 + } + _ => false, + }; + let r3 = home.is_some() && home_body.as_deref().is_some_and(r163_identity_aware); + let self_calls = match (&sw_body, &sw) { + (Some(b), Some(c)) => r163_body_after_head(b).matches(&format!("{c}(")).count(), + _ => 0, + }; + let r4 = sw.is_some() && self_calls == 0; + + R163Reading { + sw, + ent, + home, + toasts, + falling, + tail_ok, + dest: stmt, + self_calls, + r1, + r2, + r3, + r4, + } +} + +// ── 测试 ──────────────────────────────────────────────────────────────────────────────── + +/// 轴:被拒绝的视图切换不得把用户留在「没人渲染过」的界面上。 +/// +/// 四条规则的含义见上方文件头。每条规则的**牙**由 [`the_r163_rules_have_teeth`] 用合成变异体 +/// 逐条测量(基线=已知为绿的活树)。 +#[test] +fn the_refused_view_switch_never_leaves_an_unrendered_view() { + let rd = r163_read(APP_JS); + assert!( + rd.verdicts() == (true, true, true, true), + "R163 未修:被拒绝的视图切换会把用户留在没人渲染过的界面上\ + (R1 拒绝被报告/R2 调用方兜底/R3 兜底按身份取/R4 切换器不自调用):{}", + rd.report() + ); +} + +/// 提取器自证 + 反面对照:把两半都退回缺陷形状 ⇒ R1/R2/R3 同时红、R4 仍绿。 +#[test] +fn the_r163_roster_is_real() { + let live = r163_read(APP_JS); + assert!( + live.sw.is_some(), + "视图切换器没被推导出来:{}", + live.report() + ); + assert!( + live.ent.is_some(), + "会话建立的调用方没被推导出来:{}", + live.report() + ); + assert!( + live.home.is_some(), + "兜底目标函数没被推导出来:{}", + live.report() + ); + assert!( + live.toasts >= 3, + "拒绝守卫没被找齐(游客/管理/运营三处):{}", + live.report() + ); + assert_eq!( + live.falling, + 0, + "活树上就有拒绝掉下去 —— 变异体的读数无从解释:{}", + live.report() + ); + assert!( + live.tail_ok, + "成功路径没有回报「已进入」:{}", + live.report() + ); + // 语句切分器停对了地方:目的地语句必须同时提到 hash 解析器与兜底分支。 + let dest = live.dest.clone().unwrap_or_default(); + assert!( + dest.contains("viewFromHash") && dest.contains("if (!"), + "目的地语句切错了(语句切分器是近似,必须先自证):{:?}", + dest + ); + assert_ne!( + live.sw, + live.home, + "切换器与兜底函数成了同一个:{}", + live.report() + ); + + let broken = r163_variant_defect(APP_JS); + let rd = r163_read(&broken); + assert_eq!( + rd.verdicts(), + (false, false, false, true), + "退回缺陷形状没有同时打翻 R1/R2/R3(R4 应仍绿——切换器本来就不自调用):{}", + rd.report() + ); +} + +/// 四条规则**各有独立的牙**:每个合成变异体只打翻它针对的那一条(基线=已知为绿的活树)。 +/// +/// ⚠️ 变异体一律从**活树**上构造,且每个锚点都断言唯一(#612 家族):变体若没生效, +/// 构造器里的断言先响,而不是让某条腿因错误的原因变绿(#605)。 +#[test] +fn the_r163_rules_have_teeth() { + let live = r163_read(APP_JS); + assert_eq!( + live.verdicts(), + (true, true, true, true), + "基线不是绿的 —— 变异体的读数无从解释:{}", + live.report() + ); + + // ① 拒绝不再报告「没进去」⇒ 只翻 R1。 + let v = r163_variant_refusal_falls_through(APP_JS); + let rd = r163_read(&v); + assert_eq!( + rd.verdicts(), + (false, true, true, true), + "让一个拒绝掉下去只应翻掉 R1:{}", + rd.report() + ); + + // ② 调用方把判决**读反**(`if (…)` 而不是 `if (!…)`)⇒ 只翻 R2。 + // + // ⚠️ 这里刻意**不**用「把兜底整个删掉」那个原缺陷形状(见 `r163_variant_drop_fallback`): + // 兜底函数的**名字**是从兜底调用点读出来的(`r163_second_callee`)⇒ 把调用点删掉同时让 + // R3 失去它的证据,一条变异体翻两条规则。规则耦合是**真的**(R2 与 R3 是同一处语句的两面), + // 但「每条规则各有独立的牙」要求每条腿只翻它自己那条 ⇒ 本腿保留兜底调用、只打掉判据。 + // 两半一起退回缺陷形状的忠实变体另有其腿([`the_r163_roster_is_real`] 的 `r163_variant_defect`)。 + let v = r163_variant_fallback_ignores_the_verdict(APP_JS); + let rd = r163_read(&v); + assert_eq!( + rd.verdicts(), + (true, false, true, true), + "让调用方读反判决只应翻掉 R2:{}", + rd.report() + ); + + // ③ 兜底目标忘了身份(游客也被送去仪表盘)⇒ 只翻 R3。 + let v = r163_variant_home_forgets_guests(APP_JS); + let rd = r163_read(&v); + assert_eq!( + rd.verdicts(), + (true, true, false, true), + "让兜底目标不看身份只应翻掉 R3:{}", + rd.report() + ); + + // ④ 反向:切换器体内再调用自己 ⇒ 只翻 R4。 + let v = r163_variant_self_call(APP_JS); + let rd = r163_read(&v); + assert_eq!( + rd.verdicts(), + (true, true, true, false), + "让切换器自调用只应翻掉 R4:{}", + rd.report() + ); +} + +/// 变异体①:把一个拒绝的 `return false;` 退回 `return;`(原缺陷的那一半)。 +fn r163_variant_refusal_falls_through(app: &str) -> String { + let line = " toast(T(\"view.guest.lock\", { view: T(VIEW_TITLE[id] || id) }), \"error\");\n return false;\n"; + assert_eq!(app.matches(line).count(), 1, "拒绝锚点不唯一"); + app.replace( + line, + " toast(T(\"view.guest.lock\", { view: T(VIEW_TITLE[id] || id) }), \"error\");\n return;\n", + ) +} + +/// 变异体②(原缺陷那一半的**忠实**形状):`enterApp` 不再消费返回值 —— 目的地语句里连兜底 +/// 调用都没有了。(它同时让 R3 失去证据:兜底函数名从兜底调用点读出,见 `r163_second_callee`。) +fn r163_variant_drop_fallback(app: &str) -> String { + let line = "if (!switchView(viewFromHash() || \"dashboard\")) switchView(homeView());"; + assert_eq!(app.matches(line).count(), 1, "目的地语句锚点不唯一"); + app.replace(line, "switchView(viewFromHash() || \"dashboard\");") +} + +/// 变异体②b(R2 的**独立**牙):兜底还在,但调用方把判决读反了(`if (…)` 而非 `if (!…)`)。 +fn r163_variant_fallback_ignores_the_verdict(app: &str) -> String { + let line = "if (!switchView(viewFromHash() || \"dashboard\"))"; + assert_eq!(app.matches(line).count(), 1, "目的地守卫锚点不唯一"); + app.replace(line, "if (switchView(viewFromHash() || \"dashboard\"))") +} + +/// 变异体③:兜底函数不再看身份(游客也被送去仪表盘)。 +fn r163_variant_home_forgets_guests(app: &str) -> String { + let line = "function homeView() { return isGuest ? \"marketplace\" : \"dashboard\"; }"; + assert_eq!(app.matches(line).count(), 1, "兜底函数锚点不唯一"); + app.replace(line, "function homeView() { return \"dashboard\"; }") +} + +/// 变异体④:在切换器体内再调用一次自己(「拒绝就重定向」的竞争修法形状)。 +fn r163_variant_self_call(app: &str) -> String { + let line = " $(\"#main\").scrollTop = 0;\n"; + assert_eq!(app.matches(line).count(), 1, "自调用插入锚点不唯一"); + app.replace( + line, + " $(\"#main\").scrollTop = 0;\n switchView(\"dashboard\"); // mutated: a self-call\n", + ) +} + +/// 两半一起退回缺陷形状(基线对照用):一个拒绝掉下去 + 调用方不再兜底。 +fn r163_variant_defect(app: &str) -> String { + r163_variant_drop_fallback(&r163_variant_refusal_falls_through(app)) +} diff --git a/ui/README.md b/ui/README.md index c1d55ce..89a1351 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1122,3 +1122,40 @@ key**(正常操作)于是让运营者看到红色故障警报,而屏幕上 ⛔ 本轴**刻意不读** `ui/README.md`:名册是从**制品**推导的,若再钉一行手写契约, 同一事实就有了两个来源 —— 那正是本轴要消灭的漂移。本文件「页面清单」第 5 条的枚举是**文档**, 不是门禁的尺子。 + +## 被拒绝的视图切换:调用方必须知道「没进去」,并落到**本身份的家**(R163,2026-09-25) + +- **两张脸,一处根**。`switchView(id)` 是「换视图」的唯一入口,先过身份守卫(游客只进市场; + 管理视图仅 `admin`;运营视图仅 `ops`),守卫拒绝时 `toast` 一句然后**直接返回**。屏幕上有 + 东西时这是对的(用户点了个进不去的入口,留在原地看着提示);**会话建立那一刻**不一样 —— + C2136 之后 boot 只搭外壳(`renderNav()` / `bindEvents()` / 余额占位),C2137 删掉了 boot 里那句 + 无条件 `renderView("dashboard")` ⇒ 那一刻**什么都没渲染过**,DOM 还是静态骨架。而 + `enterApp()` 的目的地取自 URL hash(刷新 / 换账号都带上一次的 hash)⇒ 一个指向「当前身份进不去」 + 的 hash 会让用户**用一个 toast 换来一块从没人渲染过的骨架屏**:`#dash-stats` / `#dash-trend` / + `#dash-month-changes` / `#dash-sharings` 全空、此后**零请求**、整会话不自愈 + (`enterApp` 只在会话建立那一刻跑一次)。 +- **可达**:① 同浏览器换账号(`exitGuest()` 不清 `location.hash`)② `#/ops` 对**所有人**恒真 + (`ops` 角色至今没有生产者,见 R162 的在册轴)③ 过期 token 后换账号。 +- **约定**:**切换器报告结果,调用方消费它,兜底目标按身份取**。 + 1. `switchView` 的**每一个**拒绝都 `return false;`,成功路径以 `return true;` 收尾; + 2. 会话建立时唯一那条「把 hash 解析结果交给切换器」的语句必须带兜底: + `if (!switchView(viewFromHash() || "dashboard")) switchView(homeView());`; + 3. 兜底目标**必须**由**身份**派生(`homeView()` = 游客 `marketplace`、登录用户 `dashboard`)—— + 游客的家不是仪表盘(`GUEST_VIEWS` 之外),无条件重定向会**再撞一次访客守卫**(实测: + 丢解释性 toast + `RangeError: Maximum call stack size exceeded`); + 4. 反向:`switchView` **不得**在自己的体内再调用自己。 + ⛔ 不得先算一个 `mayEnter(id)` 再决定目的地 —— 那会把守卫里那句解释性 toast 吞掉(探针 `D1` 当场红)。 +- **门禁**:`state_gate::the_refused_view_switch_never_leaves_an_unrendered_view`(规则 1–4,函数名 + **全部派生**、期望值零手写) + + `state_gate::the_r163_roster_is_real`(提取器自证 + 两半一起退回缺陷形状的对照) + + `state_gate::the_r163_rules_have_teeth`(四条变体腿各翻一条)。 +- ⚠️ **射程**:门禁是**静态词法**的 —— 它证「拒绝被报告」「调用方兜底」「兜底按身份取」三件 + **形状**,**不**证屏幕上真的渲染出了内容,也不证 `#/ops` 对所有人恒真的前提(`ops` 角色的 + 可达性是另一条在册轴,方向待裁定)。浏览器事实与症状归 jsdom 探针(11 腿:`base` 恰 + `B1`/`B2`/`B3`/`F1` 红、`fix` 全绿、竞争修法 `m_redirect` 被 `E1`/`E3` 拒)。 + 两台仪器各自能看见对方看不见的东西(C2148/R168/R173 同款分工)。 +- ⚠️ **与 R173 的接口**:R173 的规则原先是「`switchView` 里**每一个** `return` 都在 `kbdClear()` + 之前」。R163 让成功路径也有了返回值(**函数体最后一条语句**上的 `return true;`)—— 它天然落在 + 清除之后,语义完全正确。判据因此按 R173 声明原话(「每一个 `return` **守卫**」)收紧: + 只豁免**末尾那一条语句上**的 `return`,其后的任何 `return` 仍算守卫、仍判红 + (`r173_is_last_statement`)。 diff --git a/ui/index.html b/ui/index.html index 3647aa6..7743375 100644 --- a/ui/index.html +++ b/ui/index.html @@ -862,6 +862,6 @@

使用模型

- + diff --git a/ui/js/app.js b/ui/js/app.js index 185298a..d899640 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -593,20 +593,22 @@ $("#mode-label").textContent = isGuest ? T("nav.mode.guest") : T("nav.mode.normal"); } + // 返回值即「有没有真的进去」:`false` = 被身份守卫拒绝(已 toast 说明原因),`true` = 已切到该视图。 + // R163:`enterApp()` 靠它兜底 —— 会话建立那一刻 DOM 还是静态骨架,被拒绝的切换必须有人接手渲染。 function switchView(id, opts) { // 游客限制(US-1):非市场页面 → 提示需登录 if (isGuest && !GUEST_VIEWS.includes(id)) { toast(T("view.guest.lock", { view: T(VIEW_TITLE[id] || id) }), "error"); - return; + return false; } // 角色限制(P2-A/P2-C):管理视图仅 admin;运营视图仅 ops(hash 直达 / 快捷键也兜底) if (id === "admin" && D.USER.role !== "admin") { toast(T("view.admin.lock"), "error"); - return; + return false; } if (id === "ops" && D.USER.role !== "ops") { toast(T("view.ops.lock"), "error"); - return; + return false; } // 视图**入口**(不是渲染):交易页每次进入回到默认档「总点数」(rant 范围句)。 // 挂这里而不是 renderView()/renderTxTrend() —— 那两个还会被 atp:langchange 调用, @@ -623,6 +625,7 @@ document.title = VIEW_TITLE[id] ? T(VIEW_TITLE[id]) + " · AITokenPool" : "AITokenPool"; // URL hash 路由(rant 20:39:30 A:视图切换同步 #/视图;非法 hash 回退时不清 URL,避免污染历史) if (!opts || opts.sync !== false) syncHash(id); + return true; } /* ---------------- 视图渲染 ---------------- */ @@ -3373,6 +3376,15 @@ $("#side-avatar").textContent = name ? Array.from(name)[0] : "?"; } + // 身份对应的「家」:游客是市场(`GUEST_VIEWS` 里那一个),登录用户是仪表盘。 + // + // R163:`enterApp()` 的目的地从 URL hash 取,而 hash 可能指向**当前身份进不去**的视图 + // (非管理员的 `#/admin`、所有人的 `#/ops`)—— `switchView` 拒绝时不渲染任何东西,而 + // C2137 之后 boot 也不再渲染兜底视图 ⇒ 用户会停在一块**从没人渲染过**的骨架屏上(空卡片、 + // 此后零请求、不自愈)。兜底目标**必须按身份取**:把游客送进仪表盘会再撞一次访客守卫 + // (探针 `E1`/`E3` 实测:无条件重定向会丢解释性 toast 并递归爆栈)。 + function homeView() { return isGuest ? "marketplace" : "dashboard"; } + // 进入主界面(登录成功 / 会话恢复共用) function enterApp() { isGuest = false; @@ -3383,8 +3395,9 @@ $("#side-balance").textContent = D.fmt(D.USER.balance); renderUserChip(); renderNav(); - // URL hash 路由:登录后恢复刷新前的视图(无 hash 则仪表盘) - switchView(viewFromHash() || "dashboard"); + // URL hash 路由:登录后恢复刷新前的视图(无 hash 则仪表盘)。 + // 目的地被角色/访客守卫拒绝时落到身份对应的「家」——否则会话建立那一刻什么都没渲染过(R163)。 + if (!switchView(viewFromHash() || "dashboard")) switchView(homeView()); maybeStartTour(); // 首次登录引导(rant 20:46:57 A:atp-tour-done 未标记才触发) }