diff --git a/src/state_gate.rs b/src/state_gate.rs index f82563a..756f6fc 100644 --- a/src/state_gate.rs +++ b/src/state_gate.rs @@ -220,6 +220,27 @@ //! 行尾的 `//` 注释不算注释(`app.js` 当前两者都没有,`is_comment_line` 的兄弟门禁同型)。 //! 只认字面表名:`const T = D; T.MARKET` 这类别名逃得过。 //! +//! # C2170:身份边界要清的不只是 `Live` —— **模块级**的视图状态同样跨不过边界 +//! +//! `resetSessionCaches()` 只清 `Live` 的那些槽,而交易视图的状态是**模块级**的:`txTable` 的 +//! `sort`/`filters`/`page`/`pageSize`(载荷的**输入**)、`txRange`/`txCustomStart`/`txCustomEnd` +//! (时间窗),以及 `txTable.loadedPage`/`loadedPageSize`/`loadedQuerySig`(载荷的**有效性证据**)。 +//! 证据属于载荷 —— 载荷被清空而证据留下,守卫(`txQuerySig()` 的比对)就会认一份**不存在**的 +//! 载荷为「已加载」,下一位用户的首帧是空表(服务端按 `offset=(page-1)*page_size` 返回 `items: []` +//! 而 `total` 照旧非零)且**不自愈**。 +//! +//! 不变量两条:① 身份边界的**闭包**必须**赋值**每一个派生出来的名字 —— `txTable` 字面量的字段 + +//! `txTable.loaded*` + `txQuerySig()` 读到的模块级 `let`,**零手抄名册**;② 这些名字的**证据写者** +//! 只允许装载器与边界闭包(否则在 `renderTransactions()` 里清会把守卫每次渲染都重新武装 ⇒ +//! 请求风暴,C2146 同形)。复位必须回到**声明处的字面量**(`txTable.page = 1` / +//! `txTable.pageSize = 10` / `txRange = "24h"`)而不是一键清空:`loadTransactions()` 的 +//! `Math.max(1, txTable.pageSize || 10)` 兜底会把 `undefined` 退化成「每页 1 行」。 +//! +//! 已知边界(如实的射程):本门禁是**词法**的 —— 它证明闭包**赋值**了每个名字、且值回到声明处的 +//! 字面量,**不**证明这些赋值无条件执行,也不证明屏幕上真的换了数据(那半归 jsdom 探针 +//! `c2170-probe.js`,仓内 CI 无 JS 运行器)。闭包只收 `function NAME(` 形式声明的函数(`call_graph` +//! 的键):把复位写进箭头常量时它看不见那个体的**内容**,规则 1 因此会**红**(诚实失败,不是假绿)。 +//! use std::collections::{BTreeMap, BTreeSet}; /// 前端源码在**编译期**读入:测试不依赖工作目录与文件系统布局。 @@ -1237,6 +1258,126 @@ fn identifiers(text: &str) -> BTreeSet { out } +/// `prefix` + 标识符 在 `src` 里出现过的**完整名字**(去重、字典序)。 +/// +/// 左边界也锚:`xtxTable.loaded` 不算(坑 #333 同族:兄弟标识符不是证据)。 +fn names_with_prefix(src: &str, prefix: &str) -> Vec { + let mut out: BTreeSet = BTreeSet::new(); + let bytes = src.as_bytes(); + let mut from = 0usize; + while let Some(rel) = src[from..].find(prefix) { + let at = from + rel; + from = at + prefix.len(); + if at > 0 && is_ident_byte(bytes[at - 1]) { + continue; + } + let rest = &src[at + prefix.len()..]; + let tail: String = rest + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(); + if !tail.is_empty() { + out.insert(format!("{prefix}{tail}")); + } + } + out.into_iter().collect() +} + +/// `text` 里有没有对 `lhs` 的**赋值**(`lhs = …`)。 +/// +/// **两侧都锚**:右侧必须是分隔符 —— `txTable.page` 是 `txTable.pageSize` 的前缀、 +/// `txTable.loadedPage` 是 `txTable.loadedPageSize` 的前缀,只锚左侧就会把「复位了兄弟字段」 +/// 判成「复位了它」(坑 #333)。`==` / `===` / `=>` 都不是赋值。 +fn assigns_in(text: &str, lhs: &str) -> bool { + let mut from = 0usize; + while let Some(rel) = text[from..].find(lhs) { + let at = from + rel; + from = at + lhs.len(); + if at > 0 && is_ident_byte(text.as_bytes()[at - 1]) { + continue; + } + let after = &text[at + lhs.len()..]; + if let Some(c) = after.chars().next() { + if c.is_ascii_alphanumeric() || c == '_' { + continue; + } + } + let t = after.trim_start(); + if let Some(rest) = t.strip_prefix('=') { + if !rest.starts_with('=') && !rest.starts_with('>') { + return true; + } + } + } + false +} + +/// 模块级 `let`(行首 2 空格缩进)的名字 —— 会话级视图状态就长这样(`app.js:15-22`)。 +fn module_level_lets(src: &str) -> Vec { + let mut out = Vec::new(); + for line in src.lines() { + if let Some(rest) = line.strip_prefix(" let ") { + let name: String = rest + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(); + if !name.is_empty() { + out.push(name); + } + } + } + out +} + +/// `const TABLE = { … }` 字面量里 `field` 声明处的字面量文本(到 `,` / `}` 为止)。 +fn declared_literal(src: &str, table: &str, field: &str) -> Option { + let (open, close) = object_literal_span(src, table)?; + let body = &src[open + 1..close - 1]; + let needle = format!("{field}:"); + let at = body.find(&needle)? + needle.len(); + let rest = &body[at..]; + // `[',', '}']` (a char-array pattern) rather than a closure: CI runs + // `cargo clippy --all-targets -- -D warnings`, and the closure form trips + // `clippy::manual_pattern_char_comparison` (measured: rustc/clippy 1.98.0). + let end = rest.find([',', '}']).unwrap_or(rest.len()); + Some(rest[..end].trim().to_string()) +} + +/// `let NAME = <字面量>;` 声明处的字面量文本。 +fn declared_let_literal(src: &str, name: &str) -> Option { + let needle = format!("let {name} = "); + let at = src.find(&needle)? + needle.len(); + let rest = &src[at..]; + let end = rest.find(';')?; + Some(rest[..end].trim().to_string()) +} + +/// 身份边界复位函数的**闭包**:`resetSessionCaches` 可达的函数名集合 + 它们的代码文本。 +/// +/// 闭包是必需的 —— 把复位抽进 `resetTxView()` 正是推荐写法(「一张表一处真源」),只看 +/// `resetSessionCaches` 自己的函数体会把抽出去的复位判成「没复位」。 +fn boundary_reset(src: &str) -> (String, Vec) { + let graph = call_graph(src); + // ⚠️ **只保留本文件声明过的函数**(第 94 轮实测修)。`call_graph()` 的值是「行里以 `(` 结尾的 + // 标识符」(`callee_names`),所以**方法名**(`Object.keys(...)`、`.forEach(...)`、`.push(...)`) + // 也会进图;而 `reachable()` 不看对方在不在图里就往 `seen` 里插 ⇒ 闭包混进 + // `forEach`/`keys`。它们没有函数体 ⇒ 下面那条自证「函数声明数 == 闭包大小」在**每一棵树**上 + // 都为假(实测 live `1≠3`、fix `2≠4`)⇒ 门禁不是「诚实地红」,是**坏的**。 + // 图的键**恰好**是声明集合(`call_graph` 只插 `function_name()` 认出来的名字)。 + let reach: Vec = reachable(&graph, &["resetSessionCaches".to_string()]) + .into_iter() + .filter(|f| graph.contains_key(f)) + .collect(); + let mut text = String::new(); + for f in &reach { + text.push_str(&function_code(src, f)); + text.push('\n'); + } + (text, reach) +} + +// + /// `stat(