From f8acb50156677f16eeace365088d98b9029c7640 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 19:53:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20=E5=AD=90=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=B5=B0=20platform::child::quiet=EF=BC=88?= =?UTF-8?q?=E6=B6=88=E7=81=AD=E5=90=AF=E5=8A=A8=E9=97=AA=E7=AA=97=EF=BC=89?= =?UTF-8?q?+=20=E5=AE=88=E5=8D=AB=E5=B0=84=E7=A8=8B=E6=89=A9=E5=88=B0?= =?UTF-8?q?=E6=95=B4=E6=A3=B5=20crates/=EF=BC=88M217=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报「CI 构建版启动时底下闪一个控制台」:M213 统一了 crates/core/src 的 20 处 Command::new,但守卫射程只有 core/src,app-iced 的 reg.exe(每次 GUI 启动都跑)漏网。 - crates/core/tests/child_quiet_guard.rs:扫描根 crates/core/src → crates/(1219 个 .rs), 角色清单改成相对 crates/ 的路径(文件角色钉精确处数,目录角色至少一处); 「扫不到文件」兜底断言保留,另加角色文件锚点兜底;顺带修掉全树扫描会撞上的 「纯 //! 结论页没有代码行」误判 - app-iced:platform/locale.rs 的 reg.exe、app/update/update_action.rs、 app/boot/fallback.rs、app/boot/termux_window.rs 的 xdpyinfo、 renderer/acceptance/locale.rs 的 defaults 全部改走 quiet(后三处是同一 exe / POSIX 上的 pass-through,为的是规则整体可被守卫钉住) - app/dialog/export.rs 的 explorer / open / xdg-open 保持裸构造并写清「窗口就是产品」, presenter/src/host/opener.rs 同理登记成角色(不在本 mission 改动范围) 门禁:fmt/check --workspace --all-targets/test --workspace -j 4/clippy --workspace --all-targets -D warnings/全仓 400 行 + <2 字节 .rs 扫描,六步退出码全 0,CHAIN_EXIT=0。 --- crates/app-iced/src/app/boot/fallback.rs | 5 +- crates/app-iced/src/app/boot/termux_window.rs | 8 +- crates/app-iced/src/app/dialog/export.rs | 12 + .../app-iced/src/app/update/update_action.rs | 7 +- crates/app-iced/src/platform/locale.rs | 8 +- .../src/renderer/acceptance/locale.rs | 4 +- crates/core/tests/child_quiet_guard.rs | 346 ++++++++++-------- 7 files changed, 225 insertions(+), 165 deletions(-) diff --git a/crates/app-iced/src/app/boot/fallback.rs b/crates/app-iced/src/app/boot/fallback.rs index d373f0a..2c3fe06 100644 --- a/crates/app-iced/src/app/boot/fallback.rs +++ b/crates/app-iced/src/app/boot/fallback.rs @@ -108,7 +108,10 @@ pub(super) fn fallback_command( args: impl IntoIterator, 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()) diff --git a/crates/app-iced/src/app/boot/termux_window.rs b/crates/app-iced/src/app/boot/termux_window.rs index b75ba4f..079841f 100644 --- a/crates/app-iced/src/app/boot/termux_window.rs +++ b/crates/app-iced/src/app/boot/termux_window.rs @@ -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; } diff --git a/crates/app-iced/src/app/dialog/export.rs b/crates/app-iced/src/app/dialog/export.rs index 13b83a8..f44f79b 100644 --- a/crates/app-iced/src/app/dialog/export.rs +++ b/crates/app-iced/src/app/dialog/export.rs @@ -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:路径里的空格与 `&` 不会被当成命令分隔符。 diff --git a/crates/app-iced/src/app/update/update_action.rs b/crates/app-iced/src/app/update/update_action.rs index 7c0d47a..a3ace85 100644 --- a/crates/app-iced/src/app/update/update_action.rs +++ b/crates/app-iced/src/app/update/update_action.rs @@ -71,7 +71,12 @@ impl App { match std::env::current_exe() { Ok(exe) => { let args: Vec = 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(), diff --git a/crates/app-iced/src/platform/locale.rs b/crates/app-iced/src/platform/locale.rs index bd28142..0ec4188 100644 --- a/crates/app-iced/src/platform/locale.rs +++ b/crates/app-iced/src/platform/locale.rs @@ -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()?; diff --git a/crates/app-iced/src/renderer/acceptance/locale.rs b/crates/app-iced/src/renderer/acceptance/locale.rs index 64c73b5..660fd73 100644 --- a/crates/app-iced/src/renderer/acceptance/locale.rs +++ b/crates/app-iced/src/renderer/acceptance/locale.rs @@ -249,7 +249,9 @@ impl LocaleSource for AppleLanguages { fn locale(&self) -> Option { #[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()?; diff --git a/crates/core/tests/child_quiet_guard.rs b/crates/core/tests/child_quiet_guard.rs index 6d47b15..3f994be 100644 --- a/crates/core/tests/child_quiet_guard.rs +++ b/crates/core/tests/child_quiet_guard.rs @@ -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 回收进程树;\ - 游戏要不要带自己的控制台窗口是产品决定,不是顺手抑制(M213 明确不动)", - ), +/// 允许直接构造子进程的一条**角色**。 +struct Role { + /// 相对 `crates/` 的路径。以 `/` 结尾 = 该目录下全部文件(目录角色)。 + path: &'static str, + /// 期望的裸构造处数:`Some(n)` 精确核对;目录角色 `None` = 只要求至少一处。 + sites: Option, + /// 为什么这一处不能走 `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 = 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 = sites + // 角色核对:允许的角色必须真的存在、处数对得上,否则这张表会悄悄变成摆设。 + for role in ALLOWED_ROLES { + let hits: Vec = sites .iter() - .filter(|(rel, ..)| rel == path) - .map(|(.., number, line)| format!(":{number} {line}")) + .filter(|(rel, ..)| role.covers(rel)) + .map(|(.., number, _)| *number) .collect(); - assert_eq!( - hits.len(), - 1, - "角色 `src/{path}`({role})应当恰好一处直接构造,实测 {} 处:{hits:?}", - hits.len(), - ); + match role.sites { + Some(expected) => assert_eq!( + 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::(); 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::(); 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 = 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) { - 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); - } 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('\\', "/")); +fn rust_files(root: &Path) -> Vec { + fn walk(root: &Path, dir: &Path, out: &mut Vec) { + 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() { + 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 { 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 { 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 { } fn is_spawn_site(line: &str, aliases: &[String]) -> bool { - if line.contains("Command::new(") { - return true; - } - aliases - .iter() - .any(|alias| line.contains(&format!("{alias}::new("))) + line.contains("Command::new(") + || aliases + .iter() + .any(|alias| line.contains(&format!("{alias}::new("))) }