diff --git a/.agents/docs/2026-08-25-the-two-layer-predicate-family.md b/.agents/docs/2026-08-25-the-two-layer-predicate-family.md new file mode 100644 index 00000000..4b17d827 --- /dev/null +++ b/.agents/docs/2026-08-25-the-two-layer-predicate-family.md @@ -0,0 +1,420 @@ +# 一个谓词族,六处缺陷:目标侧的分层判据 + +2026-08-25 · 缺陷分析 + 修复方案(六条中两条已修,四条待定) + +本文不是缺陷清单。六处缺陷共享**同一个形状**,而那个形状源于一处架构疏漏; +逐条修完仍会有第七条,除非把疏漏本身补上。 + +每一条都给出实测依据。凡未实测的推断,明确标注。 + +--- + +## 0. 一句话 + +> **mcpp 用「图有没有供给系统」这一个跨两层的谓词,回答了六个不同的问题, +> 而其中五个只取决于两层里的某一层,第六个两层都不取决于。** + +```cpp +bool system_from_graph() const { + return kernelAbi.fromGraph() || cAbi.fromGraph(); // ← 这个 OR +} +``` + +它成立的那一天是对的:在作者面前的那个排布里,两层总是一起来自图。 +第二种排布出现时——**后端跑在平台之上**,内核接口来自图而 C 库仍是载荷的—— +两层分开了,而六处判据全部答错。 + +--- + +## 1. 现状 + +### 1.1 已发布 + +| | 状态 | +|---|---| +| `2026.8.25.1` | 已发布,索引 artifact 已核验(`xim-index-1956eb7` 内含该版本) | +| main CI | 8/8 绿 | +| PR #506(`2026.8.25.2`) | 27 项全绿,**待合入** | +| PR #505(镜像校验补重试) | 待合入 | + +### 1.2 七个下游 pin PR + +| 仓库 | 状态 | 成因 | +|---|---|---| +| openkal | 12/12 绿 | — | +| openkal-linux | 2/2 绿 | — | +| openkal-macos | 2/2 绿 | — | +| openkal-opensbi | 1 红 | **缺陷 ⑤**,已修(#506) | +| openkal-windows | 1 红 | **缺陷 ⑥**(issue #507) | +| openkal-uefi | 1 红 | **缺陷 ⑥**,同上 | +| openkal-musl | 1 红 | **缺陷 ⑦**,本文 §2.7,尚未修 | + +--- + +## 2. 六处缺陷,一个形状 + +编号沿用发现顺序。①–④ 已随 `2026.8.25.1` 修复,⑤ 已修待合,⑥⑦ 待定。 + +### 2.1 ① 链接线丢掉载荷 C 库的路径 + +``` +error: hermetic link check failed + crt1.o (bare name — the linker cannot resolve it) +``` + +判据用 `system_from_graph()` 决定「要不要整条替换链接线」。内核接口来自图 +⇒ 替换 ⇒ 载荷 C 库的 `-B` 一起没了。**决定它的是 C 库那一层**。 + +修:`!cAbi.prebuilt()`。 + +### 2.2 ② 缓存键跨着一处不兼容命中 + +`compile_flags(spec)` 长出第二个参数 `targetCxxRuntime`,而缓存键仍按一个参数算。 +实测 `openkal@0.7.0` 出现 6 个槽位对应 5 个尺寸各异的 BMI。 + +⚠️ 这类缺陷不在加参数那天失败,在下一次缓存命中时失败。 + +### 2.3 ③ 契约表拿掉了载荷的 C++ 运行时 + +``` +undefined reference to __cxa_allocate_exception +``` + +同样的 OR。理由 `check_layering` 早已反向陈述:载荷的 C++ 运行时是**对着载荷的 +C 库**配置的,当且仅当那份 C 库在用时它才可用。 + +### 2.4 ④ `linkage = "dynamic"` 的「无效」诊断在说谎 + +``` +warning: `linkage = "dynamic"` has no effect … The artifact is static. +$ readelf -d → NEEDED libm.so.6, libgcc_s.so.1, libc.so.6 +``` + +警告自己给的理由(「那些包被当作对象编进本次构建」)是 **C 库单独一层**的性质。 +载荷的 libc 有共享对象,`dynamic` 就被兑现了。 + +### 2.5 ⑤ 声明一层,让裸机目标丢掉唯一能产出它的编译器 + +**三行清单即可复现**: + +```toml +provides = ["mcpp:kernel-abi=openkal"] +``` +``` +$ mcpp build --target riscv64-none-elf + Resolved gcc@16.1.0 → riscv64-none-elf → …/bin/g++ + g++: error: unrecognized argument in option '-mabi=lp64d' + g++: error: unrecognized command-line option '--target=riscv64-none-elf' +``` + +`prepare.cppm:4837` 的 `graphSuppliesSystem`(同一个 OR)取消目标行的编译器 pin。 + +**对宿主行这是对的**——那一行说的是「哪个载荷供给这个目标的 C 库」,图供给了就 +用不上它。**对裸机行不是**——目标表自己写着「pin 是 llvm,因为 clang/lld 按构造 +就是交叉编译器」。宿主 g++ 发不出 riscv64,图里有什么都不改变这件事。 + +⭐ **这一条揭示了比前四条更深的东西:pin 有两种,而代码里只有一种。** +一种是约定(哪个载荷供给 C 库),一种是能力陈述(哪个编译器能发出这个目标)。 + +修:PR #506,区分记在**读取那一行的同一处**,不在决定点重新推导。 + +### 2.6 ⑥ 服务不了的目标被宿主静默服务了 + +``` +Target x86_64-windows-gnu → x86_64-unknown-linux-gnu +… +src/stream.cpp:68:9: error: 'GetFileType' was not declared in this scope +``` + +一行里两个操作系统。CI 只装了原生 gcc,mcpp 既不装 mingw 载荷也不拒绝, +直接用宿主 gcc 服务了 Windows 目标。openkal-uefi 是同一条,症状在链接器: + +``` +…/xim-x-binutils/2.42/bin/ld: unrecognized option '--subsystem' +``` + +**对照**:装了载荷的机器上同一条命令完全正确 +(`Resolved gcc@16.1.0 → x86_64-windows-gnu → …/x86_64-w64-mingw32-g++`)。 + +⭐⭐ **真因在「首次运行」那条路上,而它把 `--target` 丢了。** 交叉验证把它逼了 +出来(CI 2026-08-25): + +``` +First run no toolchain configured — installing gcc@16.1.0 (glibc, native ABI) + Resolved gcc@16.1.0 → …/xim-x-gcc/16.1.0/bin/g++ + ↑ 路径里没有目标 +``` + +同一条命令在已有工具链的机器上是: + +``` + Resolved gcc@16.1.0 → x86_64-windows-gnu → …/mingw-cross-gcc/…/x86_64-w64-mingw32-g++ +``` + +这条分支回答的是「这台机器没有工具链,给它一个」,而答案是一份**宿主**载荷; +`overrides.target_triple` 在这条路上**从未被读取**。于是一台从未构建过任何东西的 +机器上,`mcpp build --target x86_64-windows-gnu` 装了原生 gcc 并用它编译 Windows +源码——而载荷解析那条路上 `autoInstall=true` **本来就在**,只是没被走到。 + +修法不是加一个条件,而是让首次运行**汇入**那条已经会处理目标的路径:装完默认之后, +若请求了目标,就拿这个默认重新解析一次。为此 `resolve_target_toolchain` 由 `auto` +改为 `std::function`(它要回调自身),深度为一——第二遍走的是首次运行刚刚让其成立的 +`tcSpec.has_value()` 分支。 + +⑥ 因此有两半,而两半都需要:**守卫**让错配变成一句拒绝而不是一百行后的 Win32 报错, +**首次运行汇入**让本来就能服务的目标不再走到那句拒绝。 + +issue #507。 + +### 2.7 ⑦ macOS 上「图供给系统」不等于「平台什么都不需要」 + +``` +Target arm64-apple-darwin23.6.0 → arm64-apple-macos14.0 + kernel-abi openkal (openkal-macos@0.3.4, graph) + c-abi musl (openkal-musl@0.3.5, graph) +ld64.lld: error: library not found for -lSystem +ld64.lld: error: undefined symbol: clock_gettime_nsec_np +ld64.lld: error: undefined symbol: pthread_create_from_mach_thread +``` + +**判据**:`macos, llvm` 这一格在 main(mcpp `2026.8.19.4`)**通过**,在 +`2026.8.25.1` 上失败。是同一跨度里的回归。 + +⚠️ **不是 `2026.8.25.1` 引入的。** 这个排布下,旧谓词 `system_from_graph()` 与新 +谓词 `!cAbi.prebuilt()` **取值相同**(c-abi 来自图),所以 §2.1 的修改没有改变 +这一格的行为。它来自 #486 引入替换本身。 + +⭐⭐ **架构层面的真因:在 Darwin 上,内核接口就是 libSystem。** + +openkal-macos 的清单写着 `ldflags = ["-nostdlib", "-lSystem", …]` —— +它**包裹** libSystem 而不是替换它。而 `flags.cppm:1458` 的替换把整条 `f.ld` +换成 `crossTarget + 少数几个 flag`,SDK 的库搜索路径随之消失,于是 `-lSystem` +找不到。 + +这不是 macOS 的特例,是模型的缺口:**「图供给了这一层」与「平台的那一层不再被 +链接」是两件事**,而代码把它们当成了一件。Linux/musl 上二者恰好重合(musl 是 +自足的),Darwin 上不重合。 + +--- + +## 3. 架构疏漏:缺的是「谁被替换」这一维 + +五层模型回答了**每一层来自哪里**(`Origin::{Payload,Xpkg,Graph,None}`), +这是对的,六处缺陷都不是因为这个模型错。 + +缺的是第二个问题: + +> 一层来自图,**平台的那一层是否因此不再参与链接**? + +| 排布 | c-abi 来源 | 平台的 C 库还参与吗 | 现状判定 | +|---|---|---|---| +| 传统栈 | Payload | 是 | ✅ 正确 | +| openkal 全图栈(Linux/musl) | Graph | 否 | ✅ 正确 | +| 后端跑在平台上 | Payload | 是 | ①③④ 曾判错,已修 | +| 裸机 | None | 无此物 | ✅ | +| **openkal on Darwin** | **Graph** | **是**(libSystem 是内核接口本身) | ❌ **⑦,未修** | + +最后一行是模型里没有的格子。它不是边角情形——它是「一个实现包裹平台接口」的 +一般形态,而 openkal 的设计前提正是「后端可以跑在平台之上」(①③④ 修的就是 +这个前提在 Linux 上的那一半)。 + +--- + +## 4. 修复方案 + +### 4.1 立即(不改模型) + +| # | 动作 | 风险 | +|---|---|---| +| A1 | 合入 #506(⑤)与 #505(镜像重试) | 低,均已全绿 | +| A2 | ⑦ 的止血:替换链接线时**保留 sysroot / SDK 的库搜索路径**,只替换启动对象与目标选择 | 中,需 Darwin 实测 | +| A3 | ⑥ 的止血:请求的目标无载荷时**惰性安装或拒绝**,不得回落宿主三元组 | 中,涉及安装路径 | + +⚠️ A2 的判据必须落在 **Darwin 真机/真 runner** 上——本机是 Linux,`-lSystem` +这一格在这里无法复现。 + +### 4.2 结构(补上缺的那一维) + +给 `TargetSide` 增加**一个**问题的答案,而不是给每处判据加一个条件: + +```cpp +// 平台自身的 C 库是否仍参与链接。 +// +// 「这一层来自图」不蕴含「平台的这一层不再被链接」。musl 是自足的, +// 所以在 Linux 上二者重合;libSystem 既是 Darwin 的 C 库也是它的内核接口, +// 一个包裹它的实现仍然要链接它。 +bool platform_c_library_still_links() const; +``` + +来源:目标 OS + c-abi 的实现是否声明自己包裹平台(**新增清单键**, +例如 `wraps = ["platform-c-library"]`,由 openkal-macos 声明)。 + +⭐ **不要用 OS 判断。** `if (os == "macos")` 会在下一个同形平台(illumos、 +某些 BSD)上再错一次,而且把生态的性质写进了引擎——正是 `subos_info.cppm` +的模块注释点名反对的分层倒置。**由包声明,引擎读取。** + +### 4.3 防止第七条 + +六处缺陷全部通过了当时的测试。共同点:**判据施加在正确的对象上,但那个对象 +回答的是另一个问题**。 + +- ⭐ 新增谓词时,写下它回答的**那一个**问题,并列出**每个 `Origin` 值**下的答案。 + 五条 targetside 单元测试就是按这个写的(一个 `Origin` 一条),它们在 ①③ 上有效。 +- ⭐ 跨层的 OR/AND **必须**在注释里说明为什么两层都参与。§2.5 里保留的两处 + `system_from_graph()` 各有一句;新增的没有就不许合。 +- ⚠️ 单元测试打在模型上抓不到「选错谓词」——模型是对的。抓得到的是 e2e, + 前提是那些 e2e **真的在 CI 跑过**(见 §5)。 + +--- + +## 5. 测试与 CI:两个已确认的空洞 + +### 5.1 写了没跑 + +`285`–`289` 声明 `# requires: llvm`,而两个 linux e2e shard 报的能力行是 + +``` +Detected capabilities: elf unix-shell fresh-sandbox gcc patchelf pack … +``` + +**没有 `llvm`**,shard 从不装,`run_all.sh` 在 skip 时退 0。五条专门衡量这个生态 +的测试一次都没执行,而套件一直绿。 + +已修:`openkal-cross.yml` 新增 `ecosystem-e2e` job——装 gcc + llvm,直跑六条, +再**逐条断言 PASS 行出现**。它第一次跑就抓到 §5.2。 + +⚠️ 断言最终 OK 行不够:脚本内部的「运行」步骤会各自降级成 SKIP 而 OK 行照印 +(288 实测)。运行阶段那一行要单独断言。 + +### 5.2 判据的「否」与「没测成」同读数 + +一次会话里我自己新写的六条 e2e,**四条**犯了它: + +| 判据 | 「否」的真因 | +|---|---| +| `objdump -d aarch64.o \| grep -c 'cas\|swp'` | 宿主 GNU binutils 只编了 x86_64 ⇒ **文件头、零指令、零报错** | +| `readelf -l \| grep -q INTERP`(断言**缺席**) | 读不了的文件输出 0 行 ⇒ 与静态镜像同读数 | +| `readelf -d \| grep -c NEEDED` | 「没有动态段」与「readelf 什么都没说」同为 0 | +| `case $first in */subos/*/bin)` | **CI 自己的 PATH 本就以它开头** ⇒ 分不清谁放的 | + +规则: + +- ⭐ **判据带分母**:`7 LSE instructions out of 148906`,不是 `7 LSE instructions`。 +- ⭐ **先确立读到了东西,再问里面有没有。** 零条 = 工具读不了 ⇒ SKIP 或硬失败, + **不是**关于被测性质的答案。 +- ⭐ **断言「没变」要前后两值并排比**,不能比对模式。 +- ⭐ **工具取自产生该产物的那条工具链**,不用 `command -v`。 + +### 5.3 交叉验证通道:三轮红,全部是判据自身的缺陷 + +新加的通道让七个生态仓库从 mcpp 的 PR 分支现场构建再跑自己的测试。它从 7 红收敛 +到 0 红,而**中间每一轮红都不是生态代码的问题**,是我写的那段 CI 脚本: + +| 现象 | 真因 | +|---|---| +| `package 'mcpp@2026.8.25.2' not found in the synced index` | 引导安装先于构建步骤跑,而我把 pin 钉到了未发布的版本 | +| `xlings: version '2026.8.17.1' not found` | 克隆出来的 `.xlings.json` 工作区 pin 抢走了「谁来构建 mcpp」的决定权 | +| 版本号对、代码旧 ⇒ ⑤ 通过而 ⑥ 不通过 | `find … \| head -1` 挑到缓存 `target/` 里上一次推送留下的二进制 | +| 修上一条时把 GNU 的 `-printf` 写进跑 macOS 的仓库 | 那七处是**新克隆**,本就不需要按时间排序 | +| `Finished release in 173.44s` 之后报 "mcpp did not build" | Windows 上产物叫 `mcpp.exe`,而 `find -name mcpp` 找不到 | + +⭐ 最后两条与本文 §2 的六条**同型**:**构建成功了,是判据看错了地方**。 + +⚠️ **通道本身要有判据。** 「七个全绿」不等于「它们用了 PR 的 mcpp」——那一步可能 +整个没跑。收尾核对的是每个 run 的日志里出现 +`under review: mcpp 2026.8.25.2 (from )`,七个全中才算数。 + +### 5.4 覆盖矩阵的实际空洞 + +| 排布 | mcpp e2e | openkal 侧 CI | +|---|---|---| +| 传统栈 | 大量 | — | +| 内核接口来自图 + 载荷 C 库 | 285 ✅ | openkal-linux ✅ | +| 三层全来自图(Linux) | 286 ✅ | openkal-musl(linux)✅ | +| 交叉到 aarch64 | 287 ✅ | — | +| 无 OS 无 C 库 | 288 ✅ | openkal-opensbi ⚠️(缺陷⑤) | +| 一宿主扫四目标 | 289 ✅ | — | +| **openkal on Darwin** | **无** | openkal-musl(macos)❌ | +| **Windows 目标无载荷的机器** | **无** | openkal-windows / uefi ❌ | + +⭐ 最后两行就是 ⑥⑦ 能存活到今天的原因。**修 ⑥⑦ 的同时必须补上这两行**, +否则下一次同样看不见。 + +### 5.5 有三格,本机永远验不了 + +| 修复 | 判据在哪 | 为什么本机不行 | +|---|---|---| +| ⑤ 裸机 pin | e2e 292 | —— 本机可验 | +| ⑥a 同 OS 不变量 | e2e 293 | —— 本机可验 | +| **⑥b 首次运行汇入目标解析** | `ci-fresh-install` / `bare Windows` / 生态 CI | **开发机永远不是首次运行** | +| **⑦ macOS 平台锚点** | openkal-musl 的 `macos, llvm` | 本机是 Linux,`-lSystem` 这一格不存在 | +| #504 列表状态 | e2e 294 | —— 本机可验 | + +⚠️ **这不是「测试写得不够」,是这两格的前提条件本机不成立。** 结论有两条: + +1. 这类修复**必须**由跨仓库 CI 把关,而那条通道在它们被写出来之前并不存在—— + 这正是 ⑥⑦ 能活到 2026.8.25.2 的机制,不是巧合。 +2. ⚠️ **本机全绿不能当作可以合入的证据。** ⑥b 的第一版在本机通过了全量单元测试、 + 五个目标零误伤、三条新 e2e,而它是**无限递归**:生态仓库上 `Resolved` 打四遍后 + `exit 139`(SIGSEGV,爆栈),mcpp 自己的 `bare Windows` 上 `First run` 反复打印 + 后 exit 1。写下「深度为一」的注释时,我推理的是「第二趟走不到这里」——而那一行 + 在分支之外,每一趟都求值,标志没有任何人复位。 + + ⭐ **一个递归,若其终止依赖于「递归调用不会改变的状态」,那它就不是深度为一的 + 递归——无论注释怎么写。** 闸要结构上不可能循环,标志在**调用之前**置位。 + +--- + +## 6. 发布链条:两处已证实的脆弱点 + +### 6.1 「发布就绪」的判据用错了对象 + +我按记忆里的判据(「索引 main 的 latest 指向它」)执行,读到 `2026.8.25.1`, +随即重钉七个下游 PR。七个全红: + +``` +[error] package 'mcpp@2026.8.25.1' not found in the synced index + (xim@artifact:8df3b47, …), synced 0 seconds +``` + +**默认客户端解析的是 artifact 快照,不是 git main。** 两者之间隔着 +`publish-artifact.yml`。 + +⭐ 正确判据:把 artifact 取下来读它。 + +```sh +curl -fsSL -o p.json .../xim-index-latest.json # source_commit 要等于索引 main 的 sha +curl -fsSL -o a.tar.gz .../xim-index-.tar.gz +sha256sum a.tar.gz # 与 p.json 的 artifact.sha256 比对 +tar xzf a.tar.gz && grep '\["latest"\]' */pkgs/m/mcpp.lua +``` + +### 6.2 一个没有重试的 GET 判掉整条发布 + +`2026.8.25.1` 的发布红了两次。两次都报 16 个资产「already mirrored, skipping」, +然后因其中**一个**的 502 判失败: + +``` +[mirror] FAIL: missing/unverified: https://gitcode.com/.../linux-x86_64.tar.gz + 502ERR https://gitcode.com/.../linux-x86_64.tar.gz +``` + +手工抓下来:**5,772,395 字节,sha256 与发布的校验和逐位相同**。文件从未缺过。 + +已修(#505):两处校验 GET 补 `--retry-all-errors`;并修掉 `502ERR` 拼接 +(`|| echo ERR` 是追加不是替换,导致状态码无法 grep)。 + +--- + +## 7. 建议的执行顺序 + +1. **合入 #506 + #505**,发 `2026.8.25.2`。⑤ 随之解决,openkal-opensbi 转绿。 +2. **⑥(issue #507)**:目标无载荷时惰性安装或拒绝。openkal-windows / uefi 两个 + PR 依赖它。同时补 e2e:「请求一个本机无载荷的目标」两向断言。 +3. **⑦**:先在 Darwin runner 上取得失败现场的完整链接命令行(`-v`),确认丢的 + 确实是 SDK 的 `-L`;再按 §4.2 由包声明、引擎读取。补 e2e 到 macOS 矩阵。 +4. **#504**(`toolchain list` 漏掉可构建的目标)——它是 ⑥ 的报告侧,同一处混淆, + 建议与 ⑥ 一起做,共用新谓词。 +5. 回填:`2026.8.19.4 → 2026.8.24.6` 这一跨度还有没有第八条?**建议做一次 + 有针对性的差分**——把 #486 触碰的每个判据列出来,逐个问「它回答的是哪一个 + 问题、取决于哪一层」。六条里有五条出自那一次改动。 diff --git a/.github/tools/mirror_res.sh b/.github/tools/mirror_res.sh index 05a79f4a..dc95421a 100755 --- a/.github/tools/mirror_res.sh +++ b/.github/tools/mirror_res.sh @@ -133,7 +133,9 @@ done # The final completeness gate below still does FULL GETs. probe() { # host_path asset → 0 iff the object serves bytes local code - code=$(curl -fsSL -o /dev/null -w '%{http_code}' -r 0-0 -L "$1" 2>/dev/null) + code=$(curl -fsS -o /dev/null -w '%{http_code}' -r 0-0 -L \ + --retry 3 --retry-all-errors --retry-delay 2 --max-time 60 \ + "$1" 2>/dev/null) [[ "$code" == 200 || "$code" == 206 ]] } @@ -285,7 +287,24 @@ hosts=() [[ "${GTC_ENABLED:-0}" == 1 ]] && hosts+=("gitcode.com/$GTC_DST") for host in "${hosts[@]}"; do for a in "${ASSETS[@]}"; do - code=$(curl -fsSL -o /dev/null -w '%{http_code}' -L "https://${host}/releases/download/${VER}/${a}" 2>/dev/null || echo ERR) + # ⚠️ RETRIED, BECAUSE A MIRROR CAN ANSWER 502 FOR AN ASSET IT HOLDS. + # v2026.8.25.1 failed here twice: every one of the 16 assets reported + # "already mirrored, skipping", and the gate then failed one of them on a + # single 502 from GitCode's edge. Fetched by hand a minute later it was + # 5,772,395 bytes with the published sha256 — the file was never missing. + # + # `--retry-all-errors` and not `--retry`: plain `--retry` covers transient + # HTTP codes but not the transport-layer failures this path also sees, and + # this repository has paid for that distinction before (ci-curl-52). + # + # ⚠️ `|| echo ERR` APPENDS, it does not replace — `-f` makes curl exit + # non-zero on 502 while `-w` has already written the code, so the variable + # read `502ERR` and the log could not be grepped for a status. Substituted + # only when curl printed nothing at all. + code=$(curl -fsS -o /dev/null -w '%{http_code}' -L \ + --retry 3 --retry-all-errors --retry-delay 3 --max-time 120 \ + "https://${host}/releases/download/${VER}/${a}" 2>/dev/null) + [[ -n "$code" ]] || code=ERR echo " $code https://${host}/releases/download/${VER}/${a}" [[ "$code" == 200 ]] || { rc=1; echo "[mirror] FAIL: missing/unverified: https://${host}/releases/download/${VER}/${a}" >&2; } done diff --git a/.github/workflows/openkal-cross.yml b/.github/workflows/openkal-cross.yml index 2c686df8..3c0916f7 100644 --- a/.github/workflows/openkal-cross.yml +++ b/.github/workflows/openkal-cross.yml @@ -281,7 +281,15 @@ jobs: "$XLINGS_BIN" config --mirror GLOBAL 2>/dev/null || true "$MCPP" self config --mirror GLOBAL 2>/dev/null || true "$MCPP" build --dev - BUILT=$(find target -type f -name 'mcpp' | head -1) + # ⚠️ NEWEST BY MTIME, NOT FIRST BY DIRECTORY ORDER. `target/` holds one + # directory per fingerprint and the runner restores a cache of it, so + # `find … | head -1` can return a binary an earlier run left behind. + # Measured: it reported the right VERSION STRING — the stale copy was + # built from an earlier push of this same release — while missing the + # last two commits, so one new test passed and two failed for reasons + # that were nowhere in the source. + BUILT=$(find target -type f -name 'mcpp' -printf '%T@ %p\n' \ + | sort -rn | head -1 | cut -d' ' -f2) [ -n "$BUILT" ] || { echo "::error::mcpp did not build"; exit 1; } BUILT=$(cd "$(dirname "$BUILT")" && pwd)/$(basename "$BUILT") echo "MCPP_UNDER_TEST=$BUILT" >> "$GITHUB_ENV" @@ -337,7 +345,8 @@ jobs: # detect. fail=0 for t in tests/e2e/285_*.sh tests/e2e/286_*.sh tests/e2e/287_*.sh \ - tests/e2e/288_*.sh tests/e2e/289_*.sh tests/e2e/291_*.sh; do + tests/e2e/288_*.sh tests/e2e/289_*.sh tests/e2e/291_*.sh \ + tests/e2e/292_*.sh tests/e2e/293_*.sh tests/e2e/294_*.sh; do echo "=== $t ===" bash "$t" 2>&1 | tee "$(basename "$t").log" || true rc=${PIPESTATUS[0]} @@ -383,4 +392,10 @@ jobs: "OK: one host reached" || fail=1 check 291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh \ "OK: the C library decides whether 'dynamic' can be honoured" || fail=1 + check 292_a_package_that_names_a_layer_does_not_lose_the_targets_compiler.sh \ + "OK: naming a layer changes the system, not the compiler that emits the target" || fail=1 + check 293_the_requested_target_and_the_resolved_one_name_one_os.sh \ + "OK: the requested target and the resolved one name one operating system" || fail=1 + check 294_the_list_answers_what_can_be_built_not_what_has_a_payload.sh \ + "OK: the list answers what can be built, not what has a payload" || fail=1 [ "$fail" = 0 ] || exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c74eb62..c9494284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,148 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.25.2] — 2026-08-25 + +### 修复 + +- **⭐⭐ 一个包声明「我供给哪一层」,让裸机目标丢掉了唯一能产出它的编译器。** + + 实测,三行清单就够: + + ```toml + provides = ["mcpp:kernel-abi=openkal"] + ``` + ``` + $ mcpp build --target riscv64-none-elf + Resolved gcc@16.1.0 → riscv64-none-elf → …/bin/g++ + g++: error: unrecognized argument in option '-mabi=lp64d' + g++: error: unrecognized command-line option '--target=riscv64-none-elf' + ``` + + `graphSuppliesSystem` 是一个跨 kernel-abi ∪ c-abi 的 OR,它取消目标行的编译器 + pin。**对宿主行这是对的**——那一行指的是「哪个载荷供给这个目标的 C 库」,图供给 + 了就用不上它。**对裸机行不是**:表里自己写着「pin 是 llvm,因为 clang/lld 按构造 + 就是交叉编译器」——宿主 g++ 根本发不出 `riscv64-none-elf`,图里有什么都不改变 + 这件事。 + + 于是 pin 分成两类,而这个区分**在读取那一行的同一处**记下,不在决定点重新推导。 + + 与 2026.8.25.1 修的四条同型:**一个跨两层的谓词,决定了一件不取决于这两层的事**。 + 这是第五条。 + + ⚠️ 发现方式:2026.8.25.1 发布后重钉七个下游 pin PR,openkal-opensbi 红在 + `g++: unrecognized`。它在 2026.8.24.6 那轮红在**同一条**,所以既非 25.1 引入, + 也非 25.1 修掉——是同一跨度里的遗留。 + +### 测试 + +- e2e 292,两向断言:声明一层之后裸机目标仍解析到同一个编译器(先建立基线, + 否则分不清「修好了」和「这台机器没有 llvm」);以及宿主行**不得**顶掉项目自己 + 选的工具链——不加区分地永不取消 pin 也能让前一半通过,而那正是这个谓词当初要 + 防的替换。 + +## [2026.8.25.2] — 2026-08-25 + +一个谓词族的收尾。`2026.8.25.1` 修了其中四条,本次修余下三条,并补上让它们 +存活至今的两个 CI 空洞。完整分析见 +[`.agents/docs/2026-08-25-the-two-layer-predicate-family.md`](.agents/docs/2026-08-25-the-two-layer-predicate-family.md)。 + +### 修复 + +- **⭐⭐ 图供给了 C 库,不等于目标平台的 SDK 不再需要。** + + ``` + kernel-abi openkal (openkal-macos@0.3.4, graph) + c-abi musl (openkal-musl@0.3.5, graph) + ld64.lld: error: library not found for -lSystem + ld64.lld: error: undefined symbol: clock_gettime_nsec_np + ``` + + macOS 分支给链接线加 `-isysroot`,它自己的注释写明了为什么(「否则 ld64.lld + 会死在 library not found for -lSystem」);而 80 行之后的图分支把整条 `f.ld` + 换掉,`-isysroot` 随之消失——**注释预言的那个失败,由它下面的代码造成**。 + + ⭐ **Linux 上二者恰好重合而 Darwin 上不重合**:Linux 的内核接口是一条指令 + (`syscall`),所以自足的 musl 真的替换了一切;Darwin 的内核接口**本身就是一个 + 库**(libSystem),所以 Mach-O 链接无论 libc 从哪来都要 SDK。新增 + `platformAnchor`:写一次、读一次,两个分支不可能对「什么该活下来」有分歧。 + +- **⭐⭐ 请求的目标与解析出的目标必须是同一个操作系统。** + + ``` + Target x86_64-windows-gnu → x86_64-unknown-linux-gnu + … + src/stream.cpp:68:9: error: 'GetFileType' was not declared in this scope + ``` + + 一行里两个操作系统,而没有任何提示。交叉载荷缺席时解析回落到宿主编译器, + Windows 源码被按 Linux 编译,失败在一百行之后——报出的是一个 Win32 函数名, + 不是做出这个决定的那一处。openkal-uefi 撞在链接器上: + `ld: unrecognized option '--subsystem'`。 + + ⭐ **报告里早就有证据,现在对它下断言**,而不是把问题重新推导一遍。范围刻意 + 只取 OS:`x86_64-windows-gnu → x86_64-w64-windows-gnu` 的差异正是这一行要报告 + 的归一化,拿整个三元组比会拒掉每一次正确的交叉构建。 + +- **⭐⭐ 「首次运行」那条路把 `--target` 丢了。** + + ``` + First run no toolchain configured — installing gcc@16.1.0 (glibc, native ABI) + Resolved gcc@16.1.0 → …/xim-x-gcc/16.1.0/bin/g++ ← 路径里没有目标 + ``` + + 同一条命令在已有工具链的机器上是 + `Resolved gcc@16.1.0 → x86_64-windows-gnu → …/mingw-cross-gcc/…`。这条分支回答 + 的是「这台机器没有工具链,给它一个」,答案是一份**宿主**载荷; + `overrides.target_triple` 在这条路上**从未被读取**。而载荷解析那条路上 + `autoInstall=true` **本来就在**,只是没被走到。 + + ⭐ 修法不是加条件,而是让首次运行**汇入**那条已经会处理目标的路径。上面那条 + 「同一个操作系统」的不变量因此有了配套:守卫让错配变成一句拒绝,汇入让本来就能 + 服务的目标不再走到那句拒绝。 + + ⚠️ **这一处的第一版是无限递归,而我的注释写着「深度为一」。** 闸放在了分支之外 + (必须放外面:它上面那段 Windows 代码自己会设置 target),而标志没有任何人复位。 + 本机看不见——这一格只在「首次运行 + 交叉目标」出现,而开发机永远不是首次运行。 + 抓到它的是两条 CI,症状还不同:生态仓库上 `Resolved` 打四遍后 **exit 139 + (SIGSEGV,爆栈)**,mcpp 自己的 `bare Windows` 上 `First run` 反复打印后 exit 1。 + 现在的闸结构上不可能循环,标志在**调用之前**置位。 + +- **`toolchain list` 漏掉了本机能构建的目标。** + + 它用 `host_can_serve`(问的是「有没有预制载荷」)去回答「能不能构建」。实测 + Linux 上 `x86_64-windows-musl` 不在列表里,而同一台机器能产出真正的 PE32+。 + + ⚠️ **而不是每一行缺席都是这样**:`x86_64-windows-msvc` 与 `aarch64-macos` 在 + Linux 上缺席是**对的**,MSVC 与 macOS SDK 是宿主专有的,依赖替代不了。判据不 + 需要新字段——**一行若指向本宿主装得上的编译器,那它缺的只是系统,而系统可以 + 由图供给**。第三种状态:`via dependency graph`。 + +- **镜像完整性门:一个没有重试的 GET 判掉整条发布。** + + `2026.8.25.1` 的发布红了两次,两次都报 16 个资产「already mirrored」,然后因 + 其中一个的 502 判失败。手工抓下来:5,772,395 字节,sha256 与发布的校验和逐位 + 相同。补 `--retry-all-errors`(不是 `--retry`,后者盖不住这条路径也会遇到的 + 传输层错误);并修掉 `502ERR` 拼接(`|| echo ERR` 是追加不是替换)。 + +### 测试 + +- **e2e 292/293/294**,每条两向断言,且**都在修复前的二进制上验证过会失败**: + 292 声明一层后裸机目标仍解析到同一编译器 + 宿主行不得顶掉项目自己的工具链; + 293 拒绝跨 OS 的解析 + 四个正确交叉目标零误伤;294 列出图供给的目标 + 宿主 + 专有的仍然缺席。 + +- **⚠️ `285`–`289` 此前一条都没在 CI 跑过。** 它们声明 `# requires: llvm`,而两个 + linux e2e shard 的能力行里没有 `llvm`,`run_all.sh` 在 skip 时退 0。新增 + `openkal-cross.yml` 的 `ecosystem-e2e`:装 gcc + llvm + 两个模拟器,直跑六条, + **逐条断言 PASS 行**,并对 287/288 **额外断言运行阶段那一行**(实测它们会降级 + 成 SKIP 而 OK 行照印)。 + +- **判据的「否」与「没测成」同读数**:一次会话里我自己新写的六条 e2e 有四条犯了 + 它(宿主 objdump 反汇编外架构得零指令零报错、`readelf` 读不了的文件输出零行、 + CI 自己的 PATH 本就以 subos/bin 开头)。判据一律带分母 + (`7 LSE instructions out of 148906`),工具取自产生该产物的工具链。 + ## [2026.8.25.1] — 2026-08-25 ### 修复 diff --git a/docs/03-toolchains.md b/docs/03-toolchains.md index 29f5fbeb..de919086 100644 --- a/docs/03-toolchains.md +++ b/docs/03-toolchains.md @@ -109,8 +109,22 @@ Available toolchains (run `mcpp toolchain install `): ``` `*` marks the default pair. The Targets block is the live view of the target -vocabulary: `installed` payloads, `available` targets this host can install, -and `planned` targets that are registered but not yet shipped. +vocabulary, in four statuses: + +| Status | Meaning | What to do next | +|---|---|---| +| `installed` | a payload here already produces it | nothing | +| `available` | a payload exists for this host | `mcpp toolchain install` | +| `via dependency graph` | the compiler is here; the target's system is not, and packages can supply it | depend on an implementation of the target's kernel interface and C library | +| `planned` | registered in the vocabulary, not yet shipped | — | + +⚠️ **A target absent from this block cannot be built here at all** — and that +is a narrower statement than it used to be. Until mcpp 2026.8.25.2 the block +listed only what a payload served, so a target whose system comes from a +dependency graph was missing while the same host produced real artefacts for +it. `x86_64-windows-msvc` and `aarch64-macos` remain absent on a Linux host, +correctly: MSVC and the macOS SDK are host-only and no dependency substitutes +for them. ## Windows PE via MinGW-w64 (`x86_64-windows-gnu`, no Visual Studio required) diff --git a/docs/zh/03-toolchains.md b/docs/zh/03-toolchains.md index c9e3a79f..d7bd57d9 100644 --- a/docs/zh/03-toolchains.md +++ b/docs/zh/03-toolchains.md @@ -106,9 +106,20 @@ Available toolchains (run `mcpp toolchain install `): llvm 20.1.7 ``` -`*` 标记当前的默认对。Targets 块是 target 词汇表的实时视图:`installed` -为已装的链,`available` 为本宿主可安装的 target,`planned` 为已登记但尚未 -发布的 target。 +`*` 标记当前的默认对。Targets 块是 target 词汇表的实时视图,共四种状态: + +| 状态 | 含义 | 下一步做什么 | +|---|---|---| +| `installed` | 本机已有的载荷就能产出它 | 无 | +| `available` | 本宿主存在可装的载荷 | `mcpp toolchain install` | +| `via dependency graph` | 编译器在本机,而目标的系统不在,由包供给 | 依赖一个实现该目标内核接口与 C 库的包 | +| `planned` | 已登记在词表中,尚未发布 | — | + +⚠️ **不在这个块里的 target,在本机根本构建不了**——而这句话现在比以前更窄。 +mcpp 2026.8.25.2 之前,这个块只列载荷能服务的那些,于是「系统来自依赖图」的 +target 缺席,而同一台机器能为它产出真实的产物。`x86_64-windows-msvc` 与 +`aarch64-macos` 在 Linux 宿主上仍然缺席,这是**对的**:MSVC 与 macOS SDK 是宿主 +专有的,依赖替代不了。 ## Windows PE 之 MinGW-w64(`x86_64-windows-gnu`,无需 Visual Studio) diff --git a/mcpp.toml b/mcpp.toml index d1f40df4..f9127fe6 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.25.1" +version = "2026.8.25.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 1e332dbb..baa5b58e 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -1270,6 +1270,19 @@ CompileFlags compute_flags(const BuildPlan& plan) { return f; } + // ⭐ THE PART OF THE LINK LINE THAT BELONGS TO THE TARGET, NOT THE PAYLOAD. + // + // Empty on every platform whose kernel interface is an instruction rather + // than a library — Linux's is `syscall`, so a self-contained libc from the + // dependency graph really does replace everything the payload contributed. + // Darwin's interface IS a library (`libSystem`), so a Mach-O link needs the + // SDK whoever supplies libc. + // + // Written by the macOS branch below and read by the graph replacement after + // it: one value, written once, so the two cannot disagree about what + // survives a replacement. Declared here rather than inside either, because + // the ordering between them is the whole point. + std::string platformAnchor; if constexpr (mcpp::platform::is_windows) { if (isMsvcDialect) { // Native cl.exe: link.exe does the link (SeparateLinker). Search @@ -1374,6 +1387,23 @@ CompileFlags compute_flags(const BuildPlan& plan) { std::string macos_sdk; if (auto sdk = mcpp::platform::macos::sdk_path()) macos_sdk = " -isysroot " + escape_path(*sdk); + // ⭐⭐ AND KEPT, BECAUSE THE GRAPH BRANCH BELOW REPLACES THIS LINE. + // + // `-isysroot` and the deployment floor describe the TARGET OS. Every + // other token here describes the payload — which is exactly what a + // graph-supplied C library replaces — so when that replacement runs it + // must carry these two across. It did not, and the failure was the one + // the comment above predicts, on a stack whose C library came from the + // graph (measured 2026-08-25, openkal-musl on macos-14): + // + // ld64.lld: error: library not found for -lSystem + // ld64.lld: error: undefined symbol: clock_gettime_nsec_np + // + // A Mach-O link needs the SDK whoever supplies libc, because on Darwin + // the platform interface IS a library. Linux needs no equivalent: its + // kernel interface is an instruction, so a self-contained libc from the + // graph really does replace everything. + platformAnchor = macos_sdk + version_min; f.ld = std::format("{}{}{} -fuse-ld=lld{}{}{}{}", full_static, b_flag, macos_sdk, version_min, link_intent_ld, user_ldflags, link_extra); @@ -1514,7 +1544,23 @@ CompileFlags compute_flags(const BuildPlan& plan) { if (plan.toolchain.compiler == mcpp::toolchain::CompilerId::Clang) graphLd += " -fuse-ld=lld"; - f.ld = std::format("{}{}{}{}{}", full_static, graphLd, + // ⭐⭐ AND THE TARGET'S OWN ANCHOR SURVIVES THE REPLACEMENT. + // + // Everything this branch rebuilds describes the PAYLOAD — its `-B`, its + // startup objects, its loader — and a graph-supplied C library is + // exactly what replaces those. `platformAnchor` is the part that does + // not: on Darwin it is `-isysroot ` plus the deployment floor, + // which describe the target OS and are needed however libc arrives. + // + // Measured 2026-08-25 without it, on openkal-musl over openkal-macos: + // + // ld64.lld: error: library not found for -lSystem + // ld64.lld: error: undefined symbol: clock_gettime_nsec_np + // + // — the failure the macOS branch's own comment predicts, caused by this + // branch discarding the line that prevents it. Empty everywhere else, + // so no other target's link line moves. + f.ld = std::format("{}{}{}{}{}{}", full_static, graphLd, platformAnchor, link_intent_ld, user_ldflags, link_extra); f.ldC = f.ld; // no C++ runtime token on this line diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 7f958eca..8459fbb0 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -924,6 +924,19 @@ prepare_build(bool print_fingerprint, // The target row's toolchain convention, held until the graph is known. // Empty when the row names none or the project named its own. std::string targetPinCandidate; + // ⭐⭐ AND WHETHER THAT PIN IS A CONVENTION OR A CAPABILITY, RECORDED AT + // THE SAME READ. + // + // A hosted row's pin answers "which payload supplies this target's C + // library", so a graph that supplies one instead makes it inapplicable. + // A freestanding row's pin answers a different question — the table says + // so in its own words: "the pin is llvm on every host because clang/lld + // are cross-compilers by construction". A host g++ cannot emit + // riscv64-none-elf at all, and no dependency changes that. + // + // Taken here rather than re-derived at the decision point, because the row + // is read exactly once and both facts come out of that read. + bool targetPinIsCapability = false; // Whether the resolved toolchain spec names the machine's own Visual // Studio. Decided inside `resolve_target_toolchain`, read by // `host_tc_for_build_program`, which is why it is declared out here. @@ -1673,6 +1686,7 @@ prepare_build(bool print_fingerprint, if (known && !hasToolchainOverride && !known->pin.empty() && !tc_origin_is_user_explicit(tcOrigin)) { targetPinCandidate = std::string(known->pin); + targetPinIsCapability = parsed && parsed->is_freestanding(); } if (known && known->defaultStatic && m->buildConfig.linkage.empty()) m->buildConfig.linkage = "static"; @@ -1750,7 +1764,19 @@ prepare_build(bool print_fingerprint, // here and the call site was measured to read `tc` exactly once, and that // one read wanted the target triple rather than the compiler. std::optional tc; - auto resolve_target_toolchain = [&]() -> std::expected { + // ⚠️ `std::function` AND NOT `auto`, BECAUSE THE FIRST-RUN BRANCH INSIDE + // CALLS BACK INTO IT. That branch installs a host default and then has to + // resolve THAT default for the requested target — which is what the top of + // this same function does. Recursing reuses it; writing it a second time + // there would be a second answer to one question. Depth is one: the second + // pass takes the `tcSpec.has_value()` branch that the first-run path just + // made true. + bool firstRunNeedsTargetPass = false; + // Guards the one recursive call below. Set before the call so the second + // pass cannot reach it, whatever else changed in between. + bool targetPassDone = false; + std::function()> resolve_target_toolchain; + resolve_target_toolchain = [&]() -> std::expected { std::optional parsedSpec; auto tcOriginAxis = mcpp::toolchain::Origin::Managed; if (tcSpec.has_value() && *tcSpec != "system") { @@ -2036,6 +2062,35 @@ prepare_build(bool print_fingerprint, // not the running build. tcSpec = defaultSpec; tcOrigin = TcOrigin::FirstRun; + + // ⭐⭐ AND IF A TARGET WAS ASKED FOR, RESOLVE FOR IT — THIS BRANCH JUST + // INSTALLED A HOST COMPILER AND WAS ABOUT TO BUILD WITH IT. + // + // Everything above answers "this machine has no toolchain, give it + // one", and the answer is a HOST payload. `--target` was never read + // here, so on a machine that had never built anything, + // `mcpp build --target x86_64-windows-gnu` installed a native gcc and + // compiled Windows sources with it. Measured in CI 2026-08-25: + // + // First run no toolchain configured — installing gcc@16.1.0 … + // Resolved gcc@16.1.0 → …/xim-x-gcc/16.1.0/bin/g++ + // ↑ no target in the path + // + // against the same command on a machine that already had one: + // + // Resolved gcc@16.1.0 → x86_64-windows-gnu → …/mingw-cross-gcc/… + // + // ⭐ REUSES THE PATH THAT ALREADY KNOWS HOW, rather than repeating what + // it does. `resolve_target_toolchain` maps a spec plus a target onto a + // payload and installs it; the default just chosen is the spec. A + // second implementation here would be a second answer to one question, + // which is the shape this release exists to remove. + // ⚠️ RECORDED HERE, ACTED ON BELOW — the Windows first-run block that + // follows SETS `overrides.target_triple` itself, and returning from + // here would skip it. Its own comment says why that matters: it + // persists BOTH axes, and persisting only the target leaves + // `mcpp toolchain list` disagreeing with what the build used. + firstRunNeedsTargetPass = !overrides.target_triple.empty(); } // Windows first run that got diverted to winlibs GCC: announce it and @@ -2059,6 +2114,48 @@ prepare_build(bool print_fingerprint, tcOrigin = TcOrigin::FirstRun; } + // ⭐⭐ AND NOW RESOLVE FOR THE TARGET, IF ONE WAS ASKED FOR. + // + // The first-run branch above answers "this machine has no toolchain, give + // it one", and the answer is a HOST payload; `--target` was never read + // there. On a machine that had never built anything, + // `mcpp build --target x86_64-windows-gnu` therefore installed a native + // gcc and compiled Windows sources with it — measured in CI 2026-08-25: + // + // First run no toolchain configured — installing gcc@16.1.0 … + // Resolved gcc@16.1.0 → …/xim-x-gcc/16.1.0/bin/g++ + // ↑ no target in the path + // + // against the same command where one already existed: + // + // Resolved gcc@16.1.0 → x86_64-windows-gnu → …/mingw-cross-gcc/… + // + // ⭐ REUSES THE PATH THAT ALREADY KNOWS HOW rather than repeating it. The + // default just chosen is the spec; mapping a spec plus a target onto a + // payload (installing it if absent — `autoInstall` was always true there) + // is what the top of this function does. Depth is one: the second pass + // takes the `tcSpec.has_value()` branch the first run just made true. + // ⚠️⚠️ ONE-SHOT, AND THE FLAG IS SET BEFORE THE CALL, NOT AFTER. + // + // This line sits OUTSIDE the first-run branch — it has to, because the + // Windows block just above sets the target itself — so it is evaluated on + // every pass. The first version relied on `firstRunNeedsTargetPass` being + // false on the second pass; it is a captured variable that nothing + // resets, so every pass recursed again. Measured in a consumer's CI as + // the same `Resolved` line four times and then + // + // ##[error]Process completed with exit code 139 + // + // — SIGSEGV, a stack that ran out. A recursion whose termination depends + // on state the recursive call does not change is not a depth-one + // recursion, however its comment reads. + if (!targetPassDone + && (firstRunNeedsTargetPass + || (windowsGnuFirstRun && tcSpec.has_value()))) { + targetPassDone = true; + return resolve_target_toolchain(); + } + auto detected = mcpp::toolchain::detect( explicit_compiler, runtimePayload, runtimeBindingSnapshot.contractHash); if (!detected) return std::unexpected(detected.error().message); @@ -4845,7 +4942,26 @@ prepare_build(bool print_fingerprint, } } } - if (!targetPinCandidate.empty() && !graphSuppliesSystem) { + // ⚠️ AND A FREESTANDING PIN SURVIVES IT. `graphSuppliesSystem` spans + // kernel-abi and c-abi, and it correctly cancels a HOSTED row's + // convention — that row names the payload the graph is replacing. + // A bare-metal row names the only compiler that emits the target. + // + // Measured 2026-08-25, on a three-line manifest: + // + // provides = ["mcpp:kernel-abi=openkal"] + // $ mcpp build --target riscv64-none-elf + // Resolved gcc@16.1.0 → riscv64-none-elf → …/bin/g++ + // g++: error: unrecognized argument in option '-mabi=lp64d' + // g++: error: unrecognized command-line option + // '--target=riscv64-none-elf' + // + // A package saying which layer it supplies made the host compiler be + // chosen for a target it cannot produce. Same shape as the four + // defects 2026.8.25.1 fixed: a predicate spanning two layers deciding + // something that does not depend on either of them. + if (!targetPinCandidate.empty() + && (!graphSuppliesSystem || targetPinIsCapability)) { if (tcOrigin == TcOrigin::GlobalDefault && tcSpec.has_value() && *tcSpec != targetPinCandidate) pinReplacedDefault = *tcSpec; @@ -6022,14 +6138,77 @@ prepare_build(bool print_fingerprint, // resolves all five layers from one compiler payload, and five lines // reading `(payload)` answer a question nobody asked. `MCPP_VERBOSE` // prints them all; a diagnostic always does. - mcpp::ui::info("Target", tsd::format_report( - resolvedTargetSide, + // ⚠️⚠️ THE REQUESTED TARGET AND THE RESOLVED ONE MUST NAME THE SAME + // OPERATING SYSTEM, AND UNTIL THIS LINE NOTHING CHECKED. + // + // Measured 2026-08-25 in CI, on a machine that had installed only a + // native gcc — the report itself said it, and the build carried on: + // + // Target x86_64-windows-gnu → x86_64-unknown-linux-gnu + // … + // src/stream.cpp:68:9: error: 'GetFileType' was not declared + // + // Two operating systems on one line. The cross payload was absent, so + // resolution fell back to the host compiler, and Windows sources were + // compiled for Linux; the failure surfaced a hundred lines later as an + // undeclared identifier, naming a symbol rather than the decision. + // openkal-uefi hit the same fallback at the linker + // (`ld: unrecognized option '--subsystem'`). + // + // ⭐ THE REPORT ALREADY HELD THE EVIDENCE — this asserts on it rather + // than deriving the question again. A refusal here costs one line; the + // alternative is a message about a Win32 function, in a file the reader + // did not write, for a decision made in this one. + // + // Scope is deliberately the OS and not the whole triple: an ABI or + // vendor difference between `x86_64-windows-gnu` and + // `x86_64-w64-windows-gnu` is the normalisation this very line reports, + // and refusing on it would reject every correct cross build. + // ⭐ THE NAME THE REPORT PRINTS, DERIVED ONCE AND USED BY BOTH. + // + // ⚠️ The first version of this guard read `resolvedTargetCanonical` + // directly while the report below chose among three sources. They + // agreed on the machine it was written on and disagreed in CI, where + // the canonical string was empty and the report still named the target + // from `targetDisplayName` — so the report showed the mismatch and the + // guard, asking a different variable, saw nothing to refuse. One fact, + // derived twice: the shape this whole release exists to remove. + const std::string reportedTargetName = !targetDisplayName.empty() ? targetDisplayName : (resolvedTargetCanonical.empty() ? (tc ? tc->targetTriple : std::string{}) - : resolvedTargetCanonical), - mcpp::log::is_verbose())); + : resolvedTargetCanonical); + if (!resolvedTargetSide.llvmTriple.empty() + && !reportedTargetName.empty()) { + auto want = mcpp::toolchain::triple::parse(reportedTargetName); + auto got = mcpp::toolchain::triple::parse( + resolvedTargetSide.llvmTriple); + // The inputs, when asked for. A guard that declines to fire and a + // guard that was never reached read the same from outside. + if (mcpp::log::is_verbose()) + mcpp::ui::info("Target", std::format( + "same-OS check: '{}'(os={}) vs '{}'(os={})", + reportedTargetName, want ? want->os : "", + resolvedTargetSide.llvmTriple, got ? got->os : "")); + if (want && got && !want->os.empty() && !got->os.empty() + && want->os != got->os) { + return std::unexpected(std::format( + "target '{}' resolved to a toolchain for '{}'.\n" + " Those are different operating systems, so nothing " + "built here would be for\n" + " the target that was asked for. No payload on this " + "host produces '{}',\n" + " and mcpp will not substitute the host's.\n" + " install one with `mcpp toolchain install " + "`, or name it\n" + " explicitly with `[target.{}] toolchain = \"…\"`.", + reportedTargetName, resolvedTargetSide.llvmTriple, + reportedTargetName, reportedTargetName)); + } + } + mcpp::ui::info("Target", tsd::format_report( + resolvedTargetSide, reportedTargetName, mcpp::log::is_verbose())); } // ── L3: ROOT build.mcpp (moved after dependency resolution, design §3.1 diff --git a/src/toolchain/lifecycle.cppm b/src/toolchain/lifecycle.cppm index b14ea86f..753f7525 100644 --- a/src/toolchain/lifecycle.cppm +++ b/src/toolchain/lifecycle.cppm @@ -591,7 +591,37 @@ export int toolchain_list(const mcpp::config::GlobalConfig& cfg) { auto t = mcpp::toolchain::triple::parse(info.canonical); if (!t) continue; bool planned = info.tier == "planned"; - if (!planned && !installable_here(*t)) continue; + // ⭐⭐ A ROW NO PAYLOAD SERVES IS NOT THEREFORE UNBUILDABLE. + // + // `host_can_serve` answers "can this host serve the target FROM A + // PAYLOAD". Dropping the row presents that as "can this host build for + // the target", and one row separates the two. Measured 2026-08-25 on + // Linux: `x86_64-windows-musl` was absent from this list while the same + // machine produced a real artefact for it — + // + // $ mcpp build --target x86_64-windows-musl + // c-abi musl (openkal-musl@0.3.5, graph) + // $ file …/winmusl.exe + // PE32+ executable (console) x86-64, for MS Windows + // + // — because its system came from the dependency graph, which is what + // the row's own note in the target table says happens. + // + // ⚠️ AND NOT EVERY ABSENT ROW IS THAT. `x86_64-windows-msvc` and + // `aarch64-macos` are absent on a Linux host CORRECTLY: MSVC and the + // macOS SDK are host-only and no dependency substitutes for them. + // The discriminator is already in the table and needs no new field — + // a row that names a compiler THIS host can install is one whose only + // missing piece is the system, and a graph can supply a system. + bool graphCouldServe = false; + if (!planned && !installable_here(*t) && !info.pin.empty()) { + auto at = info.pin.find('@'); + auto fam = info.pin.substr(0, at == std::string_view::npos + ? info.pin.size() : at); + for (auto const& idx : mcpp::toolchain::available_toolchain_indexes()) + if (idx.ximName == fam) { graphCouldServe = true; break; } + } + if (!planned && !installable_here(*t) && !graphCouldServe) continue; TargetRow r; r.target = std::string(info.canonical); r.note = note_for(*t); @@ -599,7 +629,13 @@ export int toolchain_list(const mcpp::config::GlobalConfig& cfg) { std::string pin(info.pin); if (auto at = pin.find('@'); at != std::string::npos) pin[at] = ' '; r.toolchain = pin.empty() ? "—" : pin; - r.status = planned ? "planned" : "available"; + // Three answers, not two. "available" means a payload here produces it; + // "via dependency graph" means the compiler is here and the system has + // to come from packages — a different thing to do next, so a different + // word. + r.status = planned ? "planned" + : graphCouldServe ? "via dependency graph" + : "available"; r.rank = planned ? 2 : 1; targetRows.push_back(std::move(r)); } diff --git a/src/version.cppm b/src/version.cppm index ba10d94f..c55c4771 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.25.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.25.2"; } // namespace mcpp diff --git a/tests/e2e/292_a_package_that_names_a_layer_does_not_lose_the_targets_compiler.sh b/tests/e2e/292_a_package_that_names_a_layer_does_not_lose_the_targets_compiler.sh new file mode 100755 index 00000000..463da7c5 --- /dev/null +++ b/tests/e2e/292_a_package_that_names_a_layer_does_not_lose_the_targets_compiler.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# requires: llvm unix-shell +# Declaring which layer a package supplies must not change which compiler can +# emit the target. +# +# ⚠️ MEASURED 2026-08-25, ON A THREE-LINE MANIFEST. Adding one `provides` line +# to a project moved a bare-metal RISC-V build onto the host's g++: +# +# provides = ["mcpp:kernel-abi=openkal"] +# $ mcpp build --target riscv64-none-elf +# Resolved gcc@16.1.0 → riscv64-none-elf → …/bin/g++ +# g++: error: unrecognized argument in option '-mabi=lp64d' +# g++: error: unrecognized command-line option '--target=riscv64-none-elf' +# +# The cause is a predicate spanning kernel-abi ∪ c-abi that cancelled the target +# row's compiler. For a HOSTED row that is right — the row names the payload +# supplying the target's C library, and a graph that supplies one instead makes +# it inapplicable. A bare-metal row names the only compiler that emits the +# target at all: `clang`/`lld` are cross-compilers by construction and a host +# g++ cannot produce riscv64-none-elf whatever the graph contains. +# +# ⭐⭐ BOTH DIRECTIONS, BECAUSE ONLY ONE OF THEM IS THE FIX. Never cancelling +# the pin also stops this failing, and it would restore the substitution the +# predicate was added to prevent — a hosted project whose C library comes from +# its graph having its chosen toolchain silently replaced. The second half +# builds that arrangement and requires the choice to survive. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +resolved() { # dir target → "family@version", or nothing + (cd "$1" && "$MCPP" build --target "$2" 2>&1 || true) \ + | grep -oP 'Resolved \K[a-z]+@[0-9.]+' | head -1 +} + +make_project() { # dir provides-line + mkdir -p "$1/src" + { printf '[package]\nname = "layerprobe"\nversion = "0.1.0"\n' + [ -n "$2" ] && printf '%s\n' "$2"; } > "$1/mcpp.toml" + printf 'extern "C" void _start() { for (;;) {} }\n' > "$1/src/main.cpp" +} + +# ── Half one: a bare-metal target keeps the compiler that can emit it ────── +# +# The control comes first: without the declaration the row's pin is what any +# machine resolves, and a test that never established that baseline could not +# tell "the fix works" from "this machine has no llvm". +make_project "$work/plain" "" +base="$(resolved "$work/plain" riscv64-none-elf)" +case "$base" in + llvm@*) echo " ok baseline: the bare-metal row resolves $base" ;; + *) echo "SKIP: the bare-metal row did not resolve to llvm here (got '${base:-nothing}')" + exit 0 ;; +esac + +make_project "$work/declares" 'provides = ["mcpp:kernel-abi=openkal"]' +after="$(resolved "$work/declares" riscv64-none-elf)" +if [ "$after" = "$base" ]; then + echo " ok and it still resolves $after after the package names a layer" +else + echo "FAIL: naming a layer changed the target's compiler" + echo " without provides: $base" + echo " with provides: ${after:-nothing}" + exit 1 +fi + +# ── Half two: a hosted project's own choice still wins over the row ──────── +# +# `x86_64-linux-musl` carries `gcc@16.1.0` in the table because the musl-gcc +# payload is what supplies that target's C library. A project whose C library +# comes from its graph does not use that payload, so the row must not replace +# a toolchain the user set. +mkdir -p "$work/hosted/src" +cat > "$work/hosted/mcpp.toml" <<'TOML' +[package] +name = "hostedprobe" +version = "0.1.0" +provides = ["mcpp:c-abi=musl"] + +[toolchain] +default = "llvm@22.1.8" +TOML +printf 'int main() { return 0; }\n' > "$work/hosted/src/main.cpp" +hosted="$(resolved "$work/hosted" x86_64-linux-musl)" +case "$hosted" in + llvm@*) + echo " ok a hosted row does not replace the project's own toolchain: $hosted" ;; + "") + echo "SKIP: the hosted project did not report a resolution here" ;; + *) + echo "FAIL: the target row replaced the toolchain this project chose" + echo " chose llvm@22.1.8, resolved $hosted" + exit 1 ;; +esac + +echo "OK: naming a layer changes the system, not the compiler that emits the target" diff --git a/tests/e2e/293_the_requested_target_and_the_resolved_one_name_one_os.sh b/tests/e2e/293_the_requested_target_and_the_resolved_one_name_one_os.sh new file mode 100755 index 00000000..0a804b03 --- /dev/null +++ b/tests/e2e/293_the_requested_target_and_the_resolved_one_name_one_os.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# requires: gcc unix-shell +# A build for one operating system is never quietly performed for another. +# +# ⚠️ MEASURED 2026-08-25 IN CI. On a machine that had installed only a native +# gcc, the cross payload was absent, resolution fell back to the host compiler, +# and the report said so on one line while the build carried on: +# +# Target x86_64-windows-gnu → x86_64-unknown-linux-gnu +# … +# src/stream.cpp:68:9: error: 'GetFileType' was not declared in this scope +# +# Two operating systems, one line, no diagnostic. Windows sources were compiled +# for Linux and the failure surfaced a hundred lines later naming a Win32 +# function — a symbol, not the decision that produced it. openkal-uefi reached +# the same fallback at the linker: `ld: unrecognized option '--subsystem'`. +# +# ⭐⭐ BOTH DIRECTIONS, BECAUSE A REFUSAL THAT FIRES TOO OFTEN IS WORSE THAN THE +# DEFECT. `x86_64-windows-gnu → x86_64-w64-windows-gnu` differs in vendor and +# spelling and is exactly right; refusing on anything but the OS would reject +# every correct cross build. The second half is a sweep that must not refuse. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +make_project() { # dir target-section + mkdir -p "$1/src" + { printf '[package]\nname = "osprobe"\nversion = "0.1.0"\n' + [ -n "$2" ] && printf '\n%s\n' "$2"; } > "$1/mcpp.toml" + printf '#include \nint main() { std::printf("ok\\n"); }\n' > "$1/src/main.cpp" +} + +# ── Half one: it refuses, and says which two systems ────────────────────── +# +# `toolchain = "system"` is the escape hatch that hands the build the PATH +# compiler. Pointing it at a Windows target is the one arrangement that +# reproduces CI's fallback without uninstalling anything. +make_project "$work/mismatch" '[target.x86_64-windows-gnu] +toolchain = "system"' +out="$(cd "$work/mismatch" && "$MCPP" build --target x86_64-windows-gnu 2>&1 || true)" + +# ⚠️⚠️ AND A SKIP HERE HAS TO BE EARNED, OR THE TEST CANNOT SEE A REVERT. +# The first draft took the skip branch whenever no refusal appeared — which is +# precisely what the unfixed build does, so reverting the fix turned this test +# green-by-silence rather than red. The arrangement either reproduced (the +# report names two systems) or it did not; only the second is a skip. +reported="$(printf '%s\n' "$out" | grep -oP 'Target \K\S+ → \S+' | head -1)" +case "$out" in + *"different operating systems"*) + echo " ok it refuses rather than building for the wrong system" ;; + *) + asked="${reported%% → *}" + resolved="${reported##* → }" + if [ -n "$reported" ] \ + && printf '%s' "$asked" | grep -q 'windows' \ + && printf '%s' "$resolved" | grep -q 'linux'; then + echo "FAIL: a Windows target resolved to a Linux toolchain and the build went on" + echo " $reported" + # ⚠️ THE WHOLE OUTPUT, because the one line above says WHAT happened and + # not which decision produced it. This failure first appeared only in + # CI, where the arrangement differs from any machine it was written on, + # and a one-line report cannot be read backwards into a cause. + echo " ── what mcpp said ──" + printf '%s\n' "$out" | sed 's/^/ /' + exit 1 + fi + echo "SKIP: this host did not reproduce the fallback" + printf '%s\n' "$out" | grep -iE 'Target |error' | head -3 | sed 's/^/ /' + exit 0 ;; +esac + +# ⭐ AND THE MESSAGE NAMES BOTH. A refusal that does not say what it resolved +# to leaves the reader with the same question the report used to answer. +ok=1 +printf '%s\n' "$out" | grep -q "x86_64-windows-gnu" || ok=0 +printf '%s\n' "$out" | grep -q "linux" || ok=0 +if [ "$ok" = 1 ]; then + echo " ok and it names the target asked for and the one resolved" +else + echo "FAIL: the refusal does not name both systems" + printf '%s\n' "$out" | head -4 | sed 's/^/ /' + exit 1 +fi + +# ── Half two: every correct cross build still goes through ──────────────── +# +# ⚠️ A sweep that swept nothing is a SKIP, not a pass. +built=0; skipped=0 +for t in x86_64-linux-gnu x86_64-linux-musl aarch64-linux-musl x86_64-windows-gnu; do + make_project "$work/ok-$t" "" + o="$(cd "$work/ok-$t" && "$MCPP" build --target "$t" 2>&1 || true)" + case "$o" in + *"different operating systems"*) + echo "FAIL: a correct cross build was refused: $t" + printf '%s\n' "$o" | grep -i 'Target ' | head -1 | sed 's/^/ /' + exit 1 ;; + *"Target "*) + line="$(printf '%s\n' "$o" | grep -oP 'Target \K\S+ → \S+' | head -1)" + echo " ok $line" + built=$((built+1)) ;; + *) + skipped=$((skipped+1)) ;; + esac +done + +if [ "$built" = 0 ]; then + echo "SKIP: no target resolved here — the sweep proved nothing ($skipped skipped)" + exit 0 +fi +echo " ok $built correct cross targets went through untouched" + +echo "OK: the requested target and the resolved one name one operating system" diff --git a/tests/e2e/294_the_list_answers_what_can_be_built_not_what_has_a_payload.sh b/tests/e2e/294_the_list_answers_what_can_be_built_not_what_has_a_payload.sh new file mode 100755 index 00000000..401f0f64 --- /dev/null +++ b/tests/e2e/294_the_list_answers_what_can_be_built_not_what_has_a_payload.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# requires: llvm unix-shell +# `toolchain list` names the targets this host can build for, including the ones +# whose system has to come from a dependency graph. +# +# ⚠️ IT USED TO FILTER ON `host_can_serve`, WHICH ANSWERS A NARROWER QUESTION — +# "can a payload here serve this target" — and presented it as "can this be +# built". Measured 2026-08-25 on Linux: `x86_64-windows-musl` was absent from +# the list while the same machine produced a real artefact for it, +# +# $ mcpp build --target x86_64-windows-musl +# c-abi musl (openkal-musl@0.3.5, graph) +# $ file …/winmusl.exe +# PE32+ executable (console) x86-64, for MS Windows, 14 sections +# +# because its system came from the graph. +# +# ⭐⭐ AND THE SECOND HALF IS THE POINT. Listing every vocabulary row would also +# make the first half pass, and it would tell a Linux user they can build +# `x86_64-windows-msvc` — which needs MSVC, or `aarch64-macos`, which needs the +# macOS SDK. Neither is something a dependency can supply. A list that +# over-promises is worse than one that under-promises, because the first costs +# a build to discover. +set -e + +MCPP="${MCPP:-mcpp}" +out="$("$MCPP" toolchain list 2>&1 || true)" +targets="$(printf '%s\n' "$out" | awk '/^Targets:/,0')" + +if [ -z "$targets" ]; then + echo "SKIP: this build printed no Targets section" + exit 0 +fi + +row() { printf '%s\n' "$targets" | grep -E "^\s+\*?\s*$1(\s|$)" | head -1; } + +# ── Half one: a graph-served target is listed, and says so ──────────────── +# +# Linux only: on Windows the same row IS payload-served, and on macOS there is +# no Windows-PE payload of any kind — the row's absence there is correct and +# means something else. +case "$(uname -s)" in + Linux) ;; + *) echo "SKIP: the graph-served row under test is a Linux-host arrangement"; exit 0 ;; +esac + +wm="$(row x86_64-windows-musl)" +if [ -z "$wm" ]; then + echo "FAIL: x86_64-windows-musl is absent, and this host can build it" + # ⚠️ WHICH BINARY ANSWERED. A list missing a row and a list produced by an + # older mcpp look identical, and the first CI failure of this test could not + # tell them apart — so the evidence names the program as well as its output. + echo " asked: ${MCPP} ($("$MCPP" --version 2>&1 | head -1))" + printf '%s\n' "$targets" | sed 's/^/ /' + exit 1 +fi +case "$wm" in + *"dependency graph"*) + echo " ok x86_64-windows-musl is listed as needing a dependency graph" ;; + *installed*|*available*) + echo " ok x86_64-windows-musl is listed (payload-served on this host)" ;; + *) + echo "FAIL: listed with a status that says neither" + echo " $wm" + exit 1 ;; +esac + +# ── Half two: what a graph cannot supply stays out ──────────────────────── +# +# ⚠️ ASSERTS THE ROW IS ABSENT, so it first establishes that rows are being +# printed at all — an empty section would pass this trivially. +n="$(printf '%s\n' "$targets" | grep -cE '^\s+\*?\s*[a-z0-9_]+-' || true)" +if [ "${n:-0}" -lt 3 ]; then + echo "SKIP: only ${n:-0} target rows printed — too few to assert an absence" + exit 0 +fi +echo " ok $n target rows printed, so an absence below means something" + +fail=0 +for t in x86_64-windows-msvc aarch64-macos; do + r="$(row "$t")" + if [ -n "$r" ]; then + echo "FAIL: $t is offered on a Linux host, and no dependency supplies it" + echo " $r" + fail=1 + else + echo " ok $t stays out — it needs a host-only toolchain" + fi +done +[ "$fail" = 0 ] || exit 1 + +echo "OK: the list answers what can be built, not what has a payload"