Merge branch 'feat/m217-guardrange-child-spawn-guard-across'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-09 21:12:24 +08:00
commit 1f6e15127a
7 files changed
+213 -153

No files matched your search

+4 -1
View File
@@ -108,7 +108,10 @@ pub(super) fn fallback_command(
args: impl IntoIterator<Item = std::ffi::OsString>,
fallback: Kind,
) -> std::process::Command {
let mut command = std::process::Command::new(program);
// 子进程就是本 GUI exe 自己(PE Subsystem=2,本来就不会分到控制台),所以这一处今天闪不了;
// 走 `quiet` 是为了让「app-iced 里除 opener 外不留裸构造」这条规则整体可被守卫钉住——将来
// 某天把降级重开换成控制台辅助程序时,不会从这里漏出一块黑框(M217)。
let mut command = unlucky_core::platform::child::quiet(program);
command.args(args).envs(fallback_env(fallback));
command
.stdin(std::process::Stdio::inherit())
@@ -6,8 +6,6 @@
//!
//! 只在 Termux + 有 `DISPLAY` 时探测;`xdpyinfo` 缺失/解析失败一律回默认,不影响其它平台。
use std::process::Command;
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::platform::termux;
@@ -54,7 +52,11 @@ pub(super) fn fit_screen(default: (f32, f32), min: (f32, f32), screen: (f32, f32
/// 当前 X 屏幕尺寸(`xdpyinfo` 的 `dimensions: WxH pixels`)。
fn x_screen_size() -> Option<(f32, f32)> {
let output = Command::new("xdpyinfo").output().ok()?;
// 非 Windows 上 `quiet` 是原样返回(POSIX 没有「spawn 就新开一个终端」这回事);走它是为了
// 不在整棵 `crates/` 的子进程守卫里开一个「Termux 专用」例外(M217)。
let output = unlucky_core::platform::child::quiet("xdpyinfo")
.output()
.ok()?;
if !output.status.success() {
return None;
}
+12
View File
@@ -166,6 +166,18 @@ pub(crate) fn open_dir(dir: &std::path::Path) -> Result<(), String> {
spawn_opener(dir).map_err(|error| format!("{}(打开失败:{error})", dir.display()))
}
/// 平台各自的「打开这个目录」实现。
///
/// ## 为什么这三处**故意**不走 `unlucky_core::platform::child::quiet`(M217)
///
/// 这一处 spawn 的**目的就是让桌面上出现一个可见的东西**(文件管理器窗口 / Finder 窗口 /
/// 桌面环境的文件管理器)。`quiet` 的语义是「把子进程的窗口抑制掉」,用在这里是把目的本身
/// 抑制掉——改错了用户点「打开文件夹」就只看到「打不开」的报错。
///
/// 而且从窗口角度也不需要它:`explorer.exe` / `open` / `xdg-open` 自己都是 GUI 侧程序,由
/// 无控制台的 GUI 父进程 spawn 时 Windows 不会给它们新建控制台。所以这三处在
/// `crates/core/tests/child_quiet_guard.rs` 的 `ALLOWED_ROLES` 里登记成「目录打开器」角色,
/// **不是**漏网。别把它们改成 `quiet`。
#[cfg(target_os = "windows")]
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
// `explorer` 不用 shell:路径里的空格与 `&` 不会被当成命令分隔符。
@@ -71,7 +71,12 @@ impl App {
match std::env::current_exe() {
Ok(exe) => {
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
match std::process::Command::new(&exe).args(&args).spawn() {
// 子进程是本 GUI exe 自己(Subsystem=2,不会分到控制台);走 `quiet` 是为了让
// 「app-iced 里除 opener 外不留裸构造」这条规则整体可被守卫钉住(M217)。
let spawned = unlucky_core::platform::child::quiet(&exe)
.args(&args)
.spawn();
match spawned {
Ok(child) => diag!(
"[update] 已拉起新进程 pid={}:{}",
child.id(),
+7 -1
View File
@@ -6,6 +6,9 @@
//! 只有注册表知道答案」的问题,它用 `reg.exe query` 解决,`unsafe` 一个没加。本 crate 的 `unsafe`
//! 只允许出现在 [`crate::platform::win32`](D36 的纪律),所以这里照抄那条路。
//!
//! 子进程**一律**经 `unlucky_core::platform::child::quiet` 起(M217):GUI 父进程自己没控制台,
//! 裸构造会给 `reg.exe` 新建一块控制台窗口,用户看到的就是启动时闪一下黑框。
//!
//! 为什么不 `GetUserDefaultLocaleName`:它要 FFI,而它对**本用途**并不比注册表准——Windows 的
//! 「区域格式」与「显示语言」本来就是两个设置,两者都有各自的说法(见下面的诚实边界)。
//!
@@ -83,7 +86,10 @@ impl RegIo for HostReg {
return None;
}
let output = std::process::Command::new(program)
// **必须**走 `quiet`(M217):本 crate 的父进程是没有控制台的 GUI(PE Subsystem=2),
// 裸 `Command::new("reg.exe")` 会让 Windows 给子进程新建一块控制台——用户看到的就是
// 「启动时底下闪一个黑框」。这条路径每次 GUI 启动都跑,所以它闪得准时。
let output = unlucky_core::platform::child::quiet(program)
.args(args)
.output()
.ok()?;
@@ -249,7 +249,9 @@ impl LocaleSource for AppleLanguages {
fn locale(&self) -> Option<String> {
#[cfg(target_os = "macos")]
{
let output = std::process::Command::new("defaults")
// macOS 上 `quiet` 是原样返回(POSIX 不因 spawn 新开终端);走它是为了不在整棵
// `crates/` 的子进程守卫里为这条 macOS 专用路径开例外(M217)。
let output = unlucky_core::platform::child::quiet("defaults")
.args(["read", "-g", "AppleLanguages"])
.output()
.ok()?;
+176 -146
View File
@@ -1,96 +1,130 @@
//! M213 守卫:`crates/core/src` 里**所有**子进程都必须经 `platform::child::quiet` 起。
//! M217 守卫:**整棵 `crates/` 树**里所有子进程都必须经 `platform::child::quiet` 起。
//!
//! ## 它判什么(按角色,不按次数)
//! M213 建它时射程只有 `crates/core/src`;用户随后报「CI 构建版启动时底下闪一个控制台」,
//! 定位到 `app-iced/src/platform/locale.rs` 的 `reg.exe`——射程之外的漏网。M217 把根抬到
//! `crates/`,角色路径也重写成相对 `crates/`。
//!
//! 唯一的问题是「这一处直接构造子进程**属于哪种角色**、是不是绕开了构造器」。所以判据是:
//! 每一处直接构造的**文件**必须命中 [`ALLOWED_ROLES`] 里的某一条角色;不在其中就是绕开了
//! `platform::child::quiet`。新加一处裸构造时,红的信息是「某文件某行不属于任何允许角色」,
//! 而不是「总数对不上 N」——所以它咬得住「随手再 spawn 一个命令」这种增量变异。
//! ## 判据(按角色,不按次数)
//!
//! 另外每个允许角色还钉了**恰好一处**:角色内部长出第二处裸构造同样会红(例如有人在
//! `platform/child.rs` 里绕开 `quiet` 再写一个 `Command::new`)。
//! 每一处直接构造的**文件**必须命中 [`ALLOWED_ROLES`] 里的某条角色,否则就是绕开了构造器;红的
//! 信息是「某文件某行不属于任何角色」而不是「总数对不上 N」,所以它咬得住「随手再 spawn 一个
//! 命令」。文件角色还钉**精确处数**;目录角色(`core/tests/` 这类)只要求至少一处,因为测试
//! 夹具的数量是活的,钉死会变成假红。
//!
//! ## 它抓不住什么(写在前面,别把它当完备保证)
//! ## 抓不住什么(别当完备保证)
//!
//! - **别名类型**:`type Cmd = std::process::Command;` 之后 `Cmd::new(..)`。本文件只认
//! `Command as <别名>` 与 `type <别名> = ...Command` 这两种写法导出的别名(认得的会拦),
//! 但 `type A = B; type B = Command` 这种两跳的、或从别的模块 `pub use` 进来的类型别名抓不住。
//! - **不经过 `::new` 的构造**:`std::process::Command` 目前只有 `new` / `default`,
//! 没有 `From`,所以实际风险低;真出现了本文件看不见。
//! - **只剥「整行注释」**:行尾注释里写 `Command::new` 会**误报**;行中开始的跨行块注释
//! (`let x = 1; /* …` 续行)之后那几行也会被当成代码,同样是**误报**。
//! 两个方向都是「宁可红」,与 M213 复审 P1-1 那个假绿方向相反。
//! - **空白**:判据是字面子串 `Command::new(` / `<别名>::new(`,不做空白归一化,
//! 所以 `Command :: new(..)` 看不见。`rustfmt` 会把它规范回去,只有不经 `rustfmt`
//! 的代码才有这个口子。
//! - **别的 crate**:`crates/cli` / `crates/presenter` / `crates/app-iced` 也可能 spawn 进程,
//! 本守卫的射程只有 `crates/core/src`(M213 的 scope)。
//! 复审已确认至少一处漏网:`crates/app-iced/src/platform/locale.rs:86` 的 `reg.exe`。
//! - **行为**:它只证明「代码里没有绕开构造器」,不证明「桌面上真的没闪窗口」。
//! 窗口本身的目视验证不在本会话能力内,见 mission 报告。
//! - **别名**:只认 `Command as <别名>` 与 `type <别名> = …Command`;两跳别名、从别的模块
//! `pub use` 进来的别名看不见。`Command` 没有 `From`,非 `::new` 的构造暂不存在。
//! - **注释与空白**:只剥整行 `//` 与**行首** `/*`;行尾注释里写 `Command::new`、行中开始的
//! 跨行块注释之后那几行会**误报**(方向「宁可红」,与 P1-1 的假绿相反)。判据是字面子串,
//! `Command :: new(..)` 看不见(`rustfmt` 会规范回去)。
//! - **射程**:根是 `crates/`;`packaging/*.py`、`.github/workflows/*` 里的 shell 不在内(父进程
//! 是控制台,GUI 下不闪窗),`crates/app`(Slint 退役树,非 workspace 成员)**在**内。
//! - **行为**:只证明「代码里没有绕开构造器」,不证明「桌面上真的没闪窗口」——本机无前台桌面,
//! 做不到目视验证(见 M217 报告)。
//!
//! ## 变异验证(四条,全部实测过并已还原)
//! ## 变异验证(全部实测过并已还原,只改实现侧)
//!
//! 1. 往 `download/region_system.rs` 加一处裸 `std::process::Command::new("tzutil")`
//! ⇒ [`no_spawn_site_bypasses_the_quiet_constructor`] 红(退出码 101),报文点名该行
//! (具体行号随夹具插在哪而变,第一轮 :163、复验 :162)。
//! 2. 同一处改写成别名形式(`use std::process::Command as Spawn;` + `Spawn::new("tzutil")`)
//! ⇒ 同样红,报文点名 `Spawn::new(...)`。
//! 3. 删掉 `platform/child.rs` 里那行 `command.creation_flags(CREATE_NO_WINDOW)`
//! ⇒ [`the_constructor_actually_passes_create_no_window`] 红。
//! 4. 把 `game/java/discover.rs:156` 的 `quiet("reg")` 改回裸 `std::process::Command::new("reg")`
//! ⇒ **必须**红(见下面 P1-1)。
//! M213(射程还是 `crates/core/src`):①`download/region_system.rs` 塞裸 `tzutil` ⇒ 红并点名该
//! 行;②同一处改写成别名 `Spawn::new(..)` ⇒ 同样红;③删 `platform/child.rs` 的
//! `creation_flags(CREATE_NO_WINDOW)` ⇒ [`the_constructor_actually_passes_create_no_window`] 红;
//! ④`game/java/discover.rs` 的 `quiet("reg")` 改回裸构造 ⇒ 必须红(见 P1-1)。
//!
//! ### P1-1 复发记录:守卫自己被变异验了两遍才对的
//! M217(落在**此前不在射程内**的 crate 上):⑤`presenter/src/host/env.rs` 塞裸
//! `Command::new("tzutil")` ⇒ 红并点名 `crates/presenter/src/host/env.rs:<行>`;⑥
//! `app-iced/src/platform/locale.rs` 的 `quiet(program)` 改回裸构造 ⇒ 红(用户报的那处
//! `reg.exe`);⑦`export.rs` 三个 opener 留裸 + 清单写明理由 ⇒ **不**红。
//!
//! 第 3 条第一次跑时**没红**:那版断言搜的是整份源码,而 `quiet` 的文档注释里就写着
//! `.creation_flags(CREATE_NO_WINDOW)`——注释把断言喂饱了。改成先剥注释([`code_lines`])再判。
//! ## P1-1:守卫自己被变异验了两遍才对的
//!
//! 然后第 4 条(复审判的变异 D)**又没红**:那版 `code_lines` 对每一行找 `/*`,找着就当块注释
//! 开头。可本仓库的文档注释里到处是 glob(`/usr/lib/jvm/*`、`rules.d/*.yaml`)、字符串里也有
//! (`"mods/*.jar"`),这些 `/*` 没有配对的 `*/`,于是 `in_block` 一旦为真就再也不会变假,
//! **该文件从那一行到 EOF 全部被吞掉**。实测 `crates/core/src` 里 **8/371 个文件**中招,其中
//! 一个正是 `game/java/discover.rs`——守卫从来没看见过它的 `reg query`。
//!
//! 现在的 [`code_lines`] 只在**行首**(去掉前导空白)认 `/*`,本仓库零处行首块注释,所以结构上
//! 不可能再吞代码;[`the_scan_reaches_the_end_of_every_file`] 专门盯住「整份文件被吞掉」这个
//! 故障(逐文件核对最后一行代码的行号必须出现在扫面结果里)。
//!
//! 两次都是同一个教训:**守卫本身要被变异验过,而且要验在不止一个文件上**——变异 A/B 落在
//! `region_system.rs`(那个文件恰好没中毒)才一直是绿的。
//! ③第一次**没红**:断言搜整份源码,而 `quiet` 的文档注释里就写着
//! `.creation_flags(CREATE_NO_WINDOW)`——注释喂饱了断言,改成先剥注释([`code_lines`])再判。
//! ④(复审判的变异 D)**又没红**:那版 `code_lines` 对每行找 `/*`,而注释与字符串里到处是
//! glob(`/usr/lib/jvm/*`、`"mods/*.jar"`),这些 `/*` 没有配对的 `*/` ⇒ `in_block` 一旦为真就
//! 不再变假,**该文件从那一行到 EOF 全被吞掉**(`crates/core/src` 里 8/371 个中招,一个正是
//! `game/java/discover.rs`)。现在只在**行首**认 `/*`,并由
//! [`the_scan_reaches_the_end_of_every_file`] 逐文件核对最后一行代码必须被扫到。
use std::fs;
use std::path::{Path, PathBuf};
/// 允许直接构造子进程的**角色**:`(相对 crates/core/src 的路径, 为什么这个角色可以)`。
///
/// 这张表是**角色**清单,不是「白名单文件」清单:新增一行必须写清「为什么这一处不能走
/// `quiet`」。`launch/process.rs` 那条就是全部理由——游戏进程要自己那块控制台。
/// `quiet`」。`launch/process.rs` 那条就是全部理由——游戏进程要自己那块控制台。
const ALLOWED_ROLES: &[(&str, &str)] = &[
(
"platform/child.rs",
"构造器自身:`child::quiet` 内部就是这一处 `Command::new`",
),
(
"launch/process.rs",
"游戏进程:要 CREATE_NEW_PROCESS_GROUP 撑 taskkill /T 回收进程树;\
/// 允许直接构造子进程的一条**角色**。
struct Role {
/// 相对 `crates/` 的路径。以 `/` 结尾 = 该目录下全部文件(目录角色)。
path: &'static str,
/// 期望的裸构造处数:`Some(n)` 精确核对;目录角色 `None` = 只要求至少一处。
sites: Option<usize>,
/// 为什么这一处不能走 `quiet`。文件角色写这一处,目录角色写这一整类。
why: &'static str,
}
impl Role {
/// 目录角色覆盖该目录下全部文件;文件角色按整条路径精确比。
fn covers(&self, rel: &str) -> bool {
(self.path.ends_with('/') && rel.starts_with(self.path)) || rel == self.path
}
}
/// 这张表是**角色**清单,不是「白名单文件」清单:新增一行必须写清「为什么不能走 `quiet`」。
const ALLOWED_ROLES: &[Role] = &[
Role {
path: "core/src/platform/child.rs",
sites: Some(1),
why: "构造器自身:`child::quiet` 内部就是这一处 `Command::new`",
},
Role {
path: "core/src/launch/process.rs",
sites: Some(1),
why: "游戏进程:要 CREATE_NEW_PROCESS_GROUP 撑 taskkill /T 回收进程树;\
游戏要不要带自己的控制台窗口是产品决定,不是顺手抑制(M213 明确不动)",
),
},
Role {
path: "app-iced/src/app/dialog/export.rs",
sites: Some(3),
why: "目录打开器三兄弟(explorer / open / xdg-open,各平台一个):用户点「打开文件夹」\
要的就是那个文件管理器窗口,窗口就是产品。这三者自己都是 GUI 侧程序,由无控制台的\
GUI 父进程 spawn 时本来就不分控制台;改错的代价是用户点按钮没反应(M217 要求留裸)",
},
Role {
path: "presenter/src/host/opener.rs",
sites: Some(1),
why: "浏览器打开器(rundll32 / open / xdg-open):与上一行同一角色——打开一个可见的东西\
是这次 spawn 的**目的**。M217 不改它(不在本 mission 的改动范围),只登记成角色,\
免得守卫把正确的实现判成漏网",
},
Role {
path: "core/tests/",
sites: None,
why: "测试夹具:父进程是控制台(`cargo test`),子进程继承同一块控制台,不会新建窗口;\
而且夹具要起 shell / tasklist / kill 之类的真程序做行为验证",
},
Role {
path: "cli/tests/",
sites: None,
why: "测试夹具(同上;这里还要起真二进制验 CLI 的退出码与 update 签名)",
},
];
#[test]
fn no_spawn_site_bypasses_the_quiet_constructor() {
let root = src_root();
let mut files = Vec::new();
collect_rust_files(&root, &root, &mut files);
files.sort();
let root = crates_root();
let files = rust_files(&root);
// 兜底一:扫不到任何文件 ⇒ 根路径错了。兜底二:根扫到「非空但不是 crates/」也红——拿角色
// 文件当锚点。
assert!(
!files.is_empty(),
"没扫到任何 .rs:{} 路径不对",
"没扫到任何 .rs:`crates/` 路径不对({})",
root.display()
);
for anchor in ["core/src/platform/child.rs", "core/src/launch/process.rs"] {
assert!(
files.iter().any(|rel| rel == anchor),
"扫描根不是 `crates/`:里面找不到 `{anchor}`(实际根:{})",
root.display()
);
}
// 一次扫面:`(相对 crates/ 的路径, 行号, 该行代码)`。
let mut sites: Vec<(String, usize, String)> = Vec::new();
for rel in &files {
let text = read_source(&root, rel);
@@ -101,41 +135,49 @@ fn no_spawn_site_bypasses_the_quiet_constructor() {
}
}
}
let illegal: Vec<String> = sites
.iter()
.filter(|(rel, ..)| !ALLOWED_ROLES.iter().any(|(path, _)| path == rel))
.map(|(rel, number, line)| format!(" src/{rel}:{number} {line}"))
.filter(|(rel, ..)| !ALLOWED_ROLES.iter().any(|role| role.covers(rel)))
.map(|(rel, number, line)| format!(" crates/{rel}:{number} {line}"))
.collect();
assert!(
illegal.is_empty(),
"发现绕开 `platform::child::quiet` 的子进程构造({} 处):\n{}\n\
在 Windows 上这会为每个子进程新建一块控制台(GUI 父进程下就是桌面上「黑框闪一下」)。\n\
改走 `crate::platform::child::quiet(program)`;若这一处确实不该抑制窗口,\
把它的**角色**加进本文件的 ALLOWED_ROLES 并写清理由。",
在 Windows 上这会为每个子进程新建一块控制台(GUI 父进程下就是桌面上「黑框闪一下」)。\
改走 `unlucky_core::platform::child::quiet(program)`;若这一处确实不该抑制窗口,\
把它的**角色**加进 ALLOWED_ROLES 并写清理由。",
illegal.len(),
illegal.join("\n"),
);
// 角色核对:允许的角色必须真的存在、且**恰好一处**,否则这张表会悄悄变成摆设。
for (path, role) in ALLOWED_ROLES {
let hits: Vec<String> = sites
// 角色核对:允许的角色必须真的存在、处数对得上,否则这张表会悄悄变成摆设。
for role in ALLOWED_ROLES {
let hits: Vec<usize> = sites
.iter()
.filter(|(rel, ..)| rel == path)
.map(|(.., number, line)| format!(":{number} {line}"))
.filter(|(rel, ..)| role.covers(rel))
.map(|(.., number, _)| *number)
.collect();
assert_eq!(
match role.sites {
Some(expected) => assert_eq!(
hits.len(),
1,
"角色 `src/{path}`({role})应当恰好一处直接构造,实测 {} 处:{hits:?}",
hits.len(),
);
expected,
"角色 `crates/{}`({})应当恰好 {expected} 处直接构造,实测行号 {hits:?}",
role.path,
role.why,
),
None => assert!(
!hits.is_empty(),
"目录角色 `crates/{}`({})一处都没命中——这条清单条目该删了",
role.path,
role.why,
),
}
}
}
#[test]
fn the_constructor_actually_passes_create_no_window() {
let text = read_source(&src_root(), "platform/child.rs");
let text = read_source(&crates_root(), "core/src/platform/child.rs");
// **只看代码行**:`quiet` 的文档注释里就写着 `.creation_flags(CREATE_NO_WINDOW)`,
// 对着整份源码搜会被那句注释喂饱——删掉真正的设置行也照样绿(实测踩过)。
let code: Vec<&str> = code_lines(&text)
@@ -143,11 +185,7 @@ fn the_constructor_actually_passes_create_no_window() {
.map(|(_, line)| line)
.collect();
// 只去空白,避免格式调整造成假红(下划线是标识符的一部分,不能去)。
let no_space: String = code
.concat()
.chars()
.filter(|c| !c.is_whitespace())
.collect();
let no_space = code.concat().split_whitespace().collect::<String>();
assert!(
no_space.contains("creation_flags(CREATE_NO_WINDOW)"),
"`platform::child::quiet` 不再设置 creation_flags —— 抑制窗口的那一行被删了?\n\
@@ -158,13 +196,9 @@ fn the_constructor_actually_passes_create_no_window() {
.iter()
.find(|line| line.trim_start().starts_with("const CREATE_NO_WINDOW"))
.expect("找不到 `CREATE_NO_WINDOW` 的常量定义");
let literal: String = definition
.split_once('=')
.expect("常量定义里没有 `=`")
.1
.chars()
.filter(|c| !c.is_whitespace() && *c != '_')
.collect();
let (_, value) = definition.split_once('=').expect("常量定义里没有 `=`");
let digits = value.replace('_', "");
let literal = digits.split_whitespace().collect::<String>();
assert_eq!(
literal.trim_end_matches(';'),
"0x08000000",
@@ -189,7 +223,7 @@ fn code_lines_is_not_fooled_by_glob_slashes_in_strings_or_docs() {
assert_eq!(
in_doc_glob.len(),
1,
"文档注释里的 glob 吞掉了真代码:{in_doc_glob:?}"
"注释里的 glob 吞掉了代码:{in_doc_glob:?}"
);
assert!(in_doc_glob[0].1.contains("fn real_code"));
@@ -212,87 +246,86 @@ fn code_lines_is_not_fooled_by_glob_slashes_in_strings_or_docs() {
fn the_scan_reaches_the_end_of_every_file() {
// 「整份文件被吞掉」这类故障自己红:逐文件核对**最后一行代码的行号**必须出现在扫面结果里。
// 这条比「数总行数」尖锐——文件普遍以 `}` 结尾,光比内容会被别处的 `}` 蒙混过去。
let root = src_root();
let mut files = Vec::new();
collect_rust_files(&root, &root, &mut files);
files.sort();
// 只有 `//!` 的结论页(`app-iced/src/platform/linux/mod.rs` 这类)没有代码行,跳过是对的。
let root = crates_root();
let files = rust_files(&root);
assert!(!files.is_empty(), "没扫到任何 .rs:{}", root.display());
let mut visible_total = 0usize;
let mut line_total = 0usize;
let (mut visible_total, mut line_total) = (0usize, 0usize);
let mut starved: Vec<String> = Vec::new();
for rel in &files {
let text = read_source(&root, rel);
let visible = code_lines(&text);
visible_total += visible.len();
line_total += text.lines().count();
if visible.is_empty() {
starved.push(format!("src/{rel}:整份文件没有一行可见代码"));
continue;
let mut last_code = None;
for (index, line) in text.lines().enumerate() {
if looks_like_code(line) {
last_code = Some((index + 1, line));
}
let lines: Vec<&str> = text.lines().collect();
let Some((last_index, last_line)) = lines.iter().enumerate().rev().find(|(_, line)| {
let trimmed = line.trim();
!trimmed.is_empty() && !trimmed.starts_with("//")
}) else {
starved.push(format!("src/{rel}:整份文件只有注释?"));
}
let Some((number, line)) = last_code else {
continue;
};
let last_number = last_index + 1;
if !visible.iter().any(|(number, _)| *number == last_number) {
if !visible.iter().any(|(seen, _)| *seen == number) {
starved.push(format!(
"src/{rel}:最后一行代码 `{}`(第 {last_number} 行)没被扫到 ⇒ 被吞到 EOF",
last_line.trim()
"crates/{rel}:最后一行代码 `{}`(第 {number} 行)没被扫到 ⇒ 被吞到 EOF",
line.trim()
));
}
}
assert!(starved.is_empty(), "扫面没走到底:\n{}", starved.join("\n"));
// 兜底:全仓可见行占比实测约 81%,门槛取 60% 留足余量。它只抓「大面积吞行」。
// 兜底:可见行占比实测 81%(1219 个 .rs),门槛取 60% 留足余量。它只抓「大面积吞行」。
assert!(
visible_total * 10 >= line_total * 6,
"可见行只有 {visible_total}/{line_total}(<60%),扫面异常缩水",
);
}
fn src_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("src")
/// 「这一行看起来是代码」:非空,且不是以 `/`(`//`、`/*`)或 `*`(`*/`、续行)开头。
fn looks_like_code(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty() && !trimmed.starts_with(['/', '*'])
}
/// `crates/`(`CARGO_MANIFEST_DIR` 的上一级)。守卫的射程就是它,不多不少。
fn crates_root() -> PathBuf {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let crates = manifest.parent().expect("`crates/core` 没有上一级");
crates.to_path_buf()
}
fn read_source(root: &Path, rel: &str) -> String {
fs::read_to_string(root.join(rel)).unwrap_or_else(|error| panic!("读不到 src/{rel}:{error}"))
fs::read_to_string(root.join(rel))
.unwrap_or_else(|error| panic!("读不到 crates/{rel}:{error}"))
}
fn collect_rust_files(root: &Path, dir: &Path, out: &mut Vec<String>) {
fn rust_files(root: &Path) -> Vec<String> {
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
let entries =
fs::read_dir(dir).unwrap_or_else(|error| panic!("读不到 {}:{error}", dir.display()));
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_rust_files(root, &path, out);
walk(root, &path, out);
} else if path.extension().is_some_and(|ext| ext == "rs") {
let rel = path.strip_prefix(root).expect("strip_prefix 失败");
out.push(rel.to_string_lossy().replace('\\', "/"));
}
}
}
let mut files = Vec::new();
walk(root, root, &mut files);
files.sort();
files
}
/// 去掉注释后的可见行(行号保留,报错能指到原文件)。
///
/// ## `/*` **只在行首**才算块注释开头(M213 复审 P1-1)
///
/// 上一版对每一行找 `/*`,找着就当块注释开头。可本仓库的文档注释里到处是 glob
/// (`/usr/lib/jvm/*`、`rules.d/*.yaml`、`crash-reports/*.txt`、`.github/workflows/**`),
/// 字符串里也有(`"mods/*.jar"`)。这些 `/*` 没有配对的 `*/`,于是 `in_block` 一旦为真就再也
/// 不会变假,**该文件从那一行到 EOF 全部被吞掉**——`crates/core/src` 里 8/371 个文件如此,
/// 其中一个正是 `game/java/discover.rs`(它自己有一处 `reg query`)。复审判的变异 D
/// (把它改回裸构造)因此**全绿**:守卫根本没看见那处 spawn。
///
/// 现在只在「去掉前导空白后以 `/*` 开头」的行上进入块注释态,行中/字符串里的 `/*` 一律当普通
/// 代码。本仓库没有任何行首块注释(`grep -rn '^[[:space:]]*/\*' crates/core/src` 零命中),
/// 所以这条状态机在本仓库里实际只走「剥 `//` 整行注释」那一条路,**结构上不可能再吞代码**。
///
/// 代价见文件头:行中开始的跨行块注释之后那几行会被当成代码,方向是**误报**。
/// **`/*` 只在行首才算块注释开头**(M213 复审 P1-1):本仓库的文档注释与字符串里到处是 glob
/// (`/usr/lib/jvm/*`、`"mods/*.jar"`),行中认 `/*` 会让 `in_block` 一去不回,把该文件从那
/// 里到 EOF 全吞掉。代价是行中开始的跨行块注释之后那几行会被当成代码(方向:误报)。
fn code_lines(text: &str) -> Vec<(usize, &str)> {
let mut visible: Vec<(usize, &str)> = Vec::new();
let mut in_block = false;
@@ -334,7 +367,6 @@ fn push_tail<'a>(visible: &mut Vec<(usize, &'a str)>, number: usize, tail: &'a s
fn command_aliases(text: &str) -> Vec<String> {
let mut aliases = Vec::new();
for (_, line) in code_lines(text) {
let trimmed = line.trim_start();
// `use std::process::Command as Spawn;` / `use std::process::{Command as Spawn};`
if let Some(at) = line.find("Command as ") {
let ident: String = line[at + "Command as ".len()..]
@@ -347,8 +379,8 @@ fn command_aliases(text: &str) -> Vec<String> {
continue;
}
// `type Cmd = std::process::Command;` —— 别名在 `=` 的**左边**。
let is_command_type_alias = trimmed.starts_with("type ") && line.ends_with("Command;");
if is_command_type_alias {
let trimmed = line.trim_start();
if trimmed.starts_with("type ") && line.ends_with("Command;") {
if let Some(ident) = trimmed
.split_whitespace()
.find(|word| *word != "type" && !word.contains('='))
@@ -361,10 +393,8 @@ fn command_aliases(text: &str) -> Vec<String> {
}
fn is_spawn_site(line: &str, aliases: &[String]) -> bool {
if line.contains("Command::new(") {
return true;
}
aliases
line.contains("Command::new(")
|| aliases
.iter()
.any(|alias| line.contains(&format!("{alias}::new(")))
}