From 3e522798e52697ea31cded1134d84497d9f73039 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:29:31 +0800 Subject: [PATCH 01/10] ci: bootstrap pin -> 2026.8.17.1, and the round's report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin moves only now, in the order the release checklist requires: released → mirrored (GitHub + GitCode, both GET-verified byte-for-byte) → in the index (openxlings/xim-pkgindex#643, merged). A pin ahead of what the index actually has fails every CI job's bootstrap with "package not found". The report records what shipped, what was deliberately not done and why, and the three things this round ran into that were not in the plan — including a clang crash on two targets at once, and a page of the docs that contradicted itself about a key that does not exist. --- ...6-08-17-windows-three-axes-final-report.md | 221 ++++++++++++++++++ .xlings.json | 2 +- 2 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 .agents/docs/2026-08-17-windows-three-axes-final-report.md diff --git a/.agents/docs/2026-08-17-windows-three-axes-final-report.md b/.agents/docs/2026-08-17-windows-three-axes-final-report.md new file mode 100644 index 00000000..f1fc014d --- /dev/null +++ b/.agents/docs/2026-08-17-windows-three-axes-final-report.md @@ -0,0 +1,221 @@ +# Windows 三条轴:落地报告(mcpp 2026.8.17.1) + +> 设计:`2026-08-16-windows-toolchain-three-axes-design.md`(§6.5 记录逐条状态) +> 实现:mcpp #448(单 PR,含设计文档本身) +> 前一轮:`2026-08-16-msvc-ecosystem-final-report.md` + +--- + +## 0. 一句话 + +上一轮给**编译器**装上了版本轴;这一轮把它编译时用的**头文件**和产物加载的 +**运行时**也各自变成了**声明出来、解析一次**的值 —— 并且顺手让 `mcpp pack` +不再需要**运行产物**才能知道产物依赖什么。 + +--- + +## 1. 三条轴,分别落在哪 + +| 轴 | 之前 | 现在 | +|---|---|---| +| **来源** 编译器哪来的 | ✅ 已建模,代价摊在 ~30 处分支 | 解析一次(`Origin`),三处重复各消掉一份 | +| **SDK** 用哪套头/导入库 | ❌ **靠搜**,两种来源同一条链 | **按来源绑定**;受管 toolset 忽略环境并说出来 | +| **运行时分发** 产物带不带 vcruntime | ❌ 对 MSVC **一律拒绝** | PE 上 `toolchain-coupled` 真正成立 | +| **打包**(轴三的下游) | ❌ Windows 硬拒绝;跨不了 OS/架构 | 静态读导入表,任何宿主都能给 PE 打 zip | + +### §2 SDK:绑定而不是搜索 + +`find_windows_sdk()` 过去按 `WindowsSdkDir` → 兄弟 store → 常规路径扫,**两种来源 +走同一条链**。于是被 pin 住的 `msvc@` 是一个**环境可以悄悄改写**的 pin, +两台机器可以用两套 SDK 编同一份 manifest,而日志里没有一行提到 SDK。这不是假设: +这一轮之前追的那个 LNK1104,就是一个只解包了一半的 payload 因为版本号更高赢了这场 +扫描。 + +``` +msvc@ → 随该 toolset 装进 store 的 windows-sdk payload。 + WindowsSdkDir / WindowsSdkVersion 被忽略 —— 并且打印 note。 +msvc@system → 维持今天的搜索链。机器上的东西只能靠找,而在那里 + "明确声明"应当压过"扫描"。 +``` + +旁边没有 SDK payload 的受管 toolset **仍然**退回机器的 SDK —— 能用 > 失败 —— +并且**说出来**,因为那次构建已经不可复现,而除此之外没有东西会记录它。 + +### §2.3 `ucrt@`:填一个从字段诞生起就留着的槽 + +`RuntimeBinding::runtimeId` 的注释从一开始就写着 +*"(`glibc@…`, `macos_sdk@…`, `ucrt@…`)"*,而仓库里从来没有一处写过 `ucrt@`。 +于是 SDK 版本进不了 `runtimeContractHash`,**两套 SDK 共用一个构建缓存键**。 + +**它和 `glibc@` 不同构,而且这一点写在会被读到的地方**: + +| | `glibc@2.39` | `ucrt@10.0.26100.0` | +|---|---|---| +| 绑的是 | 一个 **payload**,头和 `.so` 都在里面 | — | +| 能真绑上去吗 | ✅ patchelf 让产物真跑在那份上 | ❌ `ucrtbase.dll` 是 OS 组件,换不掉也不该发 | +| 于是标识的含义 | **运行时绑定** | **兼容性下限声明** | + +所以它**不**投影进 `libc` —— 否则私有 libc 那套机制会去找一个本就不该存在的 +payload。各处 `starts_with("glibc@")` 改成 `runtime_provider()` 分派:另一个 +provider 读起来是"这里没有规则",而不是"没有身份"。 + +**§2.4(manifest 的 SDK 版本键)明确不做** —— 它会和 `_WIN32_WINNT` 并排,看起来 +可以互相替代,实际管的是不同的事。 + +### §3.3 PE 上的 `toolchain-coupled` 现在有意义 + +原来的拒绝语是 MSVC 运行时"随 OS/redistributable 分发,而不是随工具链"。 +对 `ucrtbase.dll` 是对的;对 `vcruntime140.dll` / `msvcp140.dll` 是**错的** —— +它们躺在每个 toolset 的 `VC\Redist\MSVC\…` 里,正是 gcc 和 `libstdc++.so` 的关系。 + +于是它拿同一个契约。PE 没有 rpath,所以**机制**是拷到产物旁而不是加一条搜索路径 —— +同一个契约,不同的机制,这正是三层模型存在的理由。 + +`/MT` 仍然是降级,而且是**真正的矛盾**:静态 CRT 根本没有 DLL 可以耦合,消息会说清 +是哪一边赢了。DLL 集合来自 `vc_redist_dir()` —— 排除 `debug_nonredist\`(不可再分发) +的**唯一**判据。在这里再写一条按名字的规则,可能和它不一致,而在**这件事**上不一致 +是许可问题,不是 bug。 + +### §4 打包:读导入表,别运行产物 + +`mcpp pack` 用 `#if defined(_WIN32)` 拒绝 Windows,理由写的是工具是 POSIX-only。 +那是症状。闭包来自 + +``` +LD_TRACE_LOADED_OBJECTS=1 '' +``` + +—— 它**要把产物跑起来**,所以既跨不了 OS,也跨不了**架构**。移植 `tar` 没有用,而 +2026-05 那份设计提的每一个工具(dumpbin、`ImageNtHeader`、`Compress-Archive`)都会 +把障碍**下移一层**,因为它们都只存在于问题已经消失的那个平台上。 + +- `mcpp.pack.binfmt` —— ELF `DT_NEEDED`(经段表翻译);PE 导入表**和延迟导入表** + (少一个延迟导入不会在启动时失败,而是在第一次调用它时失败,更难查) +- `mcpp.pack.zip` —— mcpp 自己写压缩包,理由相同:没有哪个 zip 工具在每个宿主上都 + 存在。条目 **stored** 不压缩,这是真实代价和诚实的取舍:DEFLATE 编码器是这里唯一 + 可能产出**解出来是错的**而不是响亮失败的部分,而 mcpp 没有 zlib 可借。**确定性**: + 不读任何时间戳,所以公布校验和才有意义 +- **§4.3 契约终于到达打包这一步**:以前它止步于编译/链接旗标,决定"哪些文件真的跟着 + 走"的那一步看不见承诺过什么 —— ELF 上 `ldd` 闭包**碰巧**一致,PE 上没有任何东西一致 + +**明确没做的一半,连同代价**:ELF 闭包**仍然**运行产物。`ldd` 交回的是**已解析的 +路径**,`DT_NEEDED` 只有名字,把名字变成路径要重新实现 loader 的搜索顺序 +(`$ORIGIN`、`DT_RPATH` → `LD_LIBRARY_PATH` → `DT_RUNPATH` → `ld.so.cache`、hwcaps)。 +在一条**已经正确、有 e2e 覆盖**的路径上重写它,风险大于收益 —— 于是**跨架构的 ELF +打包仍然不支持**,这正是 §4.1 指出的第二个限制。 + +### §1 收拢来源轴,而不是推广它 + +- **`gcc@system` / `llvm@system` 在被读到的地方就拒绝**,并同时给出两种可能的本意。 + 它们过去能解析,然后在别处以 `xim:gcc@system` → "no such package" 失败,把读者 + 引向一个根本不会存在的版本。`msvc@system` 是对**一个平台**的让步,不是别的族缺失 + 的能力;不带族的 `system` 逃生口原样保留 +- **spec 过去在相隔十几行的地方被解析了两遍**,各自下结论。现在一次,`origin_of()` 分派 +- `resolve_managed_msvc()` 取代两份手写的"受管 toolset 在哪、为什么不能用 fetcher 的 + `root`" —— 而理由只写在其中一份里 +- `needs_linux_sysroot_payloads()` 取代同一条规则的两种拼写,其中一份的注释声称它们 + 互为镜像。**它们不是** —— 少了 PE 那一项。今天不可达,这正是它活下来的原因 +- 工具链解析顺序**被写了两遍**,一处说 3 步一处说 4 步,合起来点到 9 个真实输入里的 + 5 个,还互相矛盾。现在一张表,按 `TcOrigin` 的枚举名写,不会悄悄失配 + +--- + +## 2. 验证 + +### 2.1 单元测试:31 个新增,两个**故意不能被"CI 绿了"满足** + +- `WindowsSdkDirCannotOverrideAPinnedToolsetsSdk` —— 设计 §6 的验收判据写成单元测试。 + 把 `WindowsSdkDir` 指到别处,payload 的 SDK 必须仍然赢,**并且** note 必须说出变量 + 被忽略了。一个被**静默**忽略的覆盖,和一个从未设置过的覆盖无法区分 +- `NothingElseAsksForStagedRuntimeFiles` —— 那个 deploy 标志会到达一个拷贝步骤,所以 + 一个多余的 `true` 会在一个根本没有这种东西的平台上往产物目录里放 DLL。扫过 + (format × stdlib × contract × /MT × explicit) 的每一格 + +### 2.2 e2e 240:**在 Linux 上**给 PE 打包 + +放在 mingw-cross job 里,因为**在 Windows 上跑它什么也证明不了**。它构建一个真正的 +交叉 PE,放一个该 EXE 会导入的 DLL 的替身,打包,然后由 **Python** 独立验证压缩包: + +- `msvcrt.dll` 在 —— **正面**那一半。一个什么都没读到的解析器**产不出**它 +- `kernel32.dll` 不在 —— **反面**那一半。单独看,一个什么都没读到的解析器也能通过; + 两条合起来才是决定性的 +- `--mode system` 不放任何 DLL;`toolchain-coupled` + `--mode system` 被拒绝,消息里 + 同时有契约名和出路 + +### 2.3 本机真实验证 + +在这台 Linux 上,`mcpp pack --target x86_64-windows-gnu` 产出的 zip 被 +`python3 -m zipfile` 和 `unzip -t` 双双接受,内含 exe 和解析出来的 DLL,没有别的。 +`--format dir`、`--mode self-contained`、`--mode static --target …` 逐一验过。 + +### 2.4 CI + +mcpp #448:**19 项全绿**(Linux / macOS ARM64 / Windows × build+unit+e2e+toolchains、 +hermetic 容器、四条 cross-build、xlings 集成)。 + +--- + +## 3. 路上撞到的三件事(都不在计划里) + +### 3.1 clang 在**两个目标上同时**出问题 + +同一份新代码,gcc 到处都绿,而 clang: + +| | 现象 | +|---|---| +| Windows | clang 20.1.7(MSVC ABI)**编译** mcpp.pack 时段错误,0xC0000005,无诊断,五个 job 同时红 | +| macOS ARM64 | `test_pack_binfmt` 在**运行**时 SIGSEGV | + +**解析器不是问题,而且这是量出来的**:同一份代码在 clang 22.1.8 + libc++ 下 +ASan+UBSan 干净,在 x86_64 Linux 上**作为 clang 模块**编译并运行正确(-O0/-O2)。 + +处理方式沿用本仓库已有的判例(`hostflags.cppm` 的开头注释:往一个模块的匿名命名空间里 +加一个**没被使用**的函数,会让**相邻**函数被误编译;结论是"机制未知,复现稳定,便宜的 +反应是别往那个命名空间里加东西")。于是移除形状、保留行为:跨模块的作用域枚举做导出 +结构体的默认成员、ranges 投影取导入类型的成员、跨模块边界的 `std::span`、模块 purview +里的函数模板 —— 全部换成更简单的等价写法。**哪一个是原因并未确定,注释里就是这么写的**, +而不是编造一个结论。 + +macOS 那一半后来被**拆分测试**定位到了:崩在 `ThePeFixtureItselfIsWellFormed` —— +**完全不碰任何模块**的测试夹具代码。把夹具改写成最朴素的形式(不用 span 参数、不用捕获 +可变字符串的 lambda)之后消失。夹具现在带一个 `MCPP_TEST_TRACE=1` 才开的分步 trace: +如果它再来,一轮 CI 就能定位,而不是这次的四轮。 + +### 3.2 一处文档自相矛盾 + +`docs/03-toolchains.md` 的 MinGW 段说 `[build] linkage` 这个键不存在、会被静默忽略; +两百行之后的 MSVC 段**恰好**把它当成选 `/MT` 的写法展示。是照着文档写了一遍、看着 mcpp +打印 `unsupported key 'linkage' (ignored)` 才发现的 —— 而跟着这一页做的用户,得到的也是 +这个,只是没人告诉他为什么什么都没变。两个语种都已修正。 + +### 3.3 `--mode static` 会覆盖用户点名的 target + +强制 musl 的那次重新 prepare 忽略了 `opts.targetTriple`,于是 +`--mode static --target x86_64-windows-gnu` 悄悄变成一次 **Linux** 构建。在 PE 打包 +还不存在时这是看不见的 —— 根本没有 Windows 包可以让人发现它缺了 —— 而现在它是个错误答案。 + +--- + +## 4. 留下的账(都写清楚了,没有藏) + +| # | 事项 | 为什么这次不做 | +|---|---|---| +| 1 | **macOS 上的 `mcpp pack` 会执行用户的产物** | `ldd_parse` 在 macOS 上等于直接跑二进制(`LD_TRACE_LOADED_OBJECTS` 在那里不是环境变量)。两个 pack e2e 都 `requires: pack patchelf elf`,即 **macOS 上完全没有覆盖**,改一条没有测试的路径无法验证。`binfmt` 已经识别 Mach-O,补 `LC_LOAD_DYLIB` 读取是自然的下一步 | +| 2 | **跨架构 ELF 打包**仍不支持 | 见 §4「明确没做的一半」 | +| 3 | `[pack] include` / `exclude` 被解析、存进 Plan,**从未被消费** | 早于本轮;文档把它们当作已有功能。属于 pack 的另一条轴,不在三条轴的范围内 | +| 4 | Windows CI 仍用 `7z a -tzip` 手工打包 release | 那是一个自带 `registry/` 的定制布局,不是 pack 的输出。现在 `mcpp pack` 能产 zip 了,迁移是可能的,但那是发布流程的改动 | +| 5 | Windows 宿主 → Linux target 时 `dist::Format` 仍解析为 PE | 早于本轮。本轮的 triple 判据**只新增答案**(说不出 OS 的 triple 走原推导),刻意没有动这一条:它会改变一个正在通过的 CI job 的旗标,而对这三条轴没有好处 | + +--- + +## 5. 迁移(用户视角) + +| 改动 | 用户可见格式 | 迁移 | +|---|---|---| +| `ucrt@` 进 `runtimeContractHash` | ⚠️ 缓存键变 | **Windows 构建缓存重建一次**,和任何 contract 变更同类 | +| 受管 toolset 绑定 SDK | 行为,非格式 | pinned toolset 上 `WindowsSdkDir` 从"生效"变成"忽略 + 报告" | +| 拒绝 `gcc@system` | ⚠️ 之前"未实现",现在显式错误 | 消息给出两种替代写法 | +| PE `toolchain-coupled` / PE pack | 新增能力 | 之前是 degraded / 硬错误 | +| `--mode static --target ` | ⚠️ 之前静默变成 Linux 构建 | 现在按 target 走 | + +其余全部是内部改动。 diff --git a/.xlings.json b/.xlings.json index 8e9bb512..c72e01d3 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": "2026.8.15.1" + "mcpp": "2026.8.17.1" } } From 762b3780f59cc4f017b6b89c79dfcdeefd2c5d05 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:31:39 +0800 Subject: [PATCH 02/10] docs: record the ecosystem verification against the released binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not the local build and not this machine: `xlings install mcpp@2026.8.17.1` from the index, into a SubOS created for it, every command through `--sandbox`. Including the one that matters most — a Windows zip packed from inside that Linux sandbox, verified by Python's own zipfile reader. Also records that the local-`gtc` gitcode fill was not needed this time: the mirror leg passed on its own and was re-checked with a real GET. --- ...6-08-17-windows-three-axes-final-report.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.agents/docs/2026-08-17-windows-three-axes-final-report.md b/.agents/docs/2026-08-17-windows-three-axes-final-report.md index f1fc014d..7c5f9d26 100644 --- a/.agents/docs/2026-08-17-windows-three-axes-final-report.md +++ b/.agents/docs/2026-08-17-windows-three-axes-final-report.md @@ -153,6 +153,31 @@ LD_TRACE_LOADED_OBJECTS=1 '' mcpp #448:**19 项全绿**(Linux / macOS ARM64 / Windows × build+unit+e2e+toolchains、 hermetic 容器、四条 cross-build、xlings 集成)。 +### 2.5 生态真实验证:**发布出去的那个二进制**,在沙盒里 + +不是本地构建产物,而是 `xlings install mcpp@2026.8.17.1` 从索引装下来的那一个; +不是这台开发机,而是 `xlings subos new` 出来的**全新 SubOS**,并且每一条都跑在 +`xlings subos use --sandbox --cmd "…"` 里面。 + +| 验证 | 结果 | +|---|---| +| 从索引安装 | ✓ `xim:mcpp@2026.8.17.1` | +| `new` → `build` → `run` | ✓ `Hello from ecoproj!`(import std + C++23) | +| `mcpp test` | ✓ 1 passed | +| `mcpp pack`(ELF,未改动的路径) | ✓ `ecoproj-0.1.0-x86_64-linux-gnu.tar.gz` | +| **`mcpp pack --target x86_64-windows-gnu`** | ✓ 在这个 **Linux 沙盒**里产出 `ecowin-0.1.0-x86_64-w64-mingw32.zip`,`zipfile.testzip()` 通过 | +| `mcpp self doctor` | ✓ **all checks passed** | +| `gcc@system` | ✓ 被拒绝,消息里同时给出 pin 与 PATH 逃生口两种写法 | +| 裸 `system` 逃生口 | ✓ 仍解析到宿主 gcc 13.3.0,然后因为**不相干且正确**的理由失败(那个 gcc 没有 `import std`)—— 这正是"没有被破坏"的样子 | + +**发布链路**同样逐段核过:四平台产物齐全 → 镜像 `all assets mirrored + verified on +2 host(s) in 488s` → 用**真实 GET**(不是 `curl -I`,它在 gitcode 上会骗人)复核四个 +归档与上游**逐字节同尺寸** → 索引 PR openxlings/xim-pkgindex#643 合入 → +`xlings update` 后可安装。 + +> 上一轮预留的「本地 `gtc` 补 gitcode 资源」这次**没有用到**:镜像那一条腿自己过了。 +> 授权仍在,只是没有需要修的东西。 + --- ## 3. 路上撞到的三件事(都不在计划里) From ff99a888580b10d080f98e973f3b13087d54f77e Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:34:15 +0800 Subject: [PATCH 03/10] docs: one more finding from the ecosystem run, with its provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpp new` + `mcpp add compat.gtest@1.15.2` does not build: the scaffolded test carries its own main() and the dependency links gtest_main.o into every test target. Found while verifying the ecosystem, and reproduced on the PREVIOUS release (2026.8.16.3) in the same sandbox — so it is recorded as pre-existing, not as something this round did. --- .agents/docs/2026-08-17-windows-three-axes-final-report.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.agents/docs/2026-08-17-windows-three-axes-final-report.md b/.agents/docs/2026-08-17-windows-three-axes-final-report.md index 7c5f9d26..332c8e88 100644 --- a/.agents/docs/2026-08-17-windows-three-axes-final-report.md +++ b/.agents/docs/2026-08-17-windows-three-axes-final-report.md @@ -229,7 +229,8 @@ macOS 那一半后来被**拆分测试**定位到了:崩在 `ThePeFixtureItselfI | 2 | **跨架构 ELF 打包**仍不支持 | 见 §4「明确没做的一半」 | | 3 | `[pack] include` / `exclude` 被解析、存进 Plan,**从未被消费** | 早于本轮;文档把它们当作已有功能。属于 pack 的另一条轴,不在三条轴的范围内 | | 4 | Windows CI 仍用 `7z a -tzip` 手工打包 release | 那是一个自带 `registry/` 的定制布局,不是 pack 的输出。现在 `mcpp pack` 能产 zip 了,迁移是可能的,但那是发布流程的改动 | -| 5 | Windows 宿主 → Linux target 时 `dist::Format` 仍解析为 PE | 早于本轮。本轮的 triple 判据**只新增答案**(说不出 OS 的 triple 走原推导),刻意没有动这一条:它会改变一个正在通过的 CI job 的旗标,而对这三条轴没有好处 | +| 5 | **`mcpp new` + `mcpp add compat.gtest@1.15.2` 开箱即坏** —— 脚手架的 `test_smoke.cpp` 自带 `main()`,而 gtest 依赖会把 `gtest_main.o` 链进每个测试目标,`multiple definition of 'main'`。生态验证时撞到的 | **先于本轮**,已用 2026.8.16.3 在沙盒里复现过同样的失败,所以不是本次回归。它属于依赖/测试目标的链接策略,和这三条轴无关;放在这里是因为它是一条**开箱即坏**的路径,值得单独一轮 | +| 6 | Windows 宿主 → Linux target 时 `dist::Format` 仍解析为 PE | 早于本轮。本轮的 triple 判据**只新增答案**(说不出 OS 的 triple 走原推导),刻意没有动这一条:它会改变一个正在通过的 CI job 的旗标,而对这三条轴没有好处 | --- From 1d44f1c58b065faff02f3b2eccf8a4ccc1737e94 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:39:39 +0800 Subject: [PATCH 04/10] test(windows): assert the ucrt identity actually reaches a real build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit tests pin the hash function — two SDK versions produce two hashes. What they cannot see is whether the value ever gets there on a real Windows build, and a green Windows CI does not distinguish "the identity is filled in" from "the code path ran and produced nothing": an empty string flows through every one of those jobs without a complaint. So this reads resolution.json and asserts the VALUE — `runtime_id` starts with `ucrt@`, the contract hash is non-empty (an identity that participates in nothing is decoration), and it was NOT projected into `libc`, which names a private libc PAYLOAD that has no ucrt equivalent. It pins `msvc@system` explicitly. Windows' default toolchain is clang targeting the MSVC ABI, where clang finds its own SDK and mcpp honestly has nothing to declare — a test that took the default would assert an empty identity and pass for the wrong reason. --- .../e2e/241_windows_ucrt_runtime_identity.sh | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100755 tests/e2e/241_windows_ucrt_runtime_identity.sh diff --git a/tests/e2e/241_windows_ucrt_runtime_identity.sh b/tests/e2e/241_windows_ucrt_runtime_identity.sh new file mode 100755 index 00000000..835261ea --- /dev/null +++ b/tests/e2e/241_windows_ucrt_runtime_identity.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# requires: msvc python3 +# 241_windows_ucrt_runtime_identity.sh — the Windows SDK has an identity, and +# it reaches the build's runtime contract. +# +# `RuntimeBinding::runtimeId`'s own comment has documented `ucrt@…` since the +# field existed, and nothing ever wrote one. The cost was not cosmetic: the +# SDK version never reached `runtimeContractHash`, which keys the build cache, +# so TWO SDKs shared ONE cache key — the version axis simply stopped existing +# one layer below the compiler. +# +# The unit tests pin the hash function (two versions → two hashes). What they +# cannot see is whether the value ever gets there on a real Windows build, and +# a green Windows CI does not distinguish "the identity is filled in" from +# "the code path ran and produced nothing" — an empty string flows through +# every one of those jobs without a complaint. So this asserts the VALUE. +# +# WHY IT PINS msvc@system EXPLICITLY. The identity is produced where mcpp +# RESOLVES the SDK itself, which is the native cl.exe path. Windows' default +# toolchain is clang targeting the MSVC ABI, and there clang finds its own SDK +# — mcpp does not know which one, so there is honestly nothing to declare. A +# test that took the default would therefore assert an empty identity and pass +# for the wrong reason. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +"$MCPP" new ucrtid > /dev/null +cd ucrtid +cat >> mcpp.toml <<'EOF' + +[toolchain] +windows = "msvc@system" +EOF + +"$MCPP" build > build.log 2>&1 || { cat build.log; exit 1; } + +RES="$(find target -name resolution.json | head -1)" +[[ -n "$RES" ]] || { echo "FAIL: no resolution.json"; exit 1; } + +python3 - "$RES" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +b = d.get("runtime", {}).get("binding", {}) +rid = b.get("runtime_id", "") + +assert rid.startswith("ucrt@"), ( + "the Windows runtime identity is not filled in: runtime_id=" + f"{rid!r}. The SDK version never reaches runtimeContractHash, so two " + "SDKs share one build-cache key.") + +version = rid[len("ucrt@"):] +assert version and version[0].isdigit(), f"implausible SDK version in {rid!r}" + +# The identity is only worth anything if it PARTICIPATES. An empty contract +# hash would mean the value was recorded and then not used for anything. +assert b.get("contract_hash"), "runtime binding has no contract hash" + +# ...and it must NOT have been projected into the private-libc field. That +# field is read by the loader/patchelf machinery, and ucrt has no payload for +# it to name — `ucrtbase.dll` is a Windows component. See +# mcpp.platform.runtime_binding on why the two providers are not isomorphic. +assert not b.get("libc"), ( + f"ucrt was projected into `libc` ({b.get('libc')!r}); that field names a " + "private libc PAYLOAD, and there is no such thing for ucrt") + +print(f"OK: runtime identity {rid}, contract {b['contract_hash']}") +PY + +echo "OK" From add631751d3fa021e24029d559a28a46479c78c1 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:42:57 +0800 Subject: [PATCH 05/10] docs: the report was missing e2e 241, and the design had no forward pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report described the unit tests and e2e 240 but not the assertion added after it was written — the one that says the ucrt identity reaches a real Windows build rather than merely being computable. A report that omits the test which closes an acceptance criterion is a report that overstates the other evidence. The design doc now points forward to it, so a reader who starts at the plan finds what actually happened. --- ...6-08-16-windows-toolchain-three-axes-design.md | 4 ++++ .../2026-08-17-windows-three-axes-final-report.md | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/.agents/docs/2026-08-16-windows-toolchain-three-axes-design.md b/.agents/docs/2026-08-16-windows-toolchain-three-axes-design.md index bbccc394..09fcf704 100644 --- a/.agents/docs/2026-08-16-windows-toolchain-three-axes-design.md +++ b/.agents/docs/2026-08-16-windows-toolchain-three-axes-design.md @@ -300,6 +300,10 @@ LD_TRACE_LOADED_OBJECTS=1 '' ## 6.5 落地状态(2026-08-17,mcpp 2026.8.17.1) +> 落地过程中撞到的、不在计划里的三件事(clang 在两个目标上同时出问题、一处文档 +> 自相矛盾、`--mode static` 覆盖用户 target),连同验证结果与遗留账,记在 +> `2026-08-17-windows-three-axes-final-report.md`。 + **§1 / §2 / §3 / §4 全部实现,§2.4 明确不做。** 逐条对应: | 条目 | 状态 | 落点 | diff --git a/.agents/docs/2026-08-17-windows-three-axes-final-report.md b/.agents/docs/2026-08-17-windows-three-axes-final-report.md index 332c8e88..b7ac4f78 100644 --- a/.agents/docs/2026-08-17-windows-three-axes-final-report.md +++ b/.agents/docs/2026-08-17-windows-three-axes-final-report.md @@ -142,6 +142,21 @@ LD_TRACE_LOADED_OBJECTS=1 '' - `--mode system` 不放任何 DLL;`toolchain-coupled` + `--mode system` 被拒绝,消息里 同时有契约名和出路 +### 2.2b e2e 241:`ucrt@` 身份**真的到达了一次真实构建** + +单元测试钉住的是哈希函数(两个 SDK 版本 → 两个哈希)。它看不见的是:这个值在 +**真实的 Windows 构建**上到底有没有被填进去 —— 而 Windows CI 绿了并不能区分 +"身份填好了"和"代码路径跑了但什么都没产出":一个空字符串会毫无怨言地流过其中 +每一个 job。 + +所以 241 读 `resolution.json`,断言**值**本身:`runtime_id` 以 `ucrt@` 开头、 +`contract_hash` 非空(一个不参与任何事的身份就是装饰)、并且它**没有**被投影进 +`libc`(那个字段指的是私有 libc **payload**,ucrt 没有对应物)。 + +它显式 pin 了 `msvc@system`:Windows 的默认工具链是 clang targeting MSVC ABI, +那条路径上 SDK 是 clang 自己找的、mcpp 确实无可声明 —— 用默认工具链写这条测试, +会断言一个空身份然后**因为错误的理由通过**。 + ### 2.3 本机真实验证 在这台 Linux 上,`mcpp pack --target x86_64-windows-gnu` 产出的 zip 被 From 66e39ec3bc2539872e815e193610ffc570580077 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:46:04 +0800 Subject: [PATCH 06/10] fix(pack): a garbage e_lfanew threw instead of answering "not a PE" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `identify()` is documented as never throwing and, for one input, terminated the process. `std::string_view::substr` throws `std::out_of_range` when `pos > size()`, and the position came straight out of the file: if (b.substr(*lfanew, 4) == "PE\0\0") A file that starts with "MZ" and has garbage at 0x3C is ordinary malformed input — a truncated download, a DOS stub, a text file named `.exe` — and `mcpp pack` calls this on every artifact it packages. Measured rather than argued: `std::string_view{256 bytes}.substr(0xFFFFFFFF, 4)` throws `string_view::substr` under libc++. Every other read in the module goes through a bounds-checked accessor; these two comparisons were the exceptions, which is exactly how a module whose stated contract is "total over nonsense" stopped being one. `has_at()` now does the same job with the same check as the rest. Found by re-reading the file after it was already merged and green — the existing truncation test never produced an `e_lfanew` PAST the end, only one exactly AT it, where `substr` is well-defined and returns empty. --- src/pack/binfmt.cppm | 17 +++++++++++++++-- tests/unit/test_pack_binfmt.cpp | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/pack/binfmt.cppm b/src/pack/binfmt.cppm index 3bd03e3c..945961f4 100644 --- a/src/pack/binfmt.cppm +++ b/src/pack/binfmt.cppm @@ -160,6 +160,19 @@ std::optional le64(std::string_view b, std::size_t off) { return v; } +// Do the bytes at `off` equal `lit`? +// +// NOT `b.substr(off, n) == lit`, and the difference is a crash. +// `std::string_view::substr` THROWS `std::out_of_range` when `pos > size()`, +// and `off` here comes from a field READ OUT OF THE FILE — a file starting +// with "MZ" whose `e_lfanew` is garbage is ordinary malformed input, not a +// reason to terminate. This module's contract is that it is total over +// nonsense; one unchecked `substr` was enough to break that promise. +bool has_at(std::string_view b, std::size_t off, std::string_view lit) { + if (off > b.size() || b.size() - off < lit.size()) return false; + return b.compare(off, lit.size(), lit) == 0; +} + // NUL-terminated string at `off`, bounded by the file end. std::optional cstr(std::string_view b, std::size_t off) { if (off >= b.size()) return std::nullopt; @@ -309,7 +322,7 @@ pe_needed(std::string_view b) { auto lfanew = le32(b, 0x3C); if (!lfanew) return std::unexpected("PE: no e_lfanew"); const std::size_t nt = *lfanew; - if (b.substr(nt, 4) != std::string_view("PE\0\0", 4)) + if (!has_at(b, nt, std::string_view("PE\0\0", 4))) return std::unexpected("PE: no PE\\0\\0 signature at e_lfanew"); auto numSections = le16(b, nt + 6); @@ -445,7 +458,7 @@ Ident identify(const std::filesystem::path& binary) { // Saying "PE" for a file that has none would send the caller into a // parser that cannot succeed. if (auto lfanew = detail::le32(b, 0x3C)) { - if (b.substr(*lfanew, 4) == std::string_view("PE\0\0", 4)) { + if (detail::has_at(b, *lfanew, std::string_view("PE\0\0", 4))) { id.format = Format::Pe; if (auto m = detail::le16(b, *lfanew + 4)) id.arch = detail::pe_arch(*m); diff --git a/tests/unit/test_pack_binfmt.cpp b/tests/unit/test_pack_binfmt.cpp index da365372..6f84f3bb 100644 --- a/tests/unit/test_pack_binfmt.cpp +++ b/tests/unit/test_pack_binfmt.cpp @@ -324,6 +324,27 @@ TEST(PackBinfmt, ADosStubWithoutAPeSignatureIsNotAPe) { EXPECT_FALSE(bf::needed_names(f.path).has_value()); } +TEST(PackBinfmt, AGarbageELfanewIsRejectedAndDoesNotThrow) { + // An "MZ" file whose `e_lfanew` points past the end is ordinary malformed + // input: a truncated download, a DOS stub, a text file named `.exe`. + // + // This crashed. `std::string_view::substr` THROWS `std::out_of_range` when + // `pos > size()`, and the offset comes straight out of the file — so + // `identify()`, which is documented as never throwing, terminated the + // process instead of answering Unknown. Bounds-checked comparison now. + for (std::uint32_t lfanew : {0xFFFFFFFFu, 0x7FFFFFFFu, 0x10000u, 0x101u}) { + std::string b(0x100, '\0'); + b[0] = 'M'; b[1] = 'Z'; + put(b, 0x3C, lfanew, 4); + TempFile f{"mzjunk", b}; + EXPECT_NO_THROW({ + EXPECT_EQ(bf::identify(f.path).format, bf::Format::Unknown) + << "e_lfanew=" << lfanew; + EXPECT_FALSE(bf::needed_names(f.path).has_value()); + }) << "e_lfanew=" << lfanew; + } +} + TEST(PackBinfmt, TruncatedInputIsRejectedRatherThanRead) { // Malformed input is ordinary: a half-downloaded file, a text file named // `.exe`. Every read is bounds-checked, so the parser is total over it. From 6735061d27fcd35da50d0c1c1b5515d4cf6a8d4f Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:46:28 +0800 Subject: [PATCH 07/10] docs: the report said three findings; the self-review made it four The fourth is the one the report would most have been improved by hiding: a crash in code written this round, found by re-reading it AFTER it was merged and green. Recorded with what the existing test missed and why. --- ...6-08-17-windows-three-axes-final-report.md | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/.agents/docs/2026-08-17-windows-three-axes-final-report.md b/.agents/docs/2026-08-17-windows-three-axes-final-report.md index b7ac4f78..d0d2ccf2 100644 --- a/.agents/docs/2026-08-17-windows-three-axes-final-report.md +++ b/.agents/docs/2026-08-17-windows-three-axes-final-report.md @@ -195,7 +195,7 @@ hermetic 容器、四条 cross-build、xlings 集成)。 --- -## 3. 路上撞到的三件事(都不在计划里) +## 3. 路上撞到的四件事(都不在计划里) ### 3.1 clang 在**两个目标上同时**出问题 @@ -221,14 +221,35 @@ macOS 那一半后来被**拆分测试**定位到了:崩在 `ThePeFixtureItselfI 可变字符串的 lambda)之后消失。夹具现在带一个 `MCPP_TEST_TRACE=1` 才开的分步 trace: 如果它再来,一轮 CI 就能定位,而不是这次的四轮。 -### 3.2 一处文档自相矛盾 +### 3.2 自审出来的一个崩溃(在已合入、已全绿之后) + +合入之后重读 `binfmt.cppm`,发现 `identify()` —— 注释写着"never throws" —— +在一种输入上会**终止进程**: + +```cpp +if (b.substr(*lfanew, 4) == "PE\0\0") // *lfanew 直接读自文件 +``` + +`std::string_view::substr` 在 `pos > size()` 时抛 `std::out_of_range`。一个以 +"MZ" 开头、0x3C 处是垃圾的文件是**普通的畸形输入**(下载了一半、DOS stub、一个 +叫 `.exe` 的文本文件),而 `mcpp pack` 对**每一个**要打包的产物都调用它。 + +不是推理出来的,是量出来的:`std::string_view{256 字节}.substr(0xFFFFFFFF, 4)` +在 libc++ 下抛 `string_view::substr`。 + +模块里其它每一处读取都走了带边界检查的访问器,只有这两处比较是例外 —— 一个自称 +"对垃圾输入是全函数"的模块,就是这样不再是的。已改为 `has_at()`,并补了回归测试。 +原来的截断测试为什么没抓到:它构造的 `e_lfanew` 只落在**正好等于**文件末尾的位置, +那里 `substr` 是良定义的、返回空。 + +### 3.3 一处文档自相矛盾 `docs/03-toolchains.md` 的 MinGW 段说 `[build] linkage` 这个键不存在、会被静默忽略; 两百行之后的 MSVC 段**恰好**把它当成选 `/MT` 的写法展示。是照着文档写了一遍、看着 mcpp 打印 `unsupported key 'linkage' (ignored)` 才发现的 —— 而跟着这一页做的用户,得到的也是 这个,只是没人告诉他为什么什么都没变。两个语种都已修正。 -### 3.3 `--mode static` 会覆盖用户点名的 target +### 3.4 `--mode static` 会覆盖用户点名的 target 强制 musl 的那次重新 prepare 忽略了 `opts.targetTriple`,于是 `--mode static --target x86_64-windows-gnu` 悄悄变成一次 **Linux** 构建。在 PE 打包 From b012c4a13851729e7b8be8590c44a78a74f97df0 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:51:12 +0800 Subject: [PATCH 08/10] docs: state the crash's exposure precisely rather than dramatically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The examples I reached for — a half-downloaded file, a DOS stub, a text file named .exe — are inputs mcpp is never handed: `identify()` receives a LINK OUTPUT, so the realistic path is a truncated or killed link, not arbitrary user input. The finding stands on what it is (a function documented as never throwing did), not on a frequency it does not have. --- .../docs/2026-08-17-windows-three-axes-final-report.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.agents/docs/2026-08-17-windows-three-axes-final-report.md b/.agents/docs/2026-08-17-windows-three-axes-final-report.md index d0d2ccf2..f3ab7d1c 100644 --- a/.agents/docs/2026-08-17-windows-three-axes-final-report.md +++ b/.agents/docs/2026-08-17-windows-three-axes-final-report.md @@ -230,9 +230,13 @@ macOS 那一半后来被**拆分测试**定位到了:崩在 `ThePeFixtureItselfI if (b.substr(*lfanew, 4) == "PE\0\0") // *lfanew 直接读自文件 ``` -`std::string_view::substr` 在 `pos > size()` 时抛 `std::out_of_range`。一个以 -"MZ" 开头、0x3C 处是垃圾的文件是**普通的畸形输入**(下载了一半、DOS stub、一个 -叫 `.exe` 的文本文件),而 `mcpp pack` 对**每一个**要打包的产物都调用它。 +`std::string_view::substr` 在 `pos > size()` 时抛 `std::out_of_range`。 + +**触发面要说准,不能夸大**:`identify()` 拿到的不是任意文件,而是**链接产物**, +所以现实中的触发路径是一次**被截断/写坏的链接输出**(磁盘满、链接被 kill), +而不是"用户随便丢了个文件进来"。这一条的分量不在于它多常见,而在于:这个模块 +写明了自己"对垃圾输入是全函数",而它不是 —— 一个自称从不抛异常的函数,在一种 +输入上终止了进程。 不是推理出来的,是量出来的:`std::string_view{256 字节}.substr(0xFFFFFFFF, 4)` 在 libc++ 下抛 `string_view::substr`。 From af645de45dbde545ea3087f4a70f65d2c5151f05 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:56:06 +0800 Subject: [PATCH 09/10] docs(cli): `mcpp pack --help` still said tarball MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It says what it produces, and since #448 that is a .zip for a Windows target. `--format tar` selects "an archive rather than a plain directory"; WHICH archive follows the artifact, because a .tar.gz full of DLLs is a package most Windows users cannot open without installing something first. Found by smoke-testing the RELEASED binary — the behaviour shipped and its help did not follow. --- src/cli.cppm | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/cli.cppm b/src/cli.cppm index 4441327b..14e4397a 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -63,7 +63,7 @@ void print_usage() { std::println(" mcpp update [pkg] Re-resolve deps and rewrite mcpp.lock"); std::println(" mcpp search Search packages in registries"); std::println(" mcpp publish [--dry-run] Publish package to default registry"); - std::println(" mcpp pack [--mode ] Build + bundle a tarball (m: system|vendored|self-contained|static)"); + std::println(" mcpp pack [--mode ] Build + bundle an archive (m: system|vendored|self-contained|static)"); std::println(" mcpp emit xpkg [-V VER] [-o FILE] Generate xpkg Lua entry"); std::println(" mcpp xpkg parse [--json] Validate an xpkg descriptor (resolver grammar)"); std::println(""); @@ -399,13 +399,19 @@ int run(int argc, char** argv) { .option(cl::Option("allow-dirty").help("Allow uncommitted changes")) .action(wrap_rc(cmd_publish))) .subcommand(cl::App("pack") - .description("Build + bundle into a self-contained tarball") + // "archive", not "tarball": a Windows target produces a .zip, and + // the help said tarball while the code had already stopped + // agreeing. `--format tar` likewise selects "an archive rather + // than a plain directory" — WHICH archive follows the artifact, + // because a .tar.gz full of DLLs is a package most Windows users + // cannot open without installing something first. + .description("Build + bundle into a self-contained archive") .option(cl::Option("mode").takes_value() .help("system | vendored (default) | self-contained | static")) .option(cl::Option("target").takes_value() .help("Triple, e.g. x86_64-linux-musl")) .option(cl::Option("format").takes_value() - .help("tar (default) | dir")) + .help("tar (default; .zip for a Windows target) | dir")) .option(cl::Option("output").short_name('o').takes_value() .help("Override output path")) .action(wrap_rc(cmd_pack))) From 2c8acbcac088567ecbab947855d78b32c0cd76dc Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:11:22 +0800 Subject: [PATCH 10/10] docs: release verification found SHA256SUMS covers one platform of four MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking the four published archives against the file whose name promises to cover them: only `linux-x86_64` is in it — the aarch64, macOS and Windows archives are absent, and have been for at least v2026.8.16.3 and v2026.8.15.1 too, so this is not something this round did. The mechanism is structural rather than flaky: `build-release` (linux x86_64) runs first and writes the file; the other three platforms upload their own `.sha256` sidecars and nothing appends. Verification IS possible — the sidecars are there — but not through the file that says SHA256SUMS. Recorded as outstanding, not fixed: it is the release pipeline, not one of the three axes, and this PR is already the post-release tail. --- .agents/docs/2026-08-17-windows-three-axes-final-report.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.agents/docs/2026-08-17-windows-three-axes-final-report.md b/.agents/docs/2026-08-17-windows-three-axes-final-report.md index f3ab7d1c..acac27f6 100644 --- a/.agents/docs/2026-08-17-windows-three-axes-final-report.md +++ b/.agents/docs/2026-08-17-windows-three-axes-final-report.md @@ -270,7 +270,8 @@ if (b.substr(*lfanew, 4) == "PE\0\0") // *lfanew 直接读自文件 | 3 | `[pack] include` / `exclude` 被解析、存进 Plan,**从未被消费** | 早于本轮;文档把它们当作已有功能。属于 pack 的另一条轴,不在三条轴的范围内 | | 4 | Windows CI 仍用 `7z a -tzip` 手工打包 release | 那是一个自带 `registry/` 的定制布局,不是 pack 的输出。现在 `mcpp pack` 能产 zip 了,迁移是可能的,但那是发布流程的改动 | | 5 | **`mcpp new` + `mcpp add compat.gtest@1.15.2` 开箱即坏** —— 脚手架的 `test_smoke.cpp` 自带 `main()`,而 gtest 依赖会把 `gtest_main.o` 链进每个测试目标,`multiple definition of 'main'`。生态验证时撞到的 | **先于本轮**,已用 2026.8.16.3 在沙盒里复现过同样的失败,所以不是本次回归。它属于依赖/测试目标的链接策略,和这三条轴无关;放在这里是因为它是一条**开箱即坏**的路径,值得单独一轮 | -| 6 | Windows 宿主 → Linux target 时 `dist::Format` 仍解析为 PE | 早于本轮。本轮的 triple 判据**只新增答案**(说不出 OS 的 triple 走原推导),刻意没有动这一条:它会改变一个正在通过的 CI job 的旗标,而对这三条轴没有好处 | +| 6 | **release 的 `SHA256SUMS` 只覆盖 4 个平台里的 1 个** —— 里面只有 `linux-x86_64` 的两行(带版本名 + 无版本别名),aarch64 / macOS / Windows 都不在。核对发布产物时撞到的 | **先于本轮**:v2026.8.16.3 与 v2026.8.15.1 同样如此。机制很清楚 —— `build-release`(linux x86_64)最先跑并写下这个文件,另外三个平台各自上传自己的 `.sha256` 边车,但没有人往 `SHA256SUMS` 里追加。**能校验**(边车在),只是那个名字承诺了它没做到的事。属于发布流水线,不属于这三条轴 | +| 7 | Windows 宿主 → Linux target 时 `dist::Format` 仍解析为 PE | 早于本轮。本轮的 triple 判据**只新增答案**(说不出 OS 的 triple 走原推导),刻意没有动这一条:它会改变一个正在通过的 CI job 的旗标,而对这三条轴没有好处 | ---