diff --git a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md new file mode 100644 index 00000000..593145bf --- /dev/null +++ b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md @@ -0,0 +1,439 @@ +# 裸机 / freestanding — 第三阶段:从「能跑」到「能用」 + +- Date: 2026-08-19 +- Status: **方案计划,待 review** +- 上游:`2026-08-19-baremetal-ecosystem-closure-plan.md`(第二阶段;§10 记录已落地与三处实测修正) +- 已发布基线:**mcpp 2026.8.19.1** · `xim:qemu-riscv@9.2.4-1` · `xim:picolibc-riscv@1.8.12` +- 范围:第二阶段 §10.4 列出的四个缺口 —— **runner 归属** · **生态发布** · **裸机 test** · **产物形态 + 模板** + +--- + +## 0. 现在在哪(全部已验证,不是推测) + +``` +$ mcpp run --target-triple riscv64-none-elf # mcpp 2026.8.19.1,来自已发布索引 +BSP-CHAIN-OK 42 +float 3.1416 +MALLOC-OK +``` + +| 已经成立 | 证据 | +|---|---| +| 引擎认识 `riscv64-none-elf` / `riscv32-none-elf` | `toolchain list` 显示 `bare, static, cross` | +| 从 C++20 模块构建裸机镜像 | `tests/e2e/130`,CI 每次跑 | +| BSP 供 sysroot + 链接脚本 + 运行时,消费者只声明依赖 | `tests/e2e/131`,CI 每次跑 | +| `mcpp:link-script=` · `mcpp::xpkg_dir()` · `[target.X].runner` | 已发布在 2026.8.19.1 | + +**还差四件事,而它们的性质完全不同:** 一件是**真设计题**(§2),一件是**发布工程**(§3), +两件是**已知形状的实现**(§4/§5/§6)。 + +--- + +## 1. 判据 + +沿用第二阶段 §1 的总判据(用户写五行、跑三条命令、看不到 `picolibc`/`crt0`/`-nostdlib`/ +`-machine virt` 等词),并把否定判据补到四条: + +| # | 不算「能用」 | 现状 | +|---|---|---| +| **N1** | `mcpp run` 要用户自己拼 qemu 命令 | ⛔ **仍然违反** —— `runner` 在消费者 manifest 里 | +| **N2** | 要用户手写 `link-search` / `cflag` | ✅ 已解决(BSP 供) | +| **N3** | 换一块板要改引擎而不是换依赖 | ✅ 结构上成立,但**没有第二块板证过** | +| **N4** | 依赖只能用 `path = "..."`,`mcpp add` 拿不到 | ⛔ **仍然违反** —— 两个包都没进索引 | + +⭐ **第三阶段的完成判据 = N1 与 N4 同时消失**,即: + +```bash +mcpp new --template baremetal-riscv blinky && cd blinky +mcpp add riscv-virt-rt # 从已发布索引 +mcpp build --target riscv64-none-elf +mcpp run --target riscv64-none-elf # runner 由 BSP 供,manifest 里没有它 +mcpp test --target riscv64-none-elf +``` + +--- + +## 2. ⭐ R1:runner 归属 BSP —— 本阶段唯一的真设计题 + +### 2.1 为什么它不能照抄任何现成机制 + +四条**已实测**的约束,任意一条都能否掉一个方案: + +| # | 约束 | 出处 | +|---|---|---| +| C1 | **模拟器必须用绝对路径** | CI 实测:runner 写裸名 `qemu-system-riscv64` 时 `mcpp run` 报 `[error] xlings: 'qemu-system-riscv64' is not installed`,而同一 job 两步之前 `--version` 刚跑通 —— shim 按**拥有它的 home**派发 | +| C2 | 那个绝对路径**机器相关**,静态 manifest 写不出来 | 载荷路径含 home 与版本 | +| C3 | 能算出它的只有 `build.mcpp` | 它是唯一拿得到 `xpkg_dir()` 与 `MCPP_TARGET_*` 的地方 | +| C4 | 但 `build.mcpp` 是**构建期**程序,`mcpp run` 要的是**运行期**值 | 定义 | + +⇒ **C2+C3 排除静态 manifest 传播;C4 说明这个值必须被构建期算出、持久化、运行期读回。** + +⚠️ 顺带排除一个看起来更简单的方案:让 BSP 在 manifest 里写 +`[target.riscv64-none-elf] runner = [...]` 并让它沿依赖边传播。C1/C2 让它写不出可用的值。 + +### 2.2 设计:`mcpp:runner=` 指令 + 新 Slot + +**一条新 directive,与 `link-script` 同族**(第二阶段已经证明这条路走得通): + +```cpp +// BSP 的 build.mcpp +const char* qemu = mcpp::xpkg_dir("xim", "qemu-riscv"); // 已有接口 +mcpp::runner(std::format("{}/bin/qemu-system-riscv64", qemu).c_str()); +mcpp::runner("-machine"); mcpp::runner("virt"); +mcpp::runner("-nographic"); mcpp::runner("-no-reboot"); +mcpp::runner("-semihosting"); mcpp::runner("-bios"); mcpp::runner("none"); +mcpp::runner("-kernel"); +``` + +| 决定 | 取值 | 理由 | +|---|---|---| +| 编码 | **一行一个 argv token,按发射顺序** | argv 是有序列表,而 directive 是「一行一值」。JSON 数组(像 `action`)要引入转义规则,而这里没有任何一处需要嵌套 | +| Slot | **新 `Slot::Runner`** | 它既不是编译输入也不是链接输入;塞进 `LdFlags` 会让它进链接线 | +| Scope | **新 `Scope::RunGlobal`** | 语义与 `LinkGlobal` 平行(到达消费者),但落到运行配置而非链接线。⚠️ **必须是新的 Scope 值**,否则 `link-script` 与它会共享传播路径与冲突规则 | +| 持久化 | 随 directive 记录进构建缓存 | 已有机制,`mcpp run` 的快路径已经读它 | +| 冲突 | **两个依赖都供 = 硬错误**,消费者自己写的 `[target.X].runner` **覆盖**依赖供的 | 两个 BSP 同时供是配置错误;消费者显式覆盖是正当的(调试时换 `-bios default`) | + +### 2.3 ⚠️ 这一条要改的地方(每处都要有测试) + +1. `directives.cppm` — 新 Slot + 新 Scope + 一行表项 + `Transform::Verbatim` +2. `hostprogram.cppm` — `mcpp::runner(tok)` 接口 + protocol 4 +3. 传播 — `Scope::RunGlobal` 的 fixpoint(与 LinkGlobal 同形,但**独立的冲突检查**) +4. `execute.cppm` — `build_run_target` 先读依赖供的,消费者 override 优先 +5. `test_targets.cppm` / 测试执行 — **同一个 runner**(见 §4) + +⭐ **判据(两侧)**: +- BSP 供 runner ⇒ 消费者 manifest 里**没有 `[target.*]` 段**也能 `mcpp run`; +- 两个依赖都供 ⇒ **硬错误并同时点名两个包**; +- 消费者写了自己的 ⇒ 用消费者的,且**说明它覆盖了谁**。 + +--- + +## 3. R2:两个生态包 + 索引收录 + +### 3.1 要建的仓库 + +| 仓库 | 内容 | 依赖 | +|---|---|---| +| `mcpplibs/riscv-virt-rt` | qemu `virt` 的 BSP。`[xlings] deps = ["xim:picolibc-riscv@1.8.12", "xim:qemu-riscv@9.2.4-1"]`;`build.mcpp` 按 `MCPP_TARGET_ARCH` 选档位,发 include-dir(私有)+ link-search/link-lib/link-script/**runner** | mcpp ≥ 2026.8.19.1(+ §2 后的版本) | +| `mcpplibs/std-freestanding` | `mcpplibs.std.freestanding` —— 私有 include picolibc 头,导出可移植 std 子集模块 | 同上 | + +⚠️ **`riscv-virt-rt` 的代码在 `tests/e2e/131` 里已经逐行验证过**,建仓是把它从 heredoc 搬进仓库并补 README/CI/examples,不是重新设计。 + +### 3.2 ⚠️ 兼容底线:一个需要新 mcpp 的包,怎么进一个服务老客户端的索引 + +**已核准的事实:mcpp 只有索引级 `index.toml [index].min_mcpp`,没有包级的 mcpp 版本要求。** + +| 方案 | 后果 | +|---|---| +| 抬高索引 `min_mcpp` | ⛔ **绝对不行** —— 索引是数据、mcpp 是程序,**发布数据不得让程序失效**;抬高会让所有老客户端拿不到**任何**包 | +| 什么都不加 | 老客户端能解析、能下载,构建到 `build.mcpp` 那一步才失败 —— 但失败**是可行动的**:2026.8.19.1 起诊断会说「要么这个包写给更新的 mcpp(试 `mcpp self update`),要么指令拼错了」 | +| 加**包级** `requires-mcpp` 键 | 早失败、消息更准,但 ⚠️ **要先探针**:老客户端遇到未知的**顶层 manifest 键**是警告还是硬失败?若是硬失败,已发布包永远无法采用新键(与 provisions 的 reexport 键同一形状) | + +⭐ **决定:先按「什么都不加」发布,并把包级 `requires-mcpp` 作为一个独立探针(P-COMPAT)排在后面。** +理由:诊断已经可行动,而一个会让老客户端硬失败的新键代价高得多 —— 且这个代价**尚未测过**。 + +#### ⚠️ 实施时探到的:上表第 2 行「诊断可行动」对**类型化 API 是假的** + +那条可行动的诊断(`protocol_error()`,点名 `mcpp self update`)只对**wire 键**生效, +而它要求程序**先编译得过**。BSP 用的是 `mcpp::runner(...)`,老 mcpp 上根本编不过, +拿到的是: + +``` +error: 'runner' is not a member of 'mcpp' +``` + +—— 读起来像**包作者拼错了**,而不是**读者的引擎旧了**。 + +我先试了在包里做优雅降级,**实测证伪**: + +```cpp +if constexpr (requires { mcpp::runner("qemu"); }) // ✗ 名字不存在 ⇒ 硬错误 +``` + +`requires` 表达式作用在**不存在的限定名**上是 ill-formed,**不是求值为 `false`**。 +⇒ **语言内没有特性探测**,包侧无路可走。 + +⭐ **所以补在引擎侧**:`build.mcpp` 编译失败且错误里出现「不是 `mcpp` 的成员」时, +追加一段点名 `mcpp self update` 并报出当前版本的提示(三个前端三种写法都认)。 +**这对以后每一次类型化 API 新增都生效**,不只是 `runner`。 +判据:`mentions_missing_mcpp_api` 单测钉三种拼写 + 三条不该误报的普通错误。 + +### 3.3 收录进 `mcpplibs/mcpp-index` + +`pkgs/r/riscv-virt-rt.lua` 与 `pkgs/m/mcpplibs.std.freestanding.lua`。 + +⭐ **判据是 `mcpp add` 而不是「文件进了仓库」**: + +```bash +mcpp new probe && cd probe +mcpp add riscv-virt-rt # 从已发布索引解析、下载、写进 mcpp.toml +mcpp build --target riscv64-none-elf +``` + +⚠️ **发布顺序有依赖**:`riscv-virt-rt` 的 runner 需要 §2 落地。⇒ 先发**不带 runner** 的 +版本(0.1.0,消费者仍写 `[target.X].runner`),§2 落地后发 0.2.0 把 runner 收进 BSP。 +**两次发布都要留在索引里**,这样 0.1.0 的用户不会被新 mcpp 的要求卡住。 + +--- + +## 4. R3:裸机 `mcpp test`(W11) + +### 4.1 已核准的机制事实 + +- `mcpp test` 从 `tests/**/*.cpp` 发现用例,每个编成一个**独立可执行**,用 + `run_exec_deadline` 跑,退出码即判据。 +- 裸机上这两条都不成立:没有进程退出码回到宿主,而且**每个用例一个镜像**意味着 + N 次 qemu 冷启动。 + +### 4.2 设计 + +| 决定 | 取值 | 理由 | +|---|---|---| +| 默认模式 | **`batch`**:所有用例编进**一个**镜像,semihosting 打用例名与结果,固件自行 poweroff | qemu `virt` 冷启动实测 ~0.4s;30 个用例 isolated 就是 12s 纯开销 | +| 结果通道 | **stdout 上的结构化行**(`mcpp-test: `),`mcpp` 侧解析 | 没有退出码可用;semihosting 的 stdout 是唯一可靠通道 | +| 收尾 | 固件写 syscon `0x5555` 关机 | 已验证:否则 qemu 永远不退,只能靠超时 | +| 超时 | 整镜像一个 deadline;**超时后自动用 `isolated` 重跑一次** | batch 下「跑飞」只知道**没跑完**,不知道**是谁**;重跑一次才定位得到 | +| `isolated` | 可选开关,一个用例一个镜像 | 定位用,不做默认 | +| 执行 | **走 §2 的同一个 runner** | ⚠️ 两条路径(run / test)各自推导 runner 就是「同一决策两处推导」 | + +### 4.3 ⚠️ 判据 + +- 3 个用例 batch 一次 qemu 全过; +- **人为让第 2 个死循环 ⇒ 超时后 mcpp 指出是第 2 个**(不是「有用例超时」); +- ⚠️ **一个用例失败不能让整批静默变绿** —— 断言 `mcpp test` 的退出码非零且列出失败者。 + +--- + +## 5. R4:产物形态(W13) + +| 产物 | 怎么来 | 为什么需要 | +|---|---|---| +| `.elf` | 已有 | 调试、qemu `-kernel` | +| `.bin` | `llvm-objcopy -O binary` | 真硬件烧录只吃裸二进制 | +| `.map` | `-Wl,-Map=.map` | 裸机上「为什么这段没进来」只有 map 能答 | +| **size 摘要** | `llvm-size` → 构建后打印 text/data/bss | ⭐ 裸机的核心约束是**容量**;不打印等于让用户自己去查 | + +⚠️ **实现要点**:`.bin` 与 `.map` 是**额外的 ninja 边**,不是链接命令的副产物 —— +`.map` 可以挂在链接行上,`.bin` 必须是一条以 `.elf` 为输入的新边,否则增量构建不会重生成。 + +⭐ **判据**:改一行源码 → `.bin` 的 mtime 与内容都变;`.map` 里能找到该符号。 + +--- + +## 6. R5:`mcpp new --template baremetal-riscv`(T1) + +生成的工程**必须开箱通过 §1 的三条命令**,且 manifest 不超过: + +```toml +[package] +name = "blinky" +version = "0.1.0" + +[build] +target = "riscv64-none-elf" + +[dependencies] +riscv-virt-rt = "0.2" +``` + +⚠️ **两个实测约束会体现在模板里**: + +1. **裸机固件可以有 `main`** —— 只要 BSP 带了 picolibc 的 `crt0`,`main` 就是普通的 + `main`。**只有零 libc 档才需要 `[targets.X] main = "src/start.S"`**。⇒ 模板走 BSP 路线, + 用户看到的就是 `int main()`。 +2. 模板要**同时**给一个 `tests/` 用例,否则 `mcpp test` 在新工程上无事可做,W11 的价值 + 在用户第一次接触时就是隐形的。 + +--- + +## 7. 依赖拓扑 · 里程碑 · 并行度 + +``` +R1 runner 归属 BSP ──┬──► R2b riscv-virt-rt 0.2(收 runner) + (引擎 + 协议 4) │ + └──► R3 裸机 mcpp test(共用同一个 runner) + │ +R2a 两个仓库 + 索引收录 ────────────┼──► R5 模板(需要 add 得到 + runner 由 BSP 供) + (0.1.0,不含 runner) │ + │ +R4 产物形态 ────────────────────────┘ (完全独立,任何时候可做) +``` + +| 里程碑 | 含 | ⚠️ 验收判据(必须是这句) | +|---|---|---| +| **M-3′ 生态可取** | R2a | `mcpp add riscv-virt-rt` 从**已发布索引**成功,且构建跑通 | +| **M-3″ N1 消失** | R1 · R2b | ⭐ 消费者 manifest **没有 `[target.*]` 段**,`mcpp run` 仍在 qemu 里跑出预期串 | +| **M-4 能测** | R3 | 3 用例 batch 一次全过;第 2 个跑飞 ⇒ **超时后指出是第 2 个**;1 个失败 ⇒ **退出码非零** | +| **M-5 能烧** | R4 | 改一行源码 ⇒ `.bin` 内容变;构建后打印 text/data/bss | +| **M-6 ⭐ 能用** | R5 | ⭐ **干净机器上 `mcpp new --template baremetal-riscv` → 三条命令全过,用户没见过 §1 列出的任何一个词** | + +**并行度**:R4 零依赖;R2a 只依赖已发布的 mcpp(今天就能开);R1 是唯一的串行头。 + +--- + +## 8. ⚠️ 风险(每条有出处) + +| # | 风险 | 出处 | 防线 | +|---|---|---|---| +| **R-1** | **runner 用裸名 ⇒ 换台机器就不工作** | CI 实测(shim 按 owner home 派发) | BSP 必须发**绝对路径**;e2e 断言 runner 首 token 是绝对路径 | +| **R-2** | **新 Scope 与 LinkGlobal 共用传播路径** ⇒ 冲突规则互相污染 | 设计 | `Scope::RunGlobal` 必须是**独立值**,并有自己的冲突测试 | +| **R-3** | **run 与 test 各自推导 runner** | 本仓反复付过的形状(#233/#240/#242/#344) | 一个读点,两个调用方 | +| **R-4** | **batch 模式下一个用例失败被读成整批成功** | 假绿家族 | 判据是**退出码非零 + 列出失败者**,不是「有输出」 | +| **R-5** | **`.bin` 不是新 ninja 边 ⇒ 增量构建拿到陈旧二进制** | 增量语义 | 判据是**改一行源码后 `.bin` 内容变** | +| **R-6** | **抬高索引 `min_mcpp` 把老客户端变砖** | 记忆:PR#349 | ⛔ 绝不抬高;包级要求走 P-COMPAT 探针 | +| **R-7** | **未知顶层 manifest 键让老客户端硬失败** ⇒ 已发布包永远无法采用新键 | provisions reexport 同形 | P-COMPAT **先探再定**,不先加键 | +| **R-8** | **模板生成的工程在干净机器上装不齐依赖** | 首次体验 | M-6 判据明写「干净机器」 | +| **R-9** | **只有一块板 ⇒ N3 从未被证过** | 现状 | R2b 之后加**第二块板**(rv32 档)作为 N3 的证据 | +| **R-10** | **读代码下结论** | 本轮被实测推翻五次以上 | 每个单元判据都是命令 + 期望输出 | + +--- + +## 9. 兼容性与无感升级 + +| 轴 | 问题 | 处置 | +|---|---|---| +| **protocol 3 → 4** | 加 `mcpp:runner=` 要不要抬 protocol? | ⭐ **要**。抬了之后,老 mcpp 遇到它会说「要么这个包写给更新的 mcpp,要么拼错了」(2026.8.19.1 起的措辞),而不是静默丢弃 | +| **BSP 0.1.0 → 0.2.0** | 0.2.0 需要更新的 mcpp | **两个版本都留在索引里**;0.1.0 的用户不受影响 | +| **索引底线** | 是否抬 `min_mcpp` | ⛔ 不抬(R-6) | +| **`[target.X].runner` 的去留** | BSP 供了之后它还留着吗 | **留着,并且是覆盖语义** —— 调试时换一个 `-bios` 是正当需求,而删掉一个已发布的键是破坏性变更 | + +--- + +## 10. 不在本阶段 + +| 项 | 去向 | +|---|---| +| **第二块板 / ARM Cortex-M** | R-9 要求 rv32 作为 N3 的证据;真正的 ARM 支持另立 | +| **E-STD S-3**(`format` / `sort` / `string` 全功能) | 需为目标编 libc++ | +| **烧录 / OTA** | 设备侧的事,mcpp 不碰 | +| **D 档**(openkal / openhal / openarch) | 路线图在第一阶段计划 §7 | + +--- + +## 11. 实施记录:两条计划主张被同一轮实测推翻 + +§4 的裸机 `mcpp test` 设计建立在两个**我没测就写下的数字/断言**上。两个都是错的。 + +| 计划写的 | 实测 | 后果 | +|---|---|---| +| 「qemu 冷启动 ~0.4s ⇒ 30 用例 isolated 要 12s」⇒ **默认 batch** | **12ms**(5 次连跑 63ms) ⇒ 30 用例 **0.36s** | ⛔ **batch 的全部理由消失**;连带「超时后重跑一次 isolated 定位」也不需要 —— isolated 本来就指名道姓 | +| 「裸机没有退出码回宿主 ⇒ 结果要走结构化 stdout 通道」 | **semihosting 把固件 `main` 的返回值原样传给 qemu 退出码**(`return 7` → 退出码 7) | ⛔ **不需要新通道**;「退出码即判据」在裸机上原样成立 | + +⇒ **R3 从一个子系统缩成一件事**:让测试二进制**走 `mcpp run` 用的同一个 runner**。 +实测结果: + +``` +ok_one ... ok (0.02s) +ok_two ... ok (0.02s) +deliberate_fail ... FAIL (exit 1, 0.02s) +error: test result: FAILED. 2 passed; 1 failed +``` + +三条判据(全过 · 失败被点名 · 退出码非零)一次全中,而 §4.2 表里的六个设计决定 +**有四个根本不需要做**。 + +⚠️ **教训与本轮其它几次同形**:计划里凡是带具体数字或「A 不成立所以要 B」的句子, +数字与断言本身就是**必须先测的探针**。这两条的代价是我差点实现一整个 batch 子系统。 + +### 11.1 R1/R4 的实施结论 + +- **R1 按设计落地**(新 `Slot::Runner` + 新 `Scope::RunGlobal` + 一行表项),三侧已验: + BSP 供 ⇒ 消费者无 `[target.*]` 也能跑 · 两个依赖都供 ⇒ **硬错误并点名两个** · + 消费者写了 ⇒ 以它为准并说明覆盖了谁。 +- **R4 按设计落地**,包括计划里点名的 R-5:`.bin` 是以 `.elf` 为输入的**独立 ninja 边**, + 改一行源码后**内容**变(不是只有 mtime 变)。 +- ⚠️ **一次为了「一致性」而制造的回归,被 CI 抓回来。** `mcpp run` 用 + `--target-triple` 而其余子命令用 `--target`,看起来像我造成的不一致,于是我给 + `run` 也加了 `--target`。**parser 把选项和位置参数按同一个词索引**,于是: + + ``` + $ mcpp run q + error: unknown target 'q' ← q 是二进制名,被当成了目标三元组 + ``` + + `e2e/73` 立刻红。而位置参数上**原本就有一条注释写着这个碰撞** —— 我为了对称把它 + 覆盖了。 + + 但**第一版修法(`run` 退回只认 `--target-triple`)也不对** —— 它把「不能坏」买回来了, + 代价是留下一条真实的不一致。**真因不是这个词有两个轴,而是位置参数的名字选错了**: + `ParsedArgs::value()` 在选项未设置时会**按同名回落到位置参数**,而这个位置参数的名字 + `cmd_run` 从来不读(它按下标取 `positional(0)`),只出现在 `--help` 里。 + ⇒ **把位置参数改名 `bin`**(在 `--help` 里本来就更准),`--target` 就自由了。 + `--target-triple` 作为 2026.8.19.1 已发布的拼写保留为别名。 + **一致性是判据之一,它排在「不能坏」后面 —— 但排在后面不等于要放弃。** + +### 11.2 R2/R5 的实施结论:两条计划主张又被推翻 + +#### ⭐ R5:`mcpp new --template baremetal-riscv` 不该由 mcpp 提供 + +§6 写的是给 mcpp 加一个内建模板。**读代码发现这条路是被刻意关掉的**: + +```cpp +// src/cli/cmd_new.cppm +// builtin registry (frozen: bin; gui = transitional alias), else a +// package template: [ns.]pkg | [ns.]pkg:tmpl | [ns.]pkg@ver:tmpl. +``` + +⇒ **模板随提供它的包走**(封闭语法 / 开放词汇),而且 `mcpp new` 会把自依赖按 +**它解析到的版本**写进生成的 manifest —— 模板因此**不可能与库脱节**。 + +落地形态因此变成:`riscv-virt-rt` 里加 `templates/blinky/`,用户敲 + +```bash +mcpp new blinky --template riscv-virt-rt +``` + +**mcpp 侧零改动**。这比原计划好在:模板与板级包同一版本、同一仓库、同一次发布。 + +#### ⚠️ R2:包的 `[xlings] deps` **不会**到达消费者 —— 少了这条边包是废的 + +`mcpp` 只为**根工程**(或 workspace)物化 `[xlings]`(`prepare.cppm` 的 +`runtimeOwnerManifest`)。BSP 自己声明的 `xim:picolibc-riscv` / `xim:qemu-riscv` +在被当依赖用时**一个都不装** —— 用户 `mcpp add` 之后拿到的是一个没有 libc 也没有 +模拟器的板级包,直到 `build.mcpp` 才报出来。 + +解法**不在 mcpp 里**:xim 的描述符本来就有安装期依赖边,写在 `xpm.<平台>.deps` +(`libpng.lua` 早就这么用)。⇒ 描述符里加 + +```lua +deps = { "xim:picolibc-riscv@1.8.12", "xim:qemu-riscv@9.2.4-1" }, +``` + +**实测判据(必须是「拿走再装回来」,不能只看装好的机器)**:把 store 里的 +`xim-x-picolibc-riscv` 改名藏起来 → `mcpp add` + `mcpp build` **把它装了回来并链接成功**; +不加这条边则停在 `build.mcpp` 说「declared in [xlings].deps but is not installed」。 + +#### ⚠️ Form A vs Form B:选错的话包会「解析成功、编译成功、链接到空气」 + +`riscv-virt-rt` 带 `build.mcpp`,而 mcpp 在**包根**找它。Form B(内联 `mcpp = {...}`) +把包根留在解包目录,tarball 的 `riscv-virt-rt-/` 包裹层由每条 glob 的 `*/` 吸收 +⇒ **`build.mcpp` 落在下一层,找不到**。Form A(`mcpp = "*/mcpp.toml"`)把包根移进包裹层。 +⇒ **凡是带 `build.mcpp` 的包必须用 Form A。** + +#### 兼容性:类型化 API 没有语言内的特性探测(见 §3.2 的补记) + +包侧无解 ⇒ 补在引擎侧的编译失败诊断上,对**以后每一次**类型化 API 新增都生效。 + +### 11.3 ⚠️ 我在第二阶段留下了一条**指向不存在的包**的诊断 + +W6 的 `import std` 诊断里写着可以直接粘贴的: + +```toml +[dependencies] +mcpplibs.std.freestanding = "0.1" +``` + +**这个包没有发布,而且本阶段也不该顺手发**(E-STD-1/E-STD-2 各自是 M 规模: +`-nostdinc++` 之下 libc++ 的头一个都用不了,子集要自己实现;而且往 `namespace std` +里加声明本身就是另一个问题)。 + +⇒ 用户照着诊断粘完,下一条命令报「package not found」。**一条修不好问题的诊断, +比一条只解释不给命令的诊断更糟** —— 它让读者接下来几分钟在怀疑自己的索引坏了。 + +已改成指向**今天确实存在**的东西(板级包导出的模块,点名 `mcpplibs.riscv_virt_rt`), +并把子集包描述成一个**形态**而不是一行可粘贴的依赖,末尾明说「还没有这样的包」。 +**E-STD 仍然开着;它发布时要把这条诊断改回具体的一行。** + +⚠️ 一般化:**诊断里的每一条建议都是一个承诺**,而承诺是要被兑现的。写「加这一行」 +之前必须先确认那一行今天能跑通 —— 这条和 [[issue427-absence-treated-as-contradiction]] +里那条错误建议是同一形状。 diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index f60d966d..fb5ede2a 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -173,7 +173,8 @@ jobs: # Invoked directly, a skip is visible: the script either prints its # PASS line or it does not. for t in tests/e2e/130_freestanding_riscv_build_and_run.sh \ - tests/e2e/131_freestanding_bsp_supplies_everything.sh; do + tests/e2e/131_freestanding_bsp_supplies_everything.sh \ + tests/e2e/132_freestanding_test_and_artifacts.sh; do echo "=== $t ===" bash "$t" 2>&1 | tee "$(basename "$t").log" rc=${PIPESTATUS[0]} @@ -188,6 +189,9 @@ jobs: grep -q 'PASS: BSP supplies the sysroot' \ 131_freestanding_bsp_supplies_everything.sh.log || { echo "131 (ecosystem chain) skipped on the runner that must run it"; exit 1; } + grep -q 'PASS: bare-metal mcpp test names its failure' \ + 132_freestanding_test_and_artifacts.sh.log || { + echo "132 (test + artifacts) skipped on the runner that must run it"; exit 1; } # ────────────────────────────────────────────────────────────────── # Hermetic (no host toolchain): the ONLY environment class that diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 03571d1c..442508a9 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -906,17 +906,33 @@ can produce them. ```bash mcpp build --target riscv64-none-elf -mcpp run --target-triple riscv64-none-elf # via [target.].runner +mcpp run --target riscv64-none-elf # via [target.].runner ``` +**Starting from a board package** + +Almost nothing below has to be written by hand. A board-support package carries +the C library, the startup code, the memory layout and the emulator, so the +shortest path to a booting image is: + +```bash +mcpp new blinky --template riscv-virt-rt +cd blinky && mcpp run +``` + +The generated manifest names no linker script, load address, libc or emulator — +it has no `[target.*]` section at all. The rest of this section describes what +such a package supplies, which is what to reach for when writing one for a board +that has none. + **What changes on a freestanding target** | | | |---|---| | Link line | `-nostdlib -nostartfiles -static`, and nothing hosted — no crt files, no dynamic linker, no C++ runtime. The linker is addressed by **absolute path** (`-fuse-ld=/bin/ld.lld`), because `-fuse-ld=lld` resolves through `PATH` and finds GNU ld on any machine with binutils earlier on it. | | ISA flags | `-march` / `-mabi` / `-mcmodel` come from the target table, so `--target ` alone is enough to produce a correct object file. | -| `import std` | **Unavailable.** `std` is one module over the entire library — threads, filesystem and iostreams included — so there is no subset of it to build without an OS. The freestanding subset package replaces it, and mcpp's diagnostic names it. | -| Entry point | There is no `main`. Declare the target explicitly and point `main` at the file carrying `_start`. | +| `import std` | **Unavailable.** `std` is one module over the entire library — threads, filesystem and iostreams included — so there is no subset of it to build without an OS. What a firmware imports instead is the module its **board package** exports, which is where the target's C library is already wrapped. | +| Entry point | `int main()` works **as long as something supplies a `crt0`** — a board package normally does, and then a firmware's entry point is an ordinary `main` whose return value reaches the host through semihosting. Only a zero-libc board needs an explicit target whose `main` points at the file carrying `_start`. | **A minimal firmware** diff --git a/docs/07-build-mcpp.md b/docs/07-build-mcpp.md index 9b5b7994..e29e841f 100644 --- a/docs/07-build-mcpp.md +++ b/docs/07-build-mcpp.md @@ -52,6 +52,7 @@ is ignored, so diagnostics may be logged freely. | `mcpp:source=` *(0.0.100+)* | select a **pre-existing** source file into the build (absolute, or relative to the package root). Same downstream effect as `generated=`; use it for files the program *chose* (payload/vendored tree) rather than wrote — e.g. a per-target source selection over a large tarball | | `mcpp:include-dir=` *(0.0.100+)* | add a **private** include directory (`-I`) for this package's own TUs (absolute, or relative to the package root; normalized). Replaces the `cxxflag=-I` + `cflag=-I` double emission | | `mcpp:include-dir-after=` *(0.0.100+)* | like `include-dir`, but searched **after** the system directories (`-idirafter`) — for payload trees that shadow system headers | +| `mcpp:runner=` *(2026.8.19.2+)* | one argv token of the command that EXECUTES this build's artifact, when the host cannot. Emitted once per token, in order; the artifact path is appended (or substituted for `{}`). Reaches the **consumer**. ⚠️ Emit the executable as an ABSOLUTE path, and only **one** dependency may supply it | | `mcpp:link-script=` *(2026.8.19+)* | link with this **linker script** (`-T`; relative resolves against the package root, and the emitted path is absolute because the link runs in the build directory). Reaches the **consumer**, unlike `include-dir` — a board's memory layout is the one thing a consumer cannot write for itself | | `mcpp:rerun-if-changed=` | re-run `build.mcpp` when this file changes | | `mcpp:rerun-if-env-changed=` | re-run `build.mcpp` when this env var changes | @@ -101,9 +102,37 @@ int main() { | `mcpp::rerun_if_changed_glob(pat)` *(2026.8.6.2+)* | `mcpp:rerun-if-changed-glob=` — re-run when the **set** of files matching `pat` changes (see below) | | `mcpp::dep_bin(pkg, tool)` *(2026.8.5.1+)* | reads `MCPP_DEP__BIN_` — the absolute path of a **host tool** built by a dependency (see below) | | `mcpp::link_script(p)` *(2026.8.19+)* | `mcpp:link-script=` | +| `mcpp::runner(tok)` *(2026.8.19.2+)* | `mcpp:runner=` — see below | | `mcpp::xpkg_dir(ns, name)` / `mcpp::xpkg_dir(name)` *(2026.8.19+)* | the payload directory of a package this manifest declared in `[xlings] deps`; `""` when it was not declared or is not installed (see below) | | `mcpp::action{…}.submit()` *(2026.8.5.1+)* | `mcpp:action=` — declares a **build-graph node** instead of doing the work here (see below) | +### `runner` — how the artifact is executed (2026.8.19.2+) + +A board-support package knows the emulator, its machine model and its firmware +mode. It also knows where the emulator IS, which a static manifest cannot: the +payload path carries a home and a version. + +```cpp +const char* qemu = mcpp::xpkg_dir("xim", "qemu-riscv"); +mcpp::runner(std::format("{}/bin/qemu-system-riscv64", qemu).c_str()); +for (auto a : {"-machine","virt","-nographic","-no-reboot","-kernel"}) + mcpp::runner(a); +``` + +The consumer then needs no `[target.]` section at all. If it writes one +anyway, **it wins** — swapping `-bios default` for `-bios none -semihosting` +while debugging is a legitimate thing to want — and mcpp says which dependency +it overrode. + +⚠️ **Emit the executable as an absolute path.** A bare name resolves through +`PATH` to a shim that dispatches against its own owner home, which is not +necessarily the home this build uses. + +⚠️ **Exactly one dependency may supply a runner.** Two board-support packages +both claiming to know how to run the artifact is a configuration error, and +mcpp reports it naming both rather than merging them into an argv that is +neither one's. + ### Finding an `[xlings] deps` payload: `xpkg_dir` (2026.8.19+) `dep_dir` answers for **mcpp** dependencies. An xlings package is a different @@ -297,9 +326,12 @@ write it yourself). mcpp uses that two ways: with an upgrade hint. Continuing would silently drop directives the build depends on — and "the build succeeded but the flag never arrived" is the worst class of build bug. -- Because the two sides then provably agree, an **unrecognized directive is an - error** rather than a warning: within one protocol version it can only be a - typo. +- An **unrecognized directive is an error** rather than a warning, and the error + names *both* possible causes. It cannot name one: the protocol number is + stamped by whichever mcpp **compiled** the program, not carried by the + package, so a package written for a newer mcpp arrives at an older one + wearing the older engine's number. Two matching numbers therefore say nothing + about whether the key came from the future. A `printf`-style program announces nothing, so it keeps the historical warn-and-ignore behaviour. That surface is **frozen at the eleven directives in @@ -307,6 +339,31 @@ the table above** — it still works and will keep working, but new capabilities land only in the typed API. Prefer `import mcpp;` for anything intended to maintain. +#### A package that needs a newer mcpp + +When a published package calls a typed function this mcpp does not have, the +compile error naming it is followed by: + +``` + The `mcpp` build module this engine bundles does not have that name. + Either the package was written for a newer mcpp (try `mcpp self update`; + this is mcpp 2026.8.19.2), or the name is misspelled … +``` + +The package cannot handle this itself, and it is worth knowing why — the +obvious guard does not compile: + +```cpp +if constexpr (requires { mcpp::runner("qemu"); }) // ✗ hard error when absent + mcpp::runner("qemu"); +``` + +A `requires`-expression over a **qualified name that does not exist** is +ill-formed, not `false`. So there is no in-language feature probe, and a +package that adopts a new directive states its floor in prose (its README) and +relies on the diagnostic above. Such a package should name the mcpp version it +requires. + ### `import std;` (mcpp 2026.8.2.1+) A `build.mcpp` may `import std;` (and `import std.compat;`), alone or together diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 4e36a7cd..1109dcf2 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -804,17 +804,31 @@ cxxflags = ["-march=x86-64-v2"] ```bash mcpp build --target riscv64-none-elf -mcpp run --target-triple riscv64-none-elf # 经 [target.].runner +mcpp run --target riscv64-none-elf # 经 [target.].runner ``` +**从板级支持包起步** + +下面这些几乎都不需要手写。板级支持包(BSP)自带 C 库、启动代码、内存布局和模拟器, +所以跑起一个镜像的最短路径是: + +```bash +mcpp new blinky --template riscv-virt-rt +cd blinky && mcpp run +``` + +生成的 manifest 里没有链接脚本、没有加载地址、没有 libc、没有模拟器 —— 连 +`[target.*]` 段都没有。本节余下的内容讲的是**这样一个包提供了什么**,也就是要给 +一块还没有 BSP 的板子写一个时该照着做什么。 + **freestanding target 上有什么不同** | | | |---|---| | 链接线 | `-nostdlib -nostartfiles -static`,且不带任何 hosted 的东西 —— 没有 crt 文件、没有动态链接器、没有 C++ 运行时。链接器用**绝对路径**寻址(`-fuse-ld=<载荷>/bin/ld.lld`),因为 `-fuse-ld=lld` 走 `PATH` 解析,在任何 binutils 排前面的机器上都会找到 GNU ld。 | | ISA flag | `-march` / `-mabi` / `-mcmodel` 来自 target 表,所以只写 `--target ` 就足以产出正确的目标文件。 | -| `import std` | **不可用。** `std` 是覆盖整个库的一个模块 —— 线程、文件系统、iostreams 全在内 —— 没有 OS 就没有它的子集可编。freestanding 子集包取代它,mcpp 的诊断会点名。 | -| 入口点 | 没有 `main`。显式声明 target,并把 `main` 指向携带 `_start` 的那个文件。 | +| `import std` | **不可用。** `std` 是覆盖整个库的一个模块 —— 线程、文件系统、iostreams 全在内 —— 没有 OS 就没有它的子集可编。固件真正 import 的是**板级包导出的模块**,目标的 C 库已经在那里包好了。 | +| 入口点 | **只要有人提供 `crt0`,`int main()` 就能用** —— 板级支持包通常就提供它,于是固件的入口就是普通的 `main`,它的返回值经 semihosting 传回宿主。**只有零 libc 的板子**才需要显式声明 target 并把 `main` 指向携带 `_start` 的那个文件。 | **一个最小固件** diff --git a/docs/zh/07-build-mcpp.md b/docs/zh/07-build-mcpp.md index 5775bb0c..8aa8c5bf 100644 --- a/docs/zh/07-build-mcpp.md +++ b/docs/zh/07-build-mcpp.md @@ -49,6 +49,7 @@ mcpp build # 编译 + 运行 build.mcpp,然后构建工程 | `mcpp:source=` *(0.0.100+)* | 把一份**既有**源文件选入构建(绝对路径,或相对包根)。下游效果与 `generated=` 相同;语义区别在于文件是程序*选中*的(tarball payload / vendored 源树)而非程序写出的——例如对大型源码包做 per-target 源选择 | | `mcpp:include-dir=` *(0.0.100+)* | 为本包自身 TU 增加一个**私有** include 目录(`-I`;绝对路径或相对包根,自动规范化)。取代过去 `cxxflag=-I` + `cflag=-I` 的双重裸发 | | `mcpp:include-dir-after=` *(0.0.100+)* | 同 `include-dir`,但排在系统目录**之后**搜索(`-idirafter`)——用于会遮蔽系统头的 payload 源树 | +| `mcpp:runner=` *(2026.8.19.2+)* | 执行本次构建产物的命令的**一个 argv token**(宿主跑不了它时)。一个 token 一次调用、按顺序;产物路径会被追加(或替换 `{}`)。**到达消费者**。⚠️ 可执行文件要发**绝对路径**,且**只能有一个**依赖提供它 | | `mcpp:link-script=` *(2026.8.19+)* | 用这个**链接脚本**链接(`-T`;相对路径按包根解析,发出的是绝对路径,因为链接是在构建目录里跑的)。与 `include-dir` 不同,它**到达消费者** —— 板子的内存布局恰恰是消费者写不出来的那一项 | | `mcpp:rerun-if-changed=` | 该文件变化时重跑 `build.mcpp` | | `mcpp:rerun-if-env-changed=` | 该环境变量变化时重跑 `build.mcpp` | @@ -94,9 +95,32 @@ int main() { | `mcpp::rerun_if_changed_glob(pat)` *(2026.8.6.2+)* | `mcpp:rerun-if-changed-glob=` —— 匹配 `pat` 的文件**集合**发生变化时重跑(见下) | | `mcpp::dep_bin(pkg, tool)` *(2026.8.5.1+)* | 读 `MCPP_DEP__BIN_` —— 依赖构建出的 **host 工具**的绝对路径(见下) | | `mcpp::link_script(p)` *(2026.8.19+)* | `mcpp:link-script=` | +| `mcpp::runner(tok)` *(2026.8.19.2+)* | `mcpp:runner=` —— 见下 | | `mcpp::xpkg_dir(ns, name)` / `mcpp::xpkg_dir(name)` *(2026.8.19+)* | 本 manifest 在 `[xlings] deps` 里声明的包的载荷目录;没声明或没安装时返回 `""`(见下) | | `mcpp::action{…}.submit()` *(2026.8.5.1+)* | `mcpp:action=` —— **声明一个构建图节点**,而不是在这里把活干了(见下) | +### `runner` —— 产物的执行方式(2026.8.19.2+) + +板级支持包知道模拟器、机器型号和固件模式,也知道模拟器**在哪** —— 而静态 manifest +写不出来:载荷路径里带着 home 和版本号。 + +```cpp +const char* qemu = mcpp::xpkg_dir("xim", "qemu-riscv"); +mcpp::runner(std::format("{}/bin/qemu-system-riscv64", qemu).c_str()); +for (auto a : {"-machine","virt","-nographic","-no-reboot","-kernel"}) + mcpp::runner(a); +``` + +这样消费者**完全不需要 `[target.]` 段**。它若还是写了,**以它为准** —— +调试时把 `-bios default` 换成 `-bios none -semihosting` 是正当需求 —— 且 mcpp 会说明 +它覆盖了哪个依赖。 + +⚠️ **可执行文件要发绝对路径。** 裸名会经 `PATH` 解析到一个 shim,而 shim 按**拥有它 +的 home** 派发,那未必是本次构建用的 home。 + +⚠️ **只能有一个依赖提供 runner。** 两个板级支持包都声称知道怎么跑这个产物是配置 +错误;mcpp 会**同时点名两个**并报错,而不是把它们并成一个谁也不是的 argv。 + ### 找到 `[xlings] deps` 的载荷:`xpkg_dir`(2026.8.19+) `dep_dir` 回答的是 **mcpp** 依赖。xlings 包是另一个命名空间、另一套 store 布局, @@ -266,13 +290,36 @@ mcpp 会播下一个带着该声明的占位文件,使 prepare 期的扫描与 - 程序声明的协议**高于** mcpp 所理解的 → **拒绝执行**,并给出升级提示。继续跑会 静默丢掉构建依赖的指令,而「构建成功了但那个 flag 根本没到」是最难查的一类问题。 -- 既然双方已被证明一致,**未知指令就是错误**而不是警告:在同一个协议版本内, - 它只可能是拼写错误。 +- **未知指令是错误**而不是警告,而且这条错误会把**两种可能的原因都说出来**。 + 它没法只说一种:协议号是由**编译**该程序的那个 mcpp 现场打上的,并不由包本身携带 + —— 于是一个写给新 mcpp 的包到了老 mcpp 手里,身上戴的是老引擎的号。 + **两个号一致因此完全不能说明这个键是不是来自未来。** `printf` 风格的程序什么都不声明,因此保留历史上的「警告并忽略」行为。这一面 **冻结在上表的 11 条指令**上——它仍然能用、也会继续能用,但新能力只在类型化 API 里 落地。**要长期维护的程序请用 `import mcpp;`。** +#### 一个需要更新 mcpp 的包 + +当已发布的包调用了当前 mcpp 没有的类型化函数时,点名的编译错误之后会跟着: + +``` + The `mcpp` build module this engine bundles does not have that name. + Either the package was written for a newer mcpp (try `mcpp self update`; + this is mcpp 2026.8.19.2), or the name is misspelled … +``` + +**包自己处理不了这件事**,而原因值得知道 —— 最直觉的那道防护编译不过: + +```cpp +if constexpr (requires { mcpp::runner("qemu"); }) // ✗ 名字不存在时是硬错误 + mcpp::runner("qemu"); +``` + +`requires` 表达式作用在一个**不存在的限定名**上时是 ill-formed,而**不是求值为 +`false`**。所以语言内没有特性探测这条路:采用了新指令的包只能在自己的 README 里 +用文字写明版本下限,并依赖上面那条诊断。**这类包应当写清楚它需要哪个版本的 mcpp。** + ### `import std;`(mcpp 2026.8.2.1+) `build.mcpp` 可以 `import std;`(以及 `import std.compat;`),单用或与 diff --git a/mcpp.toml b/mcpp.toml index f9095618..3cde1f91 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.19.1" +version = "2026.8.19.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index a3cd01f7..ac61bbdc 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -28,6 +28,7 @@ import mcpp.toolchain.registry; // archive_tool import mcpp.toolchain.stdmod; // ensure_built — the SAME std BMI the main build uses import mcpp.toolchain.triple; // host_triple (MCPP_HOST contract value) import mcpp.ui; +import mcpp.version; // MCPP_VERSION — the hint names the engine the reader is on export namespace mcpp::build { @@ -94,6 +95,30 @@ inline std::string xpkg_env_var(std::string_view ns, std::string_view name) { return out; } +// Does a compiler's output say the program asked for something the bundled +// `mcpp` module does not have? +// +// ⚠️ This is the ONLY place an engine-too-old situation can be caught for the +// TYPED api, and it exists because the in-language probe does not: +// +// if constexpr (requires { mcpp::runner("x"); }) // ← hard error, +// mcpp::runner("x"); // measured +// +// A requires-expression over a qualified name that does not exist is +// ill-formed, not `false`, so a package CANNOT degrade gracefully across mcpp +// versions the way it could across, say, a header's feature macro. The wire +// protocol has its own answer for this (protocol_error() names `mcpp self +// update` for an unknown `mcpp:` key), but that answer needs the program to +// have COMPILED — and a package written against a newer mcpp does not get +// that far. So the raw compiler error is the message, and on its own it says +// only `'runner' is not a member of 'mcpp'`, which reads like the author's +// typo instead of the reader's out-of-date engine. +// +// Deliberately spelling-based, and deliberately broad across the three +// frontends (they phrase it three ways). A false positive costs one extra +// hint line under a genuine typo; a false negative costs a user an afternoon. +bool mentions_missing_mcpp_api(std::string_view compilerOutput); + // Compile + run `/build.mcpp` (if present) with `hostCompiler` (the resolved // HOST frontend — under a cross --target the caller resolves a host toolchain; // the program always compiles AND runs on the host) and apply its directives to @@ -128,6 +153,28 @@ bool glob_inputs_stale(const std::filesystem::path& projectRoot); namespace mcpp::build { +// See the declaration for why this exists at all. +// +// Three frontends, three spellings of the same fact — and MSVC's does not even +// contain the word "member" in the same order, so each is matched literally +// rather than by a shared substring: +// +// gcc error: 'runner' is not a member of 'mcpp' +// clang error: no member named 'runner' in namespace 'mcpp' +// cl.exe error C2039: 'runner': is not a member of 'mcpp' +// +// The trailing `'mcpp'` is what keeps this off unrelated failures: a package's +// own missing symbol names its own namespace, not ours. +bool mentions_missing_mcpp_api(std::string_view out) { + static constexpr std::string_view kNeedles[] = { + "is not a member of 'mcpp'", // gcc, and cl.exe's tail + "in namespace 'mcpp'", // clang + }; + for (auto n : kNeedles) + if (out.find(n) != std::string_view::npos) return true; + return false; +} + namespace { namespace fs = std::filesystem; @@ -778,8 +825,21 @@ std::expected run_build_program( auto cres = mcpp::platform::process::capture_exec(compileArgv, compileEnv, compileCwd); if (cres.exit_code != 0) { - return std::unexpected(std::format( - "build.mcpp failed to compile (exit {}):\n{}", cres.exit_code, cres.output)); + std::string msg = std::format("build.mcpp failed to compile (exit {}):\n{}", + cres.exit_code, cres.output); + if (mentions_missing_mcpp_api(cres.output)) { + msg += std::format( + "\n The `mcpp` build module this engine bundles does not have " + "that name.\n" + " Either the package was written for a newer mcpp (try " + "`mcpp self update`;\n" + " this is mcpp {}), or the name is misspelled — the compiler " + "cannot tell\n" + " the two apart, because the module is generated by whichever " + "mcpp is running.", + mcpp::MCPP_VERSION); + } + return std::unexpected(std::move(msg)); } // ── Run it; capture stdout(+stderr) and parse directives ──────────────── diff --git a/src/build/directives.cppm b/src/build/directives.cppm index f52d7e31..91379bca 100644 --- a/src/build/directives.cppm +++ b/src/build/directives.cppm @@ -72,7 +72,11 @@ using mcpp::build::program_protocol::run_timeout_for; // Where a directive's value accumulates. One slot may be fed by several wire // names (link-lib and link-search both produce link flags). enum class Slot : std::size_t { - CxxFlags = 0, + // How to EXECUTE the artifact. Its own slot, not a corner of LdFlags: it + // is neither a compile input nor a link input, and putting it in LdFlags + // would put an emulator's argv on the linker command line. + Runner, + CxxFlags, CFlags, LdFlags, Defines, @@ -101,6 +105,12 @@ inline constexpr std::size_t kSlotCount = static_cast(Slot::Count); enum class Scope { PackagePrivate, // only this package's own TUs — never propagated to consumers LinkGlobal, // reaches the final link of whatever consumes this package + // Reaches how the consumer RUNS the artifact. Parallel to LinkGlobal in + // propagation and deliberately NOT the same value: the two have different + // conflict rules. Link flags from two dependencies concatenate and that is + // correct; two runners cannot, so this scope carries an exactly-one- + // provider check that LinkGlobal must not inherit. + RunGlobal, SourceSet, // joins the compile set RerunKey, // not a build input at all; only feeds the re-run key GraphNode, // declares an edge in the build graph; see manifest::BuildAction @@ -139,7 +149,7 @@ struct Def { int sinceProtocol; }; -inline constexpr std::array kTable{{ +inline constexpr std::array kTable{{ // wire tag slot scope transform must missingPrefix missingSuffix since {"cxxflag", "cxxflag", Slot::CxxFlags, Scope::PackagePrivate, Transform::Verbatim, false, "", "", 1}, {"cflag", "cflag", Slot::CFlags, Scope::PackagePrivate, Transform::Verbatim, false, "", "", 1}, @@ -172,6 +182,19 @@ inline constexpr std::array kTable{{ // the contract for one row would cost more than it buys: lld's own error // is already exact ("cannot find linker script "), which is the // condition the contract exists to make legible. + // ⚠️ One argv TOKEN per line, in emission order. + // + // argv is an ordered list and a directive is one line = one value, so the + // list is built by repetition. The alternative — a JSON array, as `action` + // uses — would introduce an escaping contract for a payload that never + // nests, and `action` pays that cost only because it has six fields. + // + // Verbatim: a runner token is not a path to normalize (it may be `-bios`), + // and the producer already resolved the emulator absolutely, because a + // bare name resolves through PATH to a shim that dispatches against its + // OWNER home — measured in CI as `xlings: '…' is not installed` from a job + // where the same name had answered `--version` two steps earlier. + {"runner", "runner", Slot::Runner, Scope::RunGlobal, Transform::Verbatim, false, "", "", 4}, {"link-script", "ldflag", Slot::LdFlags, Scope::LinkGlobal, Transform::LinkerScript, false, "", "", 3}, {"include-dir", "include-dir", Slot::IncludeDirs, Scope::PackagePrivate, Transform::AbsPath, false, "", "", 1}, {"include-dir-after", "include-dir-after", Slot::IncludeDirsAfter, Scope::PackagePrivate, Transform::AbsPath, false, "", "", 1}, @@ -559,11 +582,14 @@ void apply(mcpp::manifest::Manifest& m, const Directives& d) { auto const& cxx = d.at(Slot::CxxFlags); auto const& c = d.at(Slot::CFlags); auto const& ld = d.at(Slot::LdFlags); + auto const& runner = d.at(Slot::Runner); auto const& defines = d.at(Slot::Defines); bc.cxxflags.insert(bc.cxxflags.end(), cxx.begin(), cxx.end()); bc.cflags.insert(bc.cflags.end(), c.begin(), c.end()); bc.ldflags.insert(bc.ldflags.end(), ld.begin(), ld.end()); + // Appended in emission order — the tokens ARE the argv. + bc.runner.insert(bc.runner.end(), runner.begin(), runner.end()); // cfg defines colour BOTH language channels — the one slot that fans out. bc.cflags.insert(bc.cflags.end(), defines.begin(), defines.end()); bc.cxxflags.insert(bc.cxxflags.end(), defines.begin(), defines.end()); diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 3779d700..39b1dcfe 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -16,6 +16,7 @@ import mcpp.diag; import mcpp.build.plan; import mcpp.toolchain.triple; import mcpp.freestanding.runner; +import mcpp.freestanding.linkline; import mcpp.build.graph_shape; // #407: which mode wrote this build.ninja import mcpp.build.backend; import mcpp.build.ninja; @@ -385,6 +386,69 @@ compute_subos_env(const mcpp::build::BuildPlan& plan) { // instead of re-deriving it. `scheduleNinjaJobs` is NOT the compiler cap under // detach-codegen: a detached compiler stops holding a ninja slot, so ninja is // handed a larger number on purpose. +// THE read point for "how is this artifact executed". +// +// One function, two callers (`mcpp run` and `mcpp test`). Deriving it twice is +// the shape this codebase has paid for repeatedly (#233/#240/#242/#344): it +// does not fail when you add the second derivation, it fails later, when one +// of them gains a rule the other does not. +// +// Returns an empty argv for a hosted target — the caller runs the artifact +// directly, as it always did. +struct RunnerChoice { + std::vector tmpl; // empty = execute the artifact directly + bool freestanding = false; // a runner is REQUIRED when true + bool fromManifest = false; // the consumer overrode a dependency's +}; + +RunnerChoice choose_runner(const BuildContext& ctx) { + RunnerChoice c; + auto ft = mcpp::toolchain::triple::parse(ctx.tc.targetTriple); + if (!ft || !ft->is_freestanding()) return c; + c.freestanding = true; + // Two producers, ordinary precedence: what the author of THIS project + // wrote beats what a dependency supplied. The dependency is the normal + // case (a board-support package computes the emulator's absolute path); + // the manifest key exists for swapping `-bios default` for + // `-bios none -semihosting` while debugging. + c.tmpl = ctx.manifest.buildConfig.runner; + if (auto it = ctx.manifest.targetOverrides.find(ctx.tc.targetTriple); + it != ctx.manifest.targetOverrides.end() && !it->second.runner.empty()) { + c.tmpl = it->second.runner; + c.fromManifest = !ctx.manifest.buildConfig.runner.empty(); + } + return c; +} + +// The capacity number, printed because capacity is the constraint. +// +// After `Finished`, not instead of it: the build succeeded either way, and a +// size line that replaced the outcome would be a different kind of message. +// Silent on every hosted target and whenever the tool is absent — an +// informational line has no standing to fail a build. +void report_freestanding_size(const BuildContext& ctx) { + auto ft = mcpp::toolchain::triple::parse(ctx.tc.targetTriple); + if (!ft || !ft->is_freestanding()) return; + auto tool = mcpp::freestanding::resolve_size_tool(ctx.tc.binaryPath); + if (tool.empty()) return; + for (auto const& lu : ctx.plan.linkUnits) { + if (lu.kind != mcpp::build::LinkUnit::Binary) continue; + auto art = ctx.outputDir / lu.output; + std::error_code ec; + if (!std::filesystem::exists(art, ec)) continue; + auto out = mcpp::xlings::run_capture(std::format( + "{} {} 2>/dev/null", mcpp::xlings::shq(tool.string()), + mcpp::xlings::shq(art.string()))); + if (!out) continue; + auto s = mcpp::freestanding::parse_size_output(*out); + if (!s) continue; + mcpp::ui::info("Size", std::format( + "{} text {} data {} bss {} total {}", + lu.targetName, s->text, s->data, s->bss, + mcpp::freestanding::size_total(*s))); + } +} + export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, std::string_view targetOverride = "") { // `--cache=off` means a cold build: no global cache, and target/ cleared — @@ -563,6 +627,7 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, if (bc.lto) descriptor += " + lto"; mcpp::ui::finished(ctx.profile, r->elapsed, descriptor); } + report_freestanding_size(ctx); return 0; } @@ -1049,10 +1114,22 @@ export int build_run_target(const std::optional& targetName, if (auto ft = mcpp::toolchain::triple::parse(ctx->tc.targetTriple)) freestandingRun = ft->is_freestanding(); if (freestandingRun) { - std::vector tmpl; - if (auto it = ctx->manifest.targetOverrides.find(ctx->tc.targetTriple); - it != ctx->manifest.targetOverrides.end()) - tmpl = it->second.runner; + // Two producers, and the precedence is the ordinary one: what the + // author of THIS project wrote beats what a dependency supplied. + // + // The dependency is the normal case — a board-support package knows + // the emulator, its machine model and its firmware mode, and computes + // the absolute path that a static manifest cannot. The explicit key + // exists for the other case: swapping `-bios default` for + // `-bios none -semihosting` while debugging is a legitimate thing to + // want, and removing that ability to make the BSP authoritative would + // trade one problem for a worse one. + const auto choice = choose_runner(*ctx); + auto tmpl = choice.tmpl; + if (choice.fromManifest) + mcpp::ui::info("note", std::format( + "[target.{}].runner overrides the runner a dependency supplied", + ctx->tc.targetTriple)); if (tmpl.empty()) { std::println(stderr, "error: {}", mcpp::freestanding::no_runner_message(ctx->tc.targetTriple)); @@ -1612,8 +1689,30 @@ export int run_tests(std::span passthrough, auto exe = ctx->outputDir / lu.output; + // A freestanding test image cannot run here either, and the answer is + // the SAME runner `mcpp run` uses — one read point, two callers. + // + // Nothing else about the test model changes, and that is a measured + // result rather than a simplification: semihosting propagates the + // firmware's `main` return value to the emulator's exit code + // (`return 7` → qemu exits 7, verified), so "exit code is the verdict" + // holds on bare metal exactly as it does on the host. An earlier plan + // called for a structured stdout protocol because it assumed there was + // no exit code to read; there is. std::vector argv; - argv.push_back(exe.string()); + { + const auto choice = choose_runner(*ctx); + if (choice.freestanding) { + if (choice.tmpl.empty()) { + std::println(stderr, "error: {}", + mcpp::freestanding::no_runner_message(ctx->tc.targetTriple)); + return 2; + } + argv = mcpp::freestanding::expand(choice.tmpl, exe); + } else { + argv.push_back(exe.string()); + } + } for (auto& a : passthrough) argv.push_back(a); std::vector> childEnv; diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index f263be33..7f35b42a 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -50,6 +50,25 @@ inline void generated(const char* path) { std::printf("mcpp:generated= inline void source(const char* path) { std::printf("mcpp:source=%s\n", path); } inline void include_dir(const char* dir) { std::printf("mcpp:include-dir=%s\n", dir); } inline void include_dir_after(const char* dir) { std::printf("mcpp:include-dir-after=%s\n", dir); } +// One argv token of the command that EXECUTES this build's artifact, when the +// host cannot run it itself (a freestanding image: wrong ISA, no loader). +// +// Called once per token, in order — argv is an ordered list and a directive +// carries one value per line. The artifact path is appended by mcpp, or +// substituted for a `{}` token if one is present. +// +// ⚠️ Emit the executable as an ABSOLUTE path. A bare name resolves through +// PATH to a shim that dispatches against its OWNER home, which is not +// necessarily the home this build uses; measured in CI as +// `xlings: 'qemu-system-riscv64' is not installed` from a job where the same +// bare name had answered `--version` two steps earlier. `xpkg_dir()` is how a +// package finds the payload it declared. +// +// ⚠️ Exactly one dependency may supply this. Two board-support packages both +// claiming to know how to run the artifact is a configuration error, and mcpp +// reports it naming both rather than merging them. +inline void runner(const char* token) { std::printf("mcpp:runner=%s\n", token); } + // The memory layout for a freestanding link. Reaches the CONSUMER's link line // (like link_lib/link_search, unlike include_dir), because the package that // knows a board's layout is not the package being built. diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 93f6adef..768e9b92 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -18,6 +18,7 @@ module; export module mcpp.build.ninja; import std; +import mcpp.freestanding.linkline; import mcpp.build.backend; import mcpp.manifest; import mcpp.source_kind; @@ -504,6 +505,29 @@ std::string emit_ninja_string(const BuildPlan& plan) { flags.toolchainRuntimeDeploy.begin(), flags.toolchainRuntimeDeploy.end()); + // ── The raw image a flasher takes ────────────────────────────────────── + // + // Only freestanding targets get this; a hosted binary is loaded by a + // loader that wants the ELF. + const auto fsObjcopy = mcpp::freestanding::resolve_objcopy( + plan.toolchain.binaryPath, plan.toolchain.targetTriple); + + if (!fsObjcopy.empty()) { + append("objcopy = " + escape_ninja_path(fsObjcopy) + "\n\n"); + // ── The raw image a flasher takes ────────────────────────────────── + // + // A SEPARATE EDGE with the ELF as its input, not a second output of + // the link. A flat binary is produced by a different tool from a + // finished ELF, and folding it into the link command would make the + // two share one up-to-date check: touch a source, ninja relinks, and + // whether the .bin is regenerated depends on the command happening to + // run again rather than on a declared dependency. That is the shape + // where an incremental build hands back a stale image. + append("rule objcopy_bin\n"); + append(" command = $objcopy -O binary $in $out\n"); + append(" description = OBJCOPY $out\n\n"); + } + bool need_c_rule = false, need_asm_rule = false, need_nasm_rule = false; for (auto& cu : plan.compileUnits) { if (is_c_source(cu)) need_c_rule = true; @@ -1849,6 +1873,18 @@ std::string emit_ninja_string(const BuildPlan& plan) { std::string implicitOut; if (!lu.importLibrary.empty()) implicitOut = " | " + escape_ninja_path(lu.importLibrary); + // The link map, DECLARED rather than merely written. It is produced by + // a flag on the link command, so it cannot be its own edge — a second + // edge claiming to produce it would run the link twice. Declaring it + // as an implicit output is what makes ninja regenerate a deleted map + // and clean it with everything else; without the declaration it exists + // only as a side effect that nothing tracks. + if (!fsObjcopy.empty() + && (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary)) { + auto map = lu.output; map += ".map"; + implicitOut += (implicitOut.empty() ? " | " : " ") + + escape_ninja_path(map); + } // The `.def` edge, emitted BEFORE the link that consumes it. // @@ -1922,11 +1958,30 @@ std::string emit_ninja_string(const BuildPlan& plan) { if (lu.kind != LinkUnit::StaticLibrary) tail.runtimeFallback = flags.ldRuntimeFallback; tail.loaderTag = lu.loaderTagFlag; + // The link map — per unit, because it is named after the artifact. + if (!fsObjcopy.empty() + && (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary)) + tail.dependencies += mcpp::freestanding::map_flag( + lu.output, [](const std::filesystem::path& q) { + return escape_ninja_path(q); + }); if (auto unit = tail.render(); !unit.empty()) out_line += " unit_ldflags =" + unit + "\n"; } append(std::move(out_line)); + // ── Freestanding artifact set ────────────────────────────────────── + // + // `.bin` is a real edge on the `.elf`, so ninja rebuilds it when the + // image changes and never when it does not. `.map` is declared as an + // implicit output of the link edge above — see the note there. + if (!fsObjcopy.empty() + && (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary)) { + const auto elf = escape_ninja_path(lu.output); + append("build " + elf + ".bin: objcopy_bin " + elf + "\n"); + append("default " + elf + ".bin\n\n"); + } + for (auto const& alias : lu.runtimeAliases) { append(std::format("build {} : runtime_alias {}\n", escape_ninja_path(alias), diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 97ec0d11..84fd71e2 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -3132,6 +3132,10 @@ prepare_build(bool print_fingerprint, // the build.mcpp side for the same reason that one is: a program that // reconstructs the store path is coupled to internals mcpp is free to // change. See mcpp::build::hostprogram::xpkg_dir. + // Which dependency supplied the runner, for the exactly-one-provider + // error below. A name rather than a bool: the message has to name both. + std::string runnerProvider; + auto fillXpkgDirs = [&](mcpp::build::BuildProgramEnv& e, const mcpp::manifest::Manifest& owner) { if (owner.xlings.deps.empty()) return; @@ -4958,6 +4962,7 @@ prepare_build(bool print_fingerprint, const auto mark = markDirectiveTail(pkg.manifest); const auto ldN = bcDep.ldflags.size(); const auto actN = bcDep.actions.size(); + const auto runnerN = bcDep.runner.size(); if (auto r = mcpp::build::run_build_program( pkg.manifest, pkg.root, host->first, host->second, pkg.manifest.cppStandard, bpEnv); @@ -4976,6 +4981,33 @@ prepare_build(bool print_fingerprint, // (link-search paths are already absolute from parse_line). foldDirectiveTailIntoPrivateBuild(pkg, pkg.manifest, mark); adoptActionOutputs(pkg.manifest, pkg.root, actN); + + // Scope::RunGlobal — how the artifact is EXECUTED, forwarded to + // the root like link flags but with the opposite merge rule. + // + // ⚠️ EXACTLY ONE provider. Link flags from two dependencies + // concatenate and that is correct; two runners cannot — appending + // produces an argv that is neither one's and fails at exec time + // with nothing to say which package contributed which token. So + // the second provider is a hard error that names BOTH, because + // naming only the loser tells the reader half of what they need. + if (bcDep.runner.size() > runnerN) { + std::vector supplied( + bcDep.runner.begin() + static_cast(runnerN), + bcDep.runner.end()); + if (!m->buildConfig.runner.empty() && !runnerProvider.empty()) { + return std::unexpected(std::format( + "two dependencies both supply a runner for this target: " + "'{}' and '{}'.\n" + " A runner is how the artifact is EXECUTED — there " + "can only be one.\n" + " Drop one of them, or override both with an " + "explicit [target.].runner.", + runnerProvider, pkg.manifest.package.name)); + } + m->buildConfig.runner = std::move(supplied); + runnerProvider = pkg.manifest.package.name; + } m->buildConfig.ldflags.insert(m->buildConfig.ldflags.end(), bcDep.ldflags.begin() + ldN, bcDep.ldflags.end()); } @@ -5241,9 +5273,18 @@ prepare_build(bool print_fingerprint, // no subset of it to build without an OS. Saying "provides no std // module source" sends the reader to look for a broken payload. // - // Naming the replacement is the whole value of the diagnostic: the - // freestanding subset is an ordinary package, so the fix is one line in - // the manifest rather than a toolchain investigation. + // ⚠️ It used to end with a copy-pasteable + // + // [dependencies] + // mcpplibs.std.freestanding = "0.1" + // + // and that package is NOT published. A diagnostic whose suggested fix + // fails at the next command is worse than one that explains and stops: + // the reader spends the next minutes deciding whether their index is + // broken. Point at what a bare-metal project actually has today — the + // board package it already depends on exports a module — and describe + // the subset package as a shape rather than as a line to paste. + // Restore the concrete line when such a package ships. if (auto ft = mcpp::toolchain::triple::parse(tc->targetTriple); ft && ft->is_freestanding()) { @@ -5254,13 +5295,19 @@ prepare_build(bool print_fingerprint, "filesystem, iostreams\n" " included), so there is no subset of it to build without " "an OS underneath.\n" - " Use the freestanding subset instead:\n" "\n" - " [dependencies]\n" - " mcpplibs.std.freestanding = \"0.1\"\n" + " What a bare-metal project uses instead:\n" + " * the module its BOARD package exports — that is where " + "the target's\n" + " C library is already wrapped (riscv-virt-rt exports " + "`mcpplibs.riscv_virt_rt`);\n" + " * or a freestanding subset package, which is an " + "ordinary dependency\n" + " providing the header-only parts of the library that " + "need no OS.\n" "\n" - " then `import mcpplibs.std.freestanding;` in place of " - "`import std;`.", + " No such subset package is published yet, so there is no " + "line to paste here.", tc->targetTriple)); } return std::unexpected(std::format( diff --git a/src/build/program_protocol.cppm b/src/build/program_protocol.cppm index 953dfe94..cd16be7b 100644 --- a/src/build/program_protocol.cppm +++ b/src/build/program_protocol.cppm @@ -45,7 +45,7 @@ export namespace mcpp::build::program_protocol { // number than this must refuse: it cannot know what it is being asked to do, // and "warn and ignore" would turn that into a silently different build. // v2 (#359): adds `rerun-if-changed-glob`. -inline constexpr int kProtocolVersion = 3; +inline constexpr int kProtocolVersion = 4; // ── Cache-format epoch ───────────────────────────────────────────────────── // diff --git a/src/cli.cppm b/src/cli.cppm index 37749e31..bd4431c6 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -316,16 +316,29 @@ int run(int argc, char** argv) { .action(wrap_rc(cmd_build))) .subcommand(cl::App("run") .description("Build + run a binary target (after `--`, args are passed to it)") - // NB: this positional is a BINARY NAME from [[bin]]/src layout — - // unrelated to `--target ` (the cross-target axis). - .arg(cl::Arg("target").help("Binary name (optional)")) - // ⚠️ Same word, two axes — and they were ALREADY named this way: - // the positional above is a binary name, this option is the cross - // target triple, exactly as in `mcpp build --target`. Spelling the - // option differently here would make the one command that needs - // both the one command where the flag is not called --target. - .option(cl::Option("target-triple").takes_value().value_name("TRIPLE") + // ⚠️ Named `bin`, NOT `target`, and the rename is load-bearing. + // + // This positional is a BINARY NAME from [[bin]]/src layout. It was + // called `target` — the same word as the cross-target axis — and + // ParsedArgs::value() falls back from an unset option to a + // positional OF THE SAME NAME, so adding `--target` here made + // every ordinary invocation read the binary name as a triple: + // + // $ mcpp run q + // error: unknown target 'q' + // + // The name is never read back (cmd_run takes positional(0) by + // index); it only labels this slot and shows up in --help, where + // `bin` is the more accurate word anyway. So renaming it is what + // lets `run` spell the flag `--target` like every other + // subcommand, instead of being the one command that cannot. + .arg(cl::Arg("bin").help("Binary name (optional)")) + .option(cl::Option("target").takes_value().value_name("TRIPLE") .help("Cross target triple (same axis as `mcpp build --target`)")) + // Kept as an alias: it shipped in 2026.8.19.1 as the only spelling + // `run` accepted, and scripts written against it must keep working. + .option(cl::Option("target-triple").takes_value().value_name("TRIPLE") + .help("Alias for --target")) .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") .help("Run only the named workspace member (single-member; no --workspace fan-out)")) .option(cl::Option("cache").takes_value().value_name("MODE") @@ -339,6 +352,8 @@ int run(int argc, char** argv) { .description("Build + run all tests/**/*.cpp (after `--`, args go to each test binary)") .arg(cl::Arg("pattern") .help("Run only tests whose name contains PATTERN (optional)")) + .option(cl::Option("target").takes_value().value_name("TRIPLE") + .help("Cross target triple (same axis as `mcpp build --target`)")) .option(cl::Option("message-format").takes_value().value_name("FMT") .help("Output format: human (default) | json (NDJSON, one record per test)")) .option(cl::Option("list") diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index 857e28c7..13b53177 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -166,7 +166,10 @@ export int cmd_run(const mcpplibs::cmdline::ParsedArgs& parsed, bool no_cache = parsed.is_flag_set("no-cache"); if (auto c = parsed.value("cache")) cache_mode = *c; else if (no_cache) cache_mode = "off"; + // Both spellings; see cli.cppm for why the positional had to be renamed + // before `--target` could exist here at all. std::string target_triple; + if (auto tt = parsed.value("target")) target_triple = *tt; if (auto tt = parsed.value("target-triple")) target_triple = *tt; return mcpp::build::build_run_target(targetName, passthrough, package_filter, cache_mode, no_cache, target_triple); @@ -188,6 +191,8 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed, if (auto c = parsed.value("cache")) ov.cache_mode = *c; else if (parsed.is_flag_set("no-cache")) ov.cache_mode = "off"; + if (auto tt = parsed.value("target")) ov.target_triple = *tt; + mcpp::build::TestOptions to; if (parsed.positional_count() > 0) to.filter = parsed.positional(0); to.list = parsed.is_flag_set("list"); diff --git a/src/freestanding/linkline.cppm b/src/freestanding/linkline.cppm index 859bf1da..2e3be178 100644 --- a/src/freestanding/linkline.cppm +++ b/src/freestanding/linkline.cppm @@ -85,6 +85,20 @@ inline std::string link_flags(const Spec& s, const LinkInputs& in, return out; } +// The link map, for the question only a map can answer on a bare-metal target: +// why a section is where it is, and why something did or did not get pulled in. +// +// A flag on the link rather than a separate edge, because the linker is the +// only thing that can produce it and it does so as a side effect of the link +// it is already doing. +inline std::string map_flag(const std::filesystem::path& artifact, + const std::function& esc) +{ + auto m = artifact; m += ".map"; + return " -Wl,-Map=" + esc(m); +} + // LLD inside the same payload as the driver. // // Derived from the driver's own path rather than searched for, because a @@ -102,6 +116,24 @@ inline std::filesystem::path resolve_lld(const std::filesystem::path& driver) { return {}; } +// llvm-objcopy inside the same payload as the driver, for the flat image a +// flasher takes. Derived from the driver's path for the same reason +// `resolve_lld` is: a search is how the wrong tool gets picked. Returns "" for +// a hosted target — a loader wants the ELF, so there is nothing to convert. +inline std::filesystem::path +resolve_objcopy(const std::filesystem::path& driver, std::string_view triple) { + if (driver.empty()) return {}; + if (!resolve(triple)) return {}; // not a bare-metal target we know + const auto bin = driver.parent_path(); + std::error_code ec; + for (const char* name : { "llvm-objcopy", "llvm-objcopy.exe", + "objcopy", "objcopy.exe" }) { + auto p = bin / name; + if (std::filesystem::exists(p, ec)) return p; + } + return {}; +} + // Does this look like LLD? Checked against the linker's own `--version` // output rather than its filename, because the filename is exactly what was // wrong in the failure this guards. @@ -124,4 +156,52 @@ inline std::string wrong_linker_message(const std::filesystem::path& linker, linker.string(), firstLine.empty() ? "(no output)" : firstLine); } +// ── Size summary ─────────────────────────────────────────────────────────── +// +// The core constraint on a bare-metal target is CAPACITY, and mcpp already +// knows the number the moment the link finishes. Not printing it means every +// user runs `size` themselves — and the ones who do not find out the image no +// longer fits when the flasher refuses it. +// +// Parsed rather than passed through so the shape is mcpp's, not the tool's: +// `llvm-size` and GNU `size` differ in their headers, and a build that printed +// one tool's table would change appearance with the toolchain. +struct SizeSummary { long long text = 0, data = 0, bss = 0; }; + +inline long long size_total(const SizeSummary& s) { return s.text + s.data + s.bss; } + +// Parse the Berkeley-format second line: " text data bss dec …". +inline std::optional parse_size_output(std::string_view out) { + std::size_t nl = out.find('\n'); + if (nl == std::string_view::npos) return std::nullopt; + auto row = out.substr(nl + 1); + SizeSummary s; + int got = 0; + long long cur = 0; bool in = false; + for (char c : row) { + if (c >= '0' && c <= '9') { cur = cur * 10 + (c - '0'); in = true; continue; } + if (in) { + if (got == 0) s.text = cur; + else if (got == 1) s.data = cur; + else if (got == 2) { s.bss = cur; return s; } + ++got; cur = 0; in = false; + } + if (c == '\n') break; + } + if (in && got == 2) { s.bss = cur; return s; } + return std::nullopt; +} + +// `llvm-size` beside the driver, same derivation as the linker and objcopy. +inline std::filesystem::path resolve_size_tool(const std::filesystem::path& driver) { + if (driver.empty()) return {}; + const auto bin = driver.parent_path(); + std::error_code ec; + for (const char* name : { "llvm-size", "llvm-size.exe", "size", "size.exe" }) { + auto p = bin / name; + if (std::filesystem::exists(p, ec)) return p; + } + return {}; +} + } // namespace mcpp::freestanding diff --git a/src/manifest/types.cppm b/src/manifest/types.cppm index 04a46541..5c83888c 100644 --- a/src/manifest/types.cppm +++ b/src/manifest/types.cppm @@ -353,6 +353,24 @@ struct Resources { // is read in ~150 places, and a BuildConfig genuinely IS a set of build // inputs plus the selection axis and resolved policy scalars. struct BuildConfig : BuildInputs { + // How `mcpp run` / `mcpp test` execute an artifact this host cannot run, + // as an argv template (the artifact path is appended, or substituted for + // `{}`). + // + // On BuildConfig rather than only in `[target.].runner` because + // the value is MACHINE-SPECIFIC: the emulator lives in a package payload + // whose path carries a home and a version, so only a `build.mcpp` can + // compute it — and a build program writes into BuildConfig. A + // board-support package emitting `mcpp:runner=` is the intended producer; + // the manifest key remains the consumer's override. + // + // ⚠️ EXACTLY ONE provider among the dependencies. Two board-support + // packages both claiming to know how to run the artifact is a + // configuration error, not something to merge: appending would produce an + // argv that is neither one's, and it would fail at exec time with no + // indication of which package contributed which token. + std::vector runner; + // Was `sources` WRITTEN, as opposed to merely being empty? // // Presence is semantic here for the same reason it is on diff --git a/src/version.cppm b/src/version.cppm index cbdd92fb..798bdb34 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.19.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.19.2"; } // namespace mcpp diff --git a/tests/e2e/130_freestanding_riscv_build_and_run.sh b/tests/e2e/130_freestanding_riscv_build_and_run.sh index 65d7142e..b04012f3 100755 --- a/tests/e2e/130_freestanding_riscv_build_and_run.sh +++ b/tests/e2e/130_freestanding_riscv_build_and_run.sh @@ -159,10 +159,26 @@ readelf -h "$img" | grep -q '0x80200000' || { readelf -h "$img" | grep -i entry; exit 1; } # ── run ───────────────────────────────────────────────────────────────────── -"$MCPP" run --target-triple riscv64-none-elf > run.log 2>&1 || true +# ⚠️ BOTH spellings, and the pairing is the test. +# +# `run` also takes a POSITIONAL binary name, and the arg parser falls back +# from an unset option to a positional of the same name. While that positional +# was itself called `target`, adding `--target` here silently turned +# `mcpp run ` into `mcpp run --target=`: +# +# $ mcpp run q +# error: unknown target 'q' +# +# `--target-triple` shipped first and stays an alias, so both are pinned; the +# positional case is pinned by 73_issue131_per_target_cxxflag.sh. +"$MCPP" run --target riscv64-none-elf > run.log 2>&1 || true grep -q 'MCPP-FREESTANDING-OK' run.log || { cat run.log; echo "the firmware did not run (or produced no output)"; exit 1; } +"$MCPP" run --target-triple riscv64-none-elf > alias.log 2>&1 || true +grep -q 'MCPP-FREESTANDING-OK' alias.log || { + cat alias.log; echo "--target-triple (the 2026.8.19.1 spelling) stopped working"; exit 1; } + # ── the runner is REQUIRED, and its absence must say so ───────────────────── # Two-sided: without this, "run worked" could equally mean mcpp exec'd the # image directly and something else printed the line. diff --git a/tests/e2e/131_freestanding_bsp_supplies_everything.sh b/tests/e2e/131_freestanding_bsp_supplies_everything.sh index 62e1f350..b1c88510 100755 --- a/tests/e2e/131_freestanding_bsp_supplies_everything.sh +++ b/tests/e2e/131_freestanding_bsp_supplies_everything.sh @@ -65,7 +65,7 @@ name = "board" version = "0.1.0" [xlings] -deps = ["xim:picolibc-riscv@1.8.12"] +deps = ["xim:picolibc-riscv@1.8.12", "xim:qemu-riscv@9.2.4-1"] EOF cat > build.mcpp <<'EOF' @@ -93,6 +93,16 @@ int main() { std::println("mcpp:link-lib=semihost"); std::println("mcpp:link-lib=clang_rt.builtins-{}", rt); std::println("mcpp:link-script={}/picolibcpp.ld", lib); + // ⭐ The runner too: the package that knows the board resolves the + // emulator absolutely and says how to drive it. The consumer's manifest + // below has no [target.*] section at all — that is N1 of the plan. + const char* qemu = std::getenv("MCPP_XPKG_XIM_QEMU_RISCV_DIR"); + if (qemu && *qemu) { + std::println("mcpp:runner={}/bin/qemu-system-riscv64", qemu); + for (auto a : {"-machine","virt","-nographic","-no-reboot", + "-semihosting","-bios","none","-kernel"}) + std::println("mcpp:runner={}", a); + } std::println("mcpp:rerun-if-env-changed=MCPP_TARGET_ARCH"); return 0; } @@ -136,7 +146,8 @@ extern "C" int main() { EOF # ⚠️ This manifest IS the assertion. Nothing here names picolibc, compiler-rt, -# crt0, a linker script, a load address, -nostdlib or -mcmodel. +# crt0, a linker script, a load address, -nostdlib, -mcmodel — or an emulator. +# There is no [target.*] section at all: the runner comes from the BSP. cat > mcpp.toml <<'EOF' [package] name = "fw" @@ -148,12 +159,7 @@ board = { path = "../board" } [targets.firmware] kind = "bin" main = "src/main.cpp" - -[target.riscv64-none-elf] -runner = ["QEMU_PATH", "-machine", "virt", "-nographic", - "-no-reboot", "-semihosting", "-bios", "none", "-kernel"] EOF -sed -i "s|QEMU_PATH|$QEMU|" mcpp.toml "$MCPP" run --target-triple riscv64-none-elf > run.log 2>&1 || true grep -q 'BSP-CHAIN-OK 42' run.log || { diff --git a/tests/e2e/132_freestanding_test_and_artifacts.sh b/tests/e2e/132_freestanding_test_and_artifacts.sh new file mode 100755 index 00000000..4d9b17af --- /dev/null +++ b/tests/e2e/132_freestanding_test_and_artifacts.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# requires: llvm qemu-riscv unix-shell +# `mcpp test` on bare metal, and the artifact set a flasher needs. +# +# ⚠️ TWO PLAN ASSUMPTIONS THIS TEST EXISTS BECAUSE THEY WERE WRONG +# +# The design called for a `batch` mode (all cases in one image) and a +# structured stdout protocol, on two premises. Both were measured false: +# +# * "qemu cold start is ~0.4s, so 30 isolated cases cost 12s" +# → measured 12ms per start. 30 cases cost 0.36s. The whole reason for +# batching disappeared, and with it the "on timeout, re-run isolated to +# find the culprit" machinery — isolated already names the culprit. +# +# * "bare metal has no exit code to read, so results need their own channel" +# → semihosting propagates the firmware's `main` return value to the +# emulator's exit code (`return 7` → qemu exits 7, verified). "Exit code +# is the verdict" holds here exactly as it does on the host. +# +# So `mcpp test` needed one change — route the test binary through the same +# runner `mcpp run` uses — and this test pins the result of that, from both +# sides: passes pass, and a failure is NAMED and makes the run non-zero. +set -e + +QEMU="$(command -v qemu-system-riscv64 || true)" +for d in "$HOME"/.mcpp/registry/data/xpkgs/*-x-qemu-riscv/*/bin \ + "$HOME"/.xlings/data/xpkgs/*-x-qemu-riscv/*/bin; do + [[ -x "$d/qemu-system-riscv64" ]] && QEMU="$d/qemu-system-riscv64" +done +[[ -n "$QEMU" ]] || { echo "SKIP: no qemu-system-riscv64"; exit 0; } + +MH="${MCPP_HOME:-$HOME/.mcpp}" +[[ -d "$MH/registry/data/xpkgs/xim-x-picolibc-riscv" ]] \ + || { echo "SKIP: xim:picolibc-riscv not installed in $MH"; exit 0; } + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# ── the board-support package (same shape as 131) ─────────────────────────── +"$MCPP" new board > /dev/null +cd board +rm -f src/main.cpp tests/*.cpp 2>/dev/null || true + +cat > mcpp.toml <<'EOF' +[package] +name = "board" +version = "0.1.0" + +[xlings] +deps = ["xim:picolibc-riscv@1.8.12", "xim:qemu-riscv@9.2.4-1"] +EOF + +cat > build.mcpp <<'EOF' +import std; +int main() { + const char* sysroot = std::getenv("MCPP_XPKG_XIM_PICOLIBC_RISCV_DIR"); + if (!sysroot || !*sysroot) { + std::cerr << "board: xim:picolibc-riscv declared but not installed\n"; + return 1; + } + std::string arch = std::getenv("MCPP_TARGET_ARCH") ?: ""; + std::string prof = (arch == "riscv32") ? "rv32imac/ilp32" : "rv64gc/lp64d"; + std::string rt = (arch == "riscv32") ? "riscv32" : "riscv64"; + std::string lib = std::format("{}/lib/{}", sysroot, prof); + + std::println("mcpp:include-dir={}/include/{}", sysroot, prof); + std::println("mcpp:link-search={}", lib); + std::println("mcpp:link-lib=crt0-semihost"); + std::println("mcpp:link-lib=c"); + std::println("mcpp:link-lib=semihost"); + std::println("mcpp:link-lib=clang_rt.builtins-{}", rt); + std::println("mcpp:link-script={}/picolibcpp.ld", lib); + + // ⭐ The runner, from the package that knows the board. The consumer's + // manifest below has no [target.*] section at all. + const char* qemu = std::getenv("MCPP_XPKG_XIM_QEMU_RISCV_DIR"); + if (qemu && *qemu) { + std::println("mcpp:runner={}/bin/qemu-system-riscv64", qemu); + for (auto a : {"-machine","virt","-nographic","-no-reboot", + "-semihosting","-bios","none","-kernel"}) + std::println("mcpp:runner={}", a); + } + std::println("mcpp:rerun-if-env-changed=MCPP_TARGET_ARCH"); + return 0; +} +EOF + +cat > src/board.cppm <<'EOF' +module; +#include +export module board; +export namespace board { + inline void print(const char* s) { fputs(s, stdout); } + inline void printf_d(const char* f, int v) { printf(f, v); } +} +EOF + +# ── the consumer: three test cases, one of them failing ───────────────────── +cd "$TMP" +"$MCPP" new fw > /dev/null +cd fw +rm -f tests/*.cpp 2>/dev/null || true + +cat > src/main.cpp <<'EOF' +import board; +extern "C" int main() { board::print("firmware\n"); return 0; } +EOF +cat > tests/ok_one.cpp <<'EOF' +import board; +extern "C" int main() { board::print("case one\n"); return 0; } +EOF +cat > tests/ok_two.cpp <<'EOF' +import board; +extern "C" int main() { board::printf_d("case two %d\n", 2); return 0; } +EOF +cat > tests/deliberate_fail.cpp <<'EOF' +import board; +// Non-zero on purpose. Without it, "all green" would be indistinguishable +// from "the verdict is never read" — which is what a bare-metal test harness +// gets wrong by default. +extern "C" int main() { board::print("case three\n"); return 1; } +EOF + +# ⚠️ No [target.*] section: the runner comes from the BSP. +cat > mcpp.toml <<'EOF' +[package] +name = "fw" +version = "0.1.0" + +[dependencies] +board = { path = "../board" } + +[targets.firmware] +kind = "bin" +main = "src/main.cpp" +EOF + +# ── mcpp test ─────────────────────────────────────────────────────────────── +if "$MCPP" test --target riscv64-none-elf > test.log 2>&1; then + cat test.log + echo "a failing test case did not make the run fail" + exit 1 +fi +grep -q 'ok_one ... ok' test.log || { cat test.log; echo "ok_one did not pass"; exit 1; } +grep -q 'ok_two ... ok' test.log || { cat test.log; echo "ok_two did not pass"; exit 1; } +# ⚠️ The failure has to be NAMED. "2 passed; 1 failed" without a name is a +# harness that tells you to go looking. +grep -q 'deliberate_fail ... FAIL' test.log || { + cat test.log; echo "the failing case was not named"; exit 1; } + +# ── artifact set ──────────────────────────────────────────────────────────── +"$MCPP" build --target riscv64-none-elf > build.log 2>&1 +elf="$(find target/riscv64-none-elf -name firmware -type f | head -1)" +[[ -n "$elf" ]] || { cat build.log; echo "no firmware"; exit 1; } +[[ -f "$elf.bin" ]] || { echo "no .bin beside the ELF"; exit 1; } +[[ -f "$elf.map" ]] || { echo "no .map beside the ELF"; exit 1; } + +# Capacity is the constraint on a bare-metal target, so the number is printed. +grep -q 'Size .*text .*data .*bss' build.log || { + cat build.log; echo "no size summary"; exit 1; } + +# ⚠️ The one that matters: `.bin` is a real edge on the ELF, not a side effect +# of the link command happening to run. Change a source and its CONTENT must +# change — a mtime-only check would pass even if the edge were missing. +before="$(sha256sum "$elf.bin" | cut -d' ' -f1)" +sed -i 's/firmware\\n/firmware2\\n/' src/main.cpp +"$MCPP" build --target riscv64-none-elf > build2.log 2>&1 +after="$(sha256sum "$elf.bin" | cut -d' ' -f1)" +[[ "$before" != "$after" ]] || { + echo ".bin did not change after a source edit — it is not a real ninja edge" + exit 1; } + +# ⚠️ The `.map` is written by a FLAG on the link command, not by its own edge, +# so it is easy to leave undeclared — and then nothing tracks it. Delete it and +# ninja must put it back; without the implicit-output declaration the ELF is +# up to date, ninja has nothing to do, and the map stays gone. +rm -f "$elf.map" +"$MCPP" build --target riscv64-none-elf > build3.log 2>&1 +[[ -f "$elf.map" ]] || { + cat build3.log + echo ".map did not come back after being deleted — it is not a declared output" + exit 1; } + +echo "PASS: bare-metal mcpp test names its failure, and the artifact set is real" diff --git a/tests/unit/test_build_directives.cpp b/tests/unit/test_build_directives.cpp index 46e423c8..f84e53d9 100644 --- a/tests/unit/test_build_directives.cpp +++ b/tests/unit/test_build_directives.cpp @@ -535,3 +535,67 @@ TEST(BuildDirectives, LinkScriptDoesNotClaimADeclaredOutput) { EXPECT_FALSE(def.mustExistAfterRun); } } + +// ── runner ───────────────────────────────────────────────────────────────── +// +// How the artifact is EXECUTED, when the host cannot execute it. Produced by +// a board-support package's build.mcpp, because the value is machine-specific: +// the emulator lives in a package payload whose path carries a home and a +// version, and only a build program can compute that. + +TEST(BuildDirectives, RunnerBuildsAnArgvFromRepeatedLines) { + // argv is an ordered list and a directive is one line = one value, so the + // list is built by repetition. Order is the emission order. + auto d = parse("mcpp:runner=/payload/bin/qemu-system-riscv64\n" + "mcpp:runner=-machine\n" + "mcpp:runner=virt\n" + "mcpp:runner=-kernel\n"); + auto& r = d.at(dirs::Slot::Runner); + ASSERT_EQ(r.size(), 4u); + EXPECT_EQ(r[0], "/payload/bin/qemu-system-riscv64"); + EXPECT_EQ(r[1], "-machine"); + EXPECT_EQ(r[3], "-kernel"); +} + +TEST(BuildDirectives, RunnerTokensAreVerbatim) { + // Not a path to normalize: `-bios` is a token, and the producer already + // resolved the executable absolutely (a bare name resolves through PATH to + // a shim that dispatches against its OWNER home — measured in CI). + auto d = parse("mcpp:runner=-bios\nmcpp:runner=none\n"); + auto& r = d.at(dirs::Slot::Runner); + ASSERT_EQ(r.size(), 2u); + EXPECT_EQ(r[0], "-bios"); + EXPECT_EQ(r[1], "none"); +} + +TEST(BuildDirectives, RunnerHasItsOwnSlotAndScope) { + const dirs::Def* runner = nullptr; + const dirs::Def* script = nullptr; + for (auto const& def : dirs::kTable) { + if (def.wire == "runner") runner = &def; + if (def.wire == "link-script") script = &def; + } + ASSERT_NE(runner, nullptr); + ASSERT_NE(script, nullptr); + // Its own slot: putting it in LdFlags would put an emulator's argv on the + // linker command line. + EXPECT_EQ(runner->slot, dirs::Slot::Runner); + EXPECT_NE(runner->slot, script->slot); + // Its own scope: RunGlobal propagates like LinkGlobal but carries an + // exactly-one-provider rule that LinkGlobal must not inherit — two + // dependencies' link flags concatenate correctly, two runners cannot. + EXPECT_EQ(runner->scope, dirs::Scope::RunGlobal); + EXPECT_EQ(script->scope, dirs::Scope::LinkGlobal); +} + +TEST(BuildDirectives, RunnerLandsInBuildConfigNotInLdflags) { + auto d = parse("mcpp:runner=/qemu\nmcpp:runner=-kernel\n" + "mcpp:link-lib=c\n"); + mcpp::manifest::Manifest m; + dirs::apply(m, d); + ASSERT_EQ(m.buildConfig.runner.size(), 2u); + EXPECT_EQ(m.buildConfig.runner[0], "/qemu"); + // The link line must carry the library and nothing of the runner. + for (auto const& f : m.buildConfig.ldflags) + EXPECT_EQ(f.find("qemu"), std::string::npos) << f; +} diff --git a/tests/unit/test_freestanding.cpp b/tests/unit/test_freestanding.cpp index 58fd15a3..1acc9bb5 100644 --- a/tests/unit/test_freestanding.cpp +++ b/tests/unit/test_freestanding.cpp @@ -227,3 +227,70 @@ TEST(XpkgEnvVar, BothSpellingsAreDerivedFromOneSanitizer) { EXPECT_EQ(xpkg_env_var("", "picolibc-riscv"), "MCPP_XPKG_PICOLIBC_RISCV_DIR"); } + +// ── engine floor: a package that needs a newer mcpp ───────────────────────── + +TEST(BuildProgramCompatHint, RecognisesAllThreeFrontendSpellings) { + using mcpp::build::mentions_missing_mcpp_api; + // ⚠️ Measured, not assumed: `if constexpr (requires { mcpp::runner("x"); })` + // is a HARD ERROR when the name is absent, so a package cannot probe for a + // newer API in-language. The compiler's error IS the compat channel, and + // these are the three ways it arrives. + EXPECT_TRUE(mentions_missing_mcpp_api( + "build.mcpp:57:11: error: 'runner' is not a member of 'mcpp'")); // gcc + EXPECT_TRUE(mentions_missing_mcpp_api( + "build.mcpp:57:11: error: no member named 'runner' in namespace 'mcpp'"));// clang + EXPECT_TRUE(mentions_missing_mcpp_api( + "build.mcpp(57): error C2039: 'runner': is not a member of 'mcpp'")); // cl.exe +} + +TEST(BuildProgramCompatHint, StaysOffFailuresThatAreNotAboutOurApi) { + using mcpp::build::mentions_missing_mcpp_api; + // The hint says "your mcpp may be too old". Attaching that to an ordinary + // compile error would send the reader to the wrong place, so the match is + // anchored on OUR namespace — a package's own missing symbol names its own. + EXPECT_FALSE(mentions_missing_mcpp_api( + "build.mcpp:12:5: error: 'runner' is not a member of 'board'")); + EXPECT_FALSE(mentions_missing_mcpp_api( + "build.mcpp:3:1: error: expected ';' after top level declarator")); + EXPECT_FALSE(mentions_missing_mcpp_api( + "build.mcpp:9:9: error: use of undeclared identifier 'mcpp_runner'")); +} + +// ── artifact set ─────────────────────────────────────────────────────────── + +TEST(FreestandingArtifacts, SizeOutputIsParsedNotPassedThrough) { + // Parsed so the printed shape is mcpp's, not the tool's: llvm-size and GNU + // size differ in their headers, and a build whose output changed + // appearance with the toolchain would be reporting the tool, not the size. + auto s = parse_size_output( + " text\t data\t bss\t dec\t hex\tfilename\n" + " 8836\t 80\t 5668\t 14584\t 38f8\tfirmware\n"); + ASSERT_TRUE(s.has_value()); + EXPECT_EQ(s->text, 8836); + EXPECT_EQ(s->data, 80); + EXPECT_EQ(s->bss, 5668); + // The number that decides whether the image fits. + EXPECT_EQ(size_total(*s), 14584); +} + +TEST(FreestandingArtifacts, MalformedSizeOutputIsNotGuessedAt) { + // An informational line has no standing to invent numbers. + EXPECT_FALSE(parse_size_output("").has_value()); + EXPECT_FALSE(parse_size_output("size: cannot open 'x'\n").has_value()); +} + +TEST(FreestandingArtifacts, MapFlagNamesTheArtifact) { + auto f = map_flag("/build/bin/firmware", + [](const std::filesystem::path& p) { return p.string(); }); + EXPECT_EQ(f, " -Wl,-Map=/build/bin/firmware.map"); +} + +TEST(FreestandingArtifacts, ObjcopyOnlyResolvesForABareMetalTarget) { + // A hosted binary is loaded by a loader that wants the ELF, so there is + // nothing to convert — and resolving a tool for it would put an edge in + // every hosted graph. + EXPECT_TRUE(resolve_objcopy("/payload/bin/clang++", "x86_64-linux-gnu").empty()); + // (The bare-metal case needs a real payload on disk, so it is asserted by + // tests/e2e/130 instead: the artifact set has to actually appear.) +}