fix(app-iced): 子进程统一走 platform::child::quiet(消灭启动闪窗)+ 守卫射程扩到整棵 crates/(M217)
用户报「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。
This commit is contained in:
1 parent
7521c47944
commit
f8acb50156
7 files changed
+213
-153
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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,64 +246,68 @@ 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('\\', "/"));
|
||||
@@ -277,22 +315,17 @@ fn collect_rust_files(root: &Path, dir: &Path, out: &mut Vec<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
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(")))
|
||||
}
|
||||
Reference in new issue
Block a user