From 5b3dbbb0d39659cde0ba30d4b7e34356e4869d17 Mon Sep 17 00:00:00 2001 From: argszero Date: Sun, 27 Sep 2026 21:29:49 +0800 Subject: [PATCH] fix(gates): stop citing out-of-repo session instruments and guard the rule Repo prose (src/** comments and ui/README.md) cited "the instrument that proves the other half" by file name -- jsdom probes, Python compile gates, edit sheets. Those instruments live outside the repository and never entered it (there is no JS runner in CI), so every such citation is a dead link for every reader. The repo had already written the convention down -- state_gate.rs's R165 block says "the instrument lives outside the repo, so no file name here (#606)" -- but nothing enforced it: 62 occurrences violate it, one of them in that same file, three lines after that very sentence. Keep the statement, drop the file name, at all 62 sites. Add src/citation_gate.rs so the rule has an enforcer: for every backticked file-shaped token in the repo's text files (Rust comments only for .rs, since the sites live in comments and this gate's own fixtures live in string literals), if the basename matches the session-instrument naming (^[rc][0-9]+[-_]) and no file of that name exists, the test fails. The scope is deliberately narrow: runtime artefacts (/config.toml), negated statements, historical entries and output-file placeholders are not flagged -- they are either runtime facts or true precisely because the file does not exist. Evidence: 62 sites fixed (62 insertions / 62 deletions, one line each); cargo test 409 -> 413 (four new tests: the rule, a >=200-token positive control, the synthetic teeth set, and the naming predicate); cargo fmt --check rc=0; clippy --all-targets -D warnings rc=0. A/B on the live tree: injecting one citation into a .rs comment and one into ui/README.md makes the rule fail with both file:line locations, while the other three tests stay green. --- src/citation_gate.rs | 436 +++++++++++++++++++++++++++++++++++++++++++ src/i18n_pack.rs | 4 +- src/main.rs | 4 + src/state_gate.rs | 84 ++++----- ui/README.md | 54 ++++-- ui/index.html | 2 +- ui/js/app.js | 2 +- 7 files changed, 523 insertions(+), 63 deletions(-) create mode 100644 src/citation_gate.rs diff --git a/src/citation_gate.rs b/src/citation_gate.rs new file mode 100644 index 0000000..889ea45 --- /dev/null +++ b/src/citation_gate.rs @@ -0,0 +1,436 @@ +//! 引用门禁(R94):**仓库正文不得引用「不存在于仓库里」的会话仪器文件名**。 +//! +//! # 起因 +//! +//! 本仓的源码注释与 `ui/README.md` 长期用反引号文件名引用「证明另一半的那台仪器」 +//!(jsdom 探针 / Python 编译门禁 / 编辑表 / 预检单 …)。这些仪器住在**仓库外**、从未进仓 +//!(根因见 `ui/README.md`:CI 里没有 JS 运行器),于是每一个这样的引用对任何读者都是 +//! **打不开的死链** —— 而本仓自己早已把这条约定写在 `state_gate.rs` 的 R165 一块里 +//!(「仪器住在仓外,故此处不写文件名 —— #606」),只是**没有执行者**: +//! R94 实测全树 **62 处**违反,其中一处就在写下那句话的**同一个文件、3 行之后**。 +//! +//! 这与本仓已收口的几条同族(R76 的 MSRV / R78 的部署产物版本 / R80 的发行 tag / +//! R82 的 CHANGELOG 标题 / R84 的构建上下文)同判:**抄了/引用了没有断言,就是腐烂的栖息地**。 +//! +//! 设计约束(与 `deploy_gate.rs` / `body_limit_gate.rs` 同型):**仅测试期编译**、 +//! **零新依赖**、**运行期走盘**(`CARGO_MANIFEST_DIR`,不依赖工作目录); +//! **阳性对照**:断言扫描器确实在语料里看见了反引号包裹的文件名 —— +//! 否则「扫到 0 条违规」与「扫描器是瞎的」在读数上无法区分。 +//! +//! # 规则 +//! +//! - **语料**:仓库树里的文本文件(`rs`/`md`/`js`/`html`/`css`/`toml`/`yml`)。 +//! 对 `.rs` 只取**注释正文**(站点按定义写在注释里;字符串字面量里的同名 token +//! 是本模块自己的合成夹具,掩掉它才不会假红)。 +//! - **站点**:被**单反引号**包裹、整体形如文件名的 token(`` `…/x.js` ``)。 +//! - **判词**:该 token 的 basename 命中**会话仪器命名**(`^[rc][0-9]+[-_]`) +//! **且**仓库里没有同名文件 ⇒ 违规。 +//! +//! # 射程(诚实声明) +//! +//! 判据是**命名形态 + 在场检查**,**不**证明「任意非仓库引用」。刻意不报的四类 +//!(R93 实测的 12 条假阳性):运行期产物(`/config.toml`)、**否定**陈述 +//!(「仓库没有 `ui/package.json`」)、历史条目(「Removed `data/models.example.json`」)、 +//! 以及导出文件名这类**占位符**(`aitokenpool-transactions-YYYYMMDD.csv`)—— +//! 它们要么真的是运行期事实、要么正因为不存在才这么写,收进来只会制造假红。 + +use std::collections::BTreeSet; +use std::path::Path; + +/// 扫描的文本扩展名(语料边界)。 +const TEXT_EXT: &[&str] = &["rs", "md", "js", "html", "css", "toml", "yml"]; + +/// 走盘时跳过的目录:版本库、构建产物、会话工作区(`.emrg` 之下才是本任务的 clone)。 +const SKIP_DIRS: &[&str] = &[".git", "target", ".emrg", "node_modules"]; + +/// 被引用的文件名可以有的扩展名(比 `TEXT_EXT` 宽:引用可以指向任何类型的产物)。 +const CITED_EXT: &[&str] = &[ + "js", "py", "rs", "sh", "json", "diff", "txt", "ps1", "csv", "toml", "yml", "html", "css", "md", +]; + +/// 会话仪器的命名形态:`r_` / `r-` / `c_` / `c-`。 +/// +/// 这是本任务(与更早的会话)给「一台一次性仪器」起的名字。它**不是**一条通用规则 —— +/// 正因为窄,才配得上「零豁免清单」:本仓没有任何真实文件长这个样子。 +fn session_instrument(basename: &str) -> bool { + let mut it = basename.chars(); + match it.next() { + Some('r') | Some('c') => {} + _ => return false, + } + let mut digits = 0usize; + for ch in it { + if ch.is_ascii_digit() { + digits += 1; + continue; + } + return digits > 0 && (ch == '-' || ch == '_'); + } + false +} + +/// 像不像是被引用**文件**的名字(而不是散文里的其它反引号内容)。 +fn look_like_file(tok: &str) -> bool { + if tok.is_empty() || tok.contains(char::is_whitespace) || tok.contains('`') { + return false; + } + if !tok + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/')) + { + return false; + } + match tok.rsplit_once('.') { + Some((stem, ext)) => !stem.is_empty() && CITED_EXT.contains(&ext), + None => false, + } +} + +/// 一行里被**单反引号**包裹的片段。奇数个反引号时最后一段视为未闭合、照收。 +fn backtick_tokens(line: &str) -> Vec<&str> { + let mut out = Vec::new(); + for (i, part) in line.split('`').skip(1).enumerate() { + if i % 2 == 0 { + out.push(part); + } + } + out +} + +/// 只取 Rust 源码的**注释正文**(代码与字符串/字符字面量掩成空格,换行保留)。 +/// +/// 站点按定义写在注释里;掩掉字符串字面量是必须的 —— 本模块自己的合成夹具就写在字符串里, +/// 不掩的话门禁会把自己的输入读成违规(R84 实测过同形的假红)。 +fn rust_comments(src: &str) -> String { + let cs: Vec = src.chars().collect(); + let mut out: Vec = cs + .iter() + .map(|c| if *c == '\n' { '\n' } else { ' ' }) + .collect(); + let mut i = 0usize; + while i < cs.len() { + if cs[i] == '/' && cs.get(i + 1) == Some(&'/') { + i += 2; + while i < cs.len() && cs[i] != '\n' { + out[i] = cs[i]; + i += 1; + } + } else if cs[i] == '/' && cs.get(i + 1) == Some(&'*') { + out[i] = '/'; + out[i + 1] = '*'; + i += 2; + let mut depth = 1usize; + while i < cs.len() && depth > 0 { + if cs[i] == '/' && cs.get(i + 1) == Some(&'*') { + out[i] = '/'; + out[i + 1] = '*'; + depth += 1; + i += 2; + } else if cs[i] == '*' && cs.get(i + 1) == Some(&'/') { + depth -= 1; + out[i] = '*'; + out[i + 1] = '/'; + i += 2; + } else { + out[i] = if cs[i] == '\n' { '\n' } else { cs[i] }; + i += 1; + } + } + } else if cs[i] == '"' || (cs[i] == 'r' && raw_string_start(&cs, i).is_some()) { + i = skip_literal(&cs, i); + } else if cs[i] == '\'' { + i = skip_char_literal(&cs, i); + } else { + i += 1; + } + } + out.into_iter().collect() +} + +/// `pos` 处若是原始字符串的开头(`r"` / `r#"` …),返回引号的下标。 +fn raw_string_start(cs: &[char], pos: usize) -> Option { + let mut j = pos + 1; + let mut hashes = 0usize; + while cs.get(j) == Some(&'#') { + hashes += 1; + j += 1; + } + if cs.get(j) == Some(&'"') && (cs[pos] == 'r' || hashes > 0) { + Some(j) + } else { + None + } +} + +/// 跳过 `pos` 处的一个字符串字面量,返回其后的下标。 +fn skip_literal(cs: &[char], pos: usize) -> usize { + if let Some(q) = raw_string_start(cs, pos) { + let hashes = q - pos - 1; + let mut j = q + 1; + while j < cs.len() { + if cs[j] == '"' && (0..hashes).all(|k| cs.get(j + 1 + k) == Some(&'#')) { + return j + 1 + hashes; + } + j += 1; + } + return j; + } + let mut j = pos + 1; + while j < cs.len() { + match cs[j] { + '\\' => j += 2, + '"' => return j + 1, + _ => j += 1, + } + } + j +} + +/// 跳过 `pos` 处的一个字符字面量(认不出就把 `'` 当寿命标记,返回 `pos + 1`)。 +fn skip_char_literal(cs: &[char], pos: usize) -> usize { + match cs.get(pos + 1) { + Some('\\') => { + for end in [pos + 3, pos + 4, pos + 6] { + if cs.get(end) == Some(&'\'') { + return end + 1; + } + } + pos + 1 + } + Some(_) => { + if cs.get(pos + 2) == Some(&'\'') { + pos + 3 + } else { + pos + 1 + } + } + None => pos + 1, + } +} + +/// 一台文件里的所有违规(`文件:行 引用了仓外仪器 \`名字\``)。 +/// +/// `present` 是仓库里**真实在场**的文件名集合(basename 与仓库相对路径两种写法都收)。 +fn violations_of(files: &[(String, String)], present: &BTreeSet) -> Vec { + let mut out = Vec::new(); + for (rel, src) in files { + let text = if rel.ends_with(".rs") { + rust_comments(src) + } else { + src.clone() + }; + for (i, line) in text.lines().enumerate() { + for raw in backtick_tokens(line) { + let tok = raw.trim(); + if !look_like_file(tok) { + continue; + } + let base = tok.rsplit('/').next().unwrap_or(tok); + if !session_instrument(base) { + continue; + } + if present.contains(base) || present.contains(tok) { + continue; + } + out.push(format!("{rel}:{} 引用了仓外仪器 `{tok}`", i + 1)); + } + } + } + out.sort(); + out +} + +/// 语料(仓库相对路径, 文本)与「真实在场的文件名」集合。运行期走盘,不依赖工作目录。 +fn scan_corpus() -> (Vec<(String, String)>, BTreeSet) { + fn walk( + dir: &Path, + root: &Path, + text: &mut Vec<(String, String)>, + names: &mut BTreeSet, + ) { + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + for e in rd.flatten() { + let p = e.path(); + let name = e.file_name().to_string_lossy().to_string(); + if p.is_dir() { + if SKIP_DIRS.contains(&name.as_str()) { + continue; + } + walk(&p, root, text, names); + continue; + } + names.insert(name); + let ext = p + .extension() + .map(|x| x.to_string_lossy().to_string()) + .unwrap_or_default(); + if TEXT_EXT.contains(&ext.as_str()) { + if let Ok(s) = std::fs::read_to_string(&p) { + let rel = p + .strip_prefix(root) + .unwrap_or(&p) + .to_string_lossy() + .replace('\\', "/"); + text.push((rel, s)); + } + } + } + } + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut text = Vec::new(); + let mut names = BTreeSet::new(); + walk(&root, &root, &mut text, &mut names); + text.sort(); + (text, names) +} + +/// 语料里被反引号包裹、形如文件名的 token 总数(阳性对照的分母)。 +fn cited_tokens(files: &[(String, String)]) -> usize { + let mut n = 0usize; + for (rel, src) in files { + let text = if rel.ends_with(".rs") { + rust_comments(src) + } else { + src.clone() + }; + for line in text.lines() { + n += backtick_tokens(line) + .into_iter() + .filter(|t| look_like_file(t.trim())) + .count(); + } + } + n +} + +#[test] +fn every_citation_names_a_file_the_repository_has() { + let (files, present) = scan_corpus(); + let bad = violations_of(&files, &present); + assert!( + bad.is_empty(), + "仓库正文不得引用仓外仪器文件名(#606):\n{}", + bad.join("\n") + ); +} + +#[test] +fn the_citation_scanner_actually_sees_the_citations_it_guards() { + // 阳性对照:语料里必须真的有反引号包裹的文件名 —— 否则上面那条「0 条违规」是空转。 + let (files, _) = scan_corpus(); + let tokens = cited_tokens(&files); + assert!( + tokens >= 200, + "扫描器必须真的看见语料里的反引号文件名(否则 0 条违规不可信):{tokens}" + ); + // 而且 `.rs` 的注释掩码没把语料吃空:注释里仍看得到引用(例:`ui/README.md`)。 + let rs_with_citations = files + .iter() + .filter(|(rel, src)| { + rel.ends_with(".rs") && cited_tokens(&[(rel.clone(), src.clone())]) > 0 + }) + .count(); + assert!( + rs_with_citations >= 3, + "至少几个 `.rs` 的注释里应当能看见反引号文件名:{rs_with_citations}" + ); +} + +#[test] +fn the_citation_rule_flags_the_shapes_it_guards() { + // 规则本身有牙:合成输入(规则与「活树此刻是否合规」是两件事,不该互相污染读数)。 + let present: BTreeSet = ["wallet.rs".to_string(), "README.md".to_string()] + .into_iter() + .collect(); + let files = |rel: &str, src: &str| vec![(rel.to_string(), src.to_string())]; + + // ① 注释里引用仓外仪器名 ⇒ 违规。 + let bad = files("src/x.rs", "// 那一半归 jsdom 探针 `r92_probe.js` 证。\n"); + let v = violations_of(&bad, &present); + assert_eq!(v.len(), 1, "仓外仪器引用必须被抓到:{v:?}"); + assert!( + v[0].contains("r92_probe.js") && v[0].contains("src/x.rs:1"), + "{v:?}" + ); + + // ② 同一个 token 写在**字符串字面量**里 ⇒ 不是站点(夹具/示例,掩掉防假红)。 + let lit = files("src/x.rs", "const S: &str = \"见 `r92_probe.js`\";\n"); + assert!( + violations_of(&lit, &present).is_empty(), + "字符串里的同名 token 不是站点" + ); + + // ③ 引用**在场**的文件 ⇒ 放行(含子目录写法)。 + let ok = files( + "src/x.rs", + "// 见 `wallet.rs`、`README.md` 与 `ui/js/app.js`。\n", + ); + assert!( + violations_of(&ok, &present).is_empty(), + "在场文件不得报违规:{ok:?}" + ); + + // ④ 不在场的**非仪器名** ⇒ 刻意放行(射程声明:运行期产物、否定陈述、占位符等)。 + let rt = files("src/x.rs", "// 首次启动自动复制 `/config.toml`。\n"); + assert!( + violations_of(&rt, &present).is_empty(), + "运行期产物路径不在射程内" + ); + + // ⑤ Markdown 语料没有注释语法 ⇒ 整篇都是站点。 + let md = files( + "ui/README.md", + "探针 `tmp/c2136_probe.js` 只用于本地证明**方向**。\n", + ); + let mv = violations_of(&md, &present); + assert_eq!(mv.len(), 1, "Markdown 正文里的引用必须被抓到:{mv:?}"); + + // ⑥ 子句里的仪器名同样算(`tmp/` 前缀不豁免)。 + let sub = files("ui/README.md", "`c2172_probe.js` 的 `F1/F2` 腿钉数值。\n"); + assert_eq!(violations_of(&sub, &present).len(), 1); +} + +#[test] +fn the_instrument_naming_predicate_is_not_a_net_that_catches_anything() { + // 判据要窄:只有「一次会话给一台仪器起的名字」才算,本仓的真实文件名一个都不沾。 + for good in [ + "r92_probe.js", + "c2172_probe.js", + "c2170-probe.js", + "r99-landing-kit.md", + "r165_compile_gate.py", + "r156_model_price_invariant_probe.py", + ] { + assert!(session_instrument(good), "{good} 必须被认成会话仪器名"); + } + for bad in [ + "README.md", + "wallet.rs", + "_gate.rs", + "foo.rs", + "config.toml", + "package.json", + "aitokenpool-transactions-YYYYMMDD.csv", + "r.json", + "r_probe.js", + "cx_probe.js", + "c_probe.js", + ] { + assert!(!session_instrument(bad), "{bad} 不得被认成会话仪器名"); + } + // 文件名的形状判据同样要窄:散文里的反引号内容不算站点。 + for good in ["ui/js/app.js", "config.toml", "r92_probe.js"] { + assert!(look_like_file(good), "{good} 应当被认成文件名"); + } + for bad in [ + "fn main()", + "cargo test", + "", + "gateway.rs 的行为", + "see also", + ] { + assert!(!look_like_file(bad), "{bad} 不是文件名"); + } +} diff --git a/src/i18n_pack.rs b/src/i18n_pack.rs index e5d440b..4dac467 100644 --- a/src/i18n_pack.rs +++ b/src/i18n_pack.rs @@ -4125,7 +4125,7 @@ mod tests { fn t() { json!({ "x": "测试中文" }) } } // 全部失败」= 给数据加戏:用户**暂停/下架自己的 key**(正常操作)会让运营者看到**红色 // 「全部失败」**(C2158 侦察实测,jsdom 真控件复现)。 // - // 三条规则**各有独立的牙**(A/B 逐腿见 `c2158_gate_ab.py`): + // 三条规则**各有独立的牙**(A/B 逐腿已在仓外仪器上逐条验证): // ① 键名不得是判定词 —— **改文案不改键名仍红**(键名会撒谎就没法用门禁钉) // ② 消费到的键必须已登记 —— 防「另起一个新判定语键」 // ③ 三个状态键必须**都被渲染** —— 防「把 pill 整块删掉」的逃逸 @@ -4436,7 +4436,7 @@ mod tests { fn t() { json!({ "x": "测试中文" }) } } /// ⑤ 那把尺子必须与市场行的 `mk.avail.` pill 家族**同词** —— 尺子不能被悄悄换掉。 /// /// ⚠️ 射程(诚实记录,同时写进 `ui/README.md`): - /// - 只钉**单位**,不钉**数值**:数值由 DOM 探针 `c2172_probe.js` 的 `F1/F2` 腿钉 + /// - 只钉**单位**,不钉**数值**:数值由仓外 DOM 探针的 `F1/F2` 腿钉 /// (数字必须等于行数,且不等于 key 总数); /// - 删掉**一个**填充位点(如空态那句)本门禁看不见(位点数会一起降),由探针 /// `P0b/F1/T1` 三条腿拒掉; diff --git a/src/main.rs b/src/main.rs index f93e153..6100cb3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -51,6 +51,10 @@ mod state_gate; // 加 #[cfg(test)] 后不会进入发布产物。 #[cfg(test)] mod body_limit_gate; +// 引用门禁(R94):同样是仅测试期编译 —— 全仓文本文件在测试期读一遍, +// 加 #[cfg(test)] 后不会进入发布产物。 +#[cfg(test)] +mod citation_gate; use std::sync::Arc; diff --git a/src/state_gate.rs b/src/state_gate.rs index f7f8ae1..6b040fe 100644 --- a/src/state_gate.rs +++ b/src/state_gate.rs @@ -238,7 +238,7 @@ //! //! 已知边界(如实的射程):本门禁是**词法**的 —— 它证明闭包**赋值**了每个名字、且值回到声明处的 //! 字面量,**不**证明这些赋值无条件执行,也不证明屏幕上真的换了数据(那半归 jsdom 探针 -//! `c2170-probe.js`,仓内 CI 无 JS 运行器)。闭包只收 `function NAME(` 形式声明的函数(`call_graph` +//! 的射程,仓内 CI 无 JS 运行器)。闭包只收 `function NAME(` 形式声明的函数(`call_graph` //! 的键):把复位写进箭头常量时它看不见那个体的**内容**,规则 1 因此会**红**(诚实失败,不是假绿)。 //! use std::collections::{BTreeMap, BTreeSet}; @@ -2522,7 +2522,7 @@ fn caption_names_spendable_half( // 的写点,都必须能在**表单回收路径**上找到**同一频道**的 `.disabled = false`。 // // ⚠️ 浏览器事实(`reset()` 不清 property)与「点一个星期没反应」只有 DOM 仪器能证 -// (`r139_probe.js`:未修树恰 `{B1,B2}` 红 / 修复树 9/9 绿 / 竞争修法 `m_drop` 被 `{C3,C2,A3}` 拒绝)。 +// (仓外探针:未修树恰 `{B1,B2}` 红 / 修复树 9/9 绿 / 竞争修法 `m_drop` 被 `{C3,C2,A3}` 拒绝)。 // 本模块只钉**代码形状** —— 与 C2148 同款分工:**形状归门禁、事实归探针**。 // // 已知边界(如实的射程,不是承诺): @@ -2530,7 +2530,7 @@ fn caption_names_spendable_half( // `$` / `$$` 工具一律双引号,而十几处**按钮**瞬时禁用走 `querySelector('button…')`(单引号) // 或事件目标 ⇒ 天然不在射程内(不是靠名册排除的)。 // - 写点的频道按**向上 80 行内最近的一个**含 `input` 标签的选择器字面量归属;今日全仓只有一个 -// 这样的频道(`#sf-days .chip input`),`r139_verify_anchors.py` 的 `D4` 腿把它钉成 1。 +// 这样的频道(`#sf-days .chip input`),仓外锚点验证器的 `D4` 腿把它钉成 1。 /// 从一行里取出 `const NAME = (` / `let NAME = (` / `var NAME = (` 的 `NAME`(只认行首声明)。 fn arrow_name(line: &str) -> Option<&str> { @@ -2896,7 +2896,7 @@ fn r139_reading(app: &str, html: &str) -> R139Reading { // 出口(悬停)在文件里根本不存在(`fmtTokensExact` 只喂 `title`)。 // // 本门禁钉的是**形状**:卡片不许自带拼写、必须委派;导出必须写数字;单元格必须仍是缩写。 -// 「屏幕与文件真的对不对得上」由 DOM 仪器 `r158_probe.js` 证(形状归门禁,事实归探针)。 +// 「屏幕与文件真的对不对得上」由仓外 DOM 仪器证(形状归门禁,事实归探针)。 /// 交易表四个 token 列的**显示字段**(= 视图模型 `txsToView` 返回对象里的键名)。 /// @@ -3167,7 +3167,7 @@ impl R158Reading { // // 本门禁钉的是**形状**:粒度函数体内不许再出现控件选项值、必须委派给 `txRangeParams()`、 // 返回的粒度字面量恰为 `{hour, day, week}`、且窗口真源与控件**都还在**。 -// 「屏幕上的轴与请求窗口真的对不对得上」由 jsdom 探针 `r165_probe.js` 证。 +// 「屏幕上的轴与请求窗口真的对不对得上」由仓外 jsdom 探针证。 /// `#tx-range` 这个 `