diff --git a/.agents/docs/2026-08-26-resolved-but-not-consulted.md b/.agents/docs/2026-08-26-resolved-but-not-consulted.md new file mode 100644 index 00000000..da3c9409 --- /dev/null +++ b/.agents/docs/2026-08-26-resolved-but-not-consulted.md @@ -0,0 +1,614 @@ +# 已经解析出的答案,没有被用来做决定 + +2026-08-26 · 两条用户报告的缺陷 + 两条连带发现 + 优化方案(待 review,尚未实施) + +前置:[`2026-08-26-cross-target-implies-graph.md`](2026-08-26-cross-target-implies-graph.md) · +[`2026-08-25-the-two-layer-predicate-family.md`](2026-08-25-the-two-layer-predicate-family.md) + +--- + +## 0. 一句话 + +> **两处拒绝,都发生在 mcpp 已经知道正确答案之后。** + +一处知道图要求 `llvm` 而手上正装着 `llvm@22.1.8`,却让用户去改全局默认; +一处知道用户没有写 C 库这一段,却拿自己填进去的那一段去查表并宣布不支持。 + +⭐ 这不是 2026.8.25.x 那个「谓词回答了更窄的问题」的家族。**这次谓词问对了, +答案也算对了,只是那个答案没有参与决定。** 前者是判据错,后者是判据没接线。 + +--- + +## 1. 判据 + +四条,全部在 `mcpp 2026.8.26.1` 实测,机器接口取值。 + +### 1.1 图声明了编译器,而 mcpp 让用户自己去设 + +``` +$ cat mcpp.toml + [dependencies] + openkal-llvm-runtime = "0.1.3" + +$ mcpp build # 全局默认 gcc@16.1.0 + build.mcpp compiling + build.mcpp running + error: `openkal-llvm-runtime@0.1.3` requires the compiler to be `llvm`. + compiler gcc (16.1.0, payload) + required llvm (required by openkal-llvm-runtime@0.1.3) + Select that compiler — yours outranks mcpp's own default: + mcpp toolchain default llvm +``` + +同一台机器,同一个工程,只把编译器换成图要的那个: + +``` +$ MCPP_TOOLCHAIN=llvm@22.1.8 mcpp build + Cached openkal-llvm-runtime v0.1.3 (244 units) + Finished dev [unoptimized + debuginfo] in 1.02s +``` + +⚠️ **`llvm@22.1.8` 本来就装着。** 这次拒绝没有换来任何信息:它要求的东西已经 +在本机,版本也已经确定,mcpp 只是没有去拿。付出的代价是让用户改一处**全局**状 +态(`mcpp toolchain default llvm`)去满足**一个**工程的一条依赖。 + +### 1.2 短拼写在一个架构上通,在另一个架构上不通 + +``` +$ mcpp build --target aarch64-linux + error: target 'aarch64-linux-gnu' is registered but not yet supported (planned) + +$ mcpp build --target aarch64-linux-musl + Finished dev [unoptimized + debuginfo] in 0.99s +``` + +而同一个短拼写在 x86_64 上是**文档教的写法** —— +`examples/06-openkal-cross/src/main.cpp:4`: + +``` +// mcpp build --target x86_64-linux Linux, any host +// mcpp build --target aarch64-macos macOS, any host +// mcpp build --target x86_64-windows Windows, any host +``` + +这个示例的主题是「一份源码,三台机器」。缺的第四行正是 `aarch64-linux`,而它 +是四个里唯一写不出来的。 + +机器接口: + +| 请求 | `data.status` | `data.reason` | +|---|---|---| +| `x86_64-linux` | `ok` | `none` | +| `x86_64-windows` | `ok` | `none` | +| `aarch64-macos` | `ok` | `none` | +| `aarch64-linux` | `refused` | `tier-planned` | + +### 1.3 `unknown target` 说的是假话 + +``` +$ mcpp why toolchain --target riscv64-linux --format json | jq -r '.diagnostics[].message' + unknown target 'riscv64-linux' + known targets: `mcpp toolchain list`; a custom triple needs an + explicit [target.riscv64-linux] section in mcpp.toml +``` + +`riscv64-linux-musl` **就在** `kKnownTargets` 里(tier `planned`)。这个 arch+os +组合是登记过的,消息说它未知。 + +而且这条拒绝没有记号: + +``` +"reason": "other" +``` + +`refusal::Code` 有十二个具名值,`unknown target` 这条路径一个都没记,于是 +`--format json` 把一个有名字的拒绝报成 `other`。 + +### 1.4 `why toolchain` 的两个字段互相矛盾 + +同一份文档,同一次调用: + +```json +"cLibrary": { "mode": "payload-first", "origin": "payload", + "path": ".../xim-x-glibc/2.44/lib64" }, +"layers": [ { "layer": "c-abi", "interface": "musl", + "impl": "openkal-musl@0.3.5", "origin": "graph" } ] +``` + +产物是哪一个,由产物回答: + +``` +$ file target/x86_64-linux-gnu/*/bin/test4 + ELF 64-bit LSB executable, x86-64, statically linked +$ readelf -l … | grep -i interpreter → (空) +$ readelf -d … | grep -i needed → (空) +$ nm -C … | grep -c openkal → 11 +``` + +静态、无解释器、无 `NEEDED`。glibc 不在产物里。`cLibrary` 描述的是 payload 的 +链接搜索模型,`layers` 描述的是产物,两者在图供给 C 库时分叉,而 JSON 没有任何 +字段说明哪一个管用。 + +--- + +## 2. 缺陷一:`requires` 被检查,但从未被采纳 + +### 2.1 位置 + +`src/build/prepare.cppm` 已经有一个「图存在之后再定工具链」的接缝,它的标题就 +是这么写的: + +```cpp +// ─── The toolchain, resolved now that the graph exists ────────────────── + // 5055 +for (auto const& pkg : packages) + for (auto const& entry : pkg.manifest.provides) // 5074 + … graphSuppliesSystem = true; +… +if (auto r = resolve_target_toolchain(); !r) … // 5165 +``` + +这个循环扫 `provides`。**它不扫 `requires`。** 对称的那一半在一千行之后: + +```cpp +for (auto const& entry : pkg.manifest.requires_) // 6153 + requirements.push_back(…); +… +if (auto why = tsd::check_requirements(resolvedTargetSide, requirements)) { + refusal::record(refusal::Code::LayerRequirement); // 6290 + return std::unexpected(*why); +} +``` + +同一个 `packages` 容器,同一种 manifest 字段,一个用来**决定**,一个只用来 +**核对**。 + +### 2.2 为什么这一处是可以修的 + +`resolve_target_toolchain()` 在 5165 首次被调用。此前没有任何 `tc` 消费者: +依赖的 `build.mcpp` 在 5863 编译,根工程的在 6476。⭐ **在接缝里改 `tcSpec` +不需要重新规划,也不会浪费任何已完成的工作。** + +对照 6321 那段注释所说的、在 6328 处**做不到**的事: + +> `tc` has been read and mutated at 39 sites between its resolution and this +> point — re-resolving it here would redo all of them out of order. + +那是 6328。5101 不是。同一份注释还写下了正确的方向: + +> The structural fix is to defer the pin the way the target side itself was +> deferred — resolve it after the graph, where the question it answers has an +> answer. + +pin 已经这样延后了(`targetPinCandidate`,5101–5108)。**`requires` 是同一个决 +定的第三个输入,只是还没接进来。** + +### 2.3 谁的决定可以被改写 + +`TcOrigin` 已经把这个问题回答完了(`prepare.cppm:454`): + +```cpp +export inline bool tc_origin_is_user_explicit(TcOrigin o) { + return o == TcOrigin::ManifestToolchain || o == TcOrigin::TargetSection; +} +``` + +`GlobalDefault` 不在其中,并且那段 ⚠️ 注释逐字说明了为什么:target row 的 pin +必须能压过全局默认,因为 pin 说的是「谁供给这个目标的 C 库」。 + +⚠️ **1.1 里那次拒绝,发生在 `GlobalDefault` 之上** —— 用户跑过 +`mcpp toolchain default gcc`,而 `mcpp toolchain default llvm` 打印的 +`(was: gcc@16.1.0)` 就是它的收据。**mcpp 拒绝改写一个它自己的谓词判定为可改写 +的值。** + +更能说明问题的是:两种来源产生**逐字相同**的输出。 + +``` +$ MCPP_TOOLCHAIN=gcc@16.1.0 mcpp build # ManifestToolchain,用户写下的 + error: `openkal-llvm-runtime@0.1.3` requires the compiler to be `llvm`. +``` + +前者应当拒绝(用户写下了 gcc),后者不应当(那是 mcpp 记住的一个默认值)。 +`TcOrigin` 分出的这个差别,在用户能看到的任何地方都不存在。 + +### 2.3.1 ⭐ 建议文本本身就是这个缺陷的指纹 + +`src/targetside/model.cppm:600–609`,拒绝时给出的全部补救办法: + +```cpp +" Select that compiler — yours outranks mcpp's own default:\n" +" mcpp toolchain default {}\n" // ← 全局,影响本机每一个工程 +" or, for one target only:\n" +" [target.]\n" +" toolchain = \"{}\"" // ← 写进工程 manifest +``` + +两条**都是持久状态变更**,而触发它们的是一次构建里的一条依赖。第一条尤其错: +**一个工程的依赖要 llvm,代价是这台机器上每一个工程的默认编译器都变了。** + +⚠️ 一个引擎在能自己做决定时却建议用户去改全局配置,通常说明这个决定被放在了拿 +不到答案的位置上。这里正是如此——建议写于 2.2 所示的那一千行之后,而答案在一千 +行之前就齐了。 + +### 2.4 版本从哪来 + +`requires = ["mcpp:compiler=llvm"]` 只给族,不给版本。这个问题**已经有答案**: +`mcpp toolchain default llvm` 就只给了族,而它解析出了 `llvm@22.1.8`。 +`src/toolchain/lifecycle.cppm:1074`: + +```cpp +if (auto picked = resolve_version_match( + pkg.ximVersion, list_installed_versions(pkgsDir, pkg.ximName))) +``` + +⭐ **复用它,不要新写一条。** 一个裸族名在 mcpp 里只应该有一种解析方式;写第二 +条就是把同一个决定推导两遍,而那是隐性架构债——两处推导今天一致,加新语义时会 +变成构建失败。 + +--- + +## 3. 缺陷二:tier 闸问的是补全后的身份,而不是请求 + +### 3.1 补全是身份操作,不是请求操作 + +`src/toolchain/triple.cppm:642`: + +```cpp +if (t.os == "linux" && t.env.empty()) t.env = "gnu"; +``` + +紧挨着的注释把这件事说得很清楚: + +> `x86_64-linux` is the canonical identity `x86_64-linux-gnu` … **but it is NOT +> the request** `x86_64-linux-gnu`, which names a C library. + +`envExplicit` 这个字段就是为了记住这个区别而存在的,`prepare.cppm:1631/1640` 两 +处都在用它:请求的 C 库只在 `envExplicit` 时被记下,报告名在 `!envExplicit` 时 +去掉那一段。 + +⚠️ **而 tier 闸(`prepare.cppm:1550`)和 unknown 闸(1540)都不看 `envExplicit`。** + +```cpp +const triple::TargetInfo* known = parsed ? triple::find_known_target(*parsed) : nullptr; +… +if (known && known->tier == "planned" && !hasToolchainOverride) { … } +``` + +`*parsed` 是补全后的身份。于是: + +| 用户问的 | 引擎答的 | +|---|---| +| aarch64 的 Linux 支持吗 | aarch64-linux-**gnu** 支持吗 | +| riscv64 的 Linux 支持吗 | riscv64-linux-**gnu** 支持吗(此行不存在) | + +第一行答「planned」,第二行答「unknown」。两个答案都对——对的是它们各自被问的 +那个问题,而那不是用户问的问题。 + +### 3.2 补全没有看词表 + +补全是词法的:linux→gnu、windows→gnu、freestanding→elf。它在决定时不知道词表里 +有什么。把词表按 (arch, os) 分组之后,唯一的分歧集中在两组: + +| arch+os | 词表里的行 | 词法默认 | 结论 | +|---|---|---|---| +| x86_64 + linux | gnu `verified` / musl `verified` | gnu | 命中,不变 | +| x86_64 + windows | gnu `verified` / musl `preview` / msvc `verified` | gnu | 命中,不变 | +| freestanding | 全部 `elf` | elf | 命中,不变 | +| **aarch64 + linux** | musl `verified` / gnu `planned` | gnu | ⚠️ 落到 planned | +| **riscv64 + linux** | musl `planned` | gnu | ⚠️ 落到不存在的行 | + +⭐ **改动面就是这两格。** 其余每一格的词法默认恰好是一个受支持的行,补全结果不 +变。 + +### 3.3 `parse()` 不动 + +`tests/unit/test_toolchain_triple.cpp:67`: + +```cpp +EXPECT_EQ(parse("x86_64-linux")->str(), "x86_64-linux-gnu"); +``` + +`parse()` 必须保持**词法、全量、与宿主无关**。triple.cppm 的注释为最后一条给了 +理由:从宿主的 env 填会让同一条命令在不同机器上得到不同的输出目录和缓存键, +「a target's identity may not depend on where it was built」。 + +因此补全要作为**请求站点的一个独立步骤**,而不是改 `parse()`。词表是编译期数 +据,在每台宿主上相同,所以这个步骤仍然与宿主无关。 + +--- + +## 4. 优化方案 + +### 4.1 A —— 图声明的编译器参与工具链决定 + +改动落在 `prepare.cppm:5071–5108` 这一个块内。 + +1. 在既有的 `packages` 循环里同时扫 `requires_`,取 `mcpp:compiler=`。 +2. 出现两个不同的 family ⇒ 拒绝,逐字对齐既有的 + 「ONE SUPPLIER PER LAYER, AND TWO IS AN ERROR RATHER THAN A PICK」 + (`prepare.cppm:6056`)那条规则的措辞与形状。 +3. 恰好一个,且与当前 `tcSpec` 的 family 不同: + - `tc_origin_is_user_explicit(tcOrigin)` ⇒ **不改**,走今天的 + `LayerRequirement` 拒绝。用户写下的东西不被改写。 + - 否则 ⇒ `tcSpec = `(裸族名,由 4.5 的既有路径定版本), + `tcOrigin = TcOrigin::GraphRequirement`。 +4. 与 pin 的关系**不需要新的优先级**:图要求编译器的场景里,若图同时供给系统, + `graphSuppliesSystem` 已经取消了 pin;若图不供给系统,那正是 + `ConventionUnreplaced`(5142)要拒绝的局面,拒绝先发生。 + +新增 `TcOrigin::GraphRequirement`,并且: + +- `tc_origin_is_user_explicit()` 不含它(它是 mcpp 的推导,不是用户的话)。 +- `tc_origin_name()` 给它一句话。 +- 状态行按 `pinReplacedDefault` 的先例说明缘由: + +``` + Resolved llvm@22.1.8 → …/xim-x-llvm/22.1.8/bin/clang++ + required by openkal-llvm-runtime@0.1.3 (`requires = ["mcpp:compiler=llvm"]`), + replacing your default gcc@16.1.0 for this project +``` + +- `mcpp why toolchain --format json` 在 `data` 下给出该来源,使 + 「为什么是 llvm」可被机器回答。 + +⭐ **`check_requirements` 的建议文本一并改写(§2.3.1)。** 拒绝在方案 A 下只剩 +一种局面——工程自己写下了相反的编译器——而那种局面里**全局默认与本次构建无 +关**,建议 `mcpp toolchain default llvm` 是答非所问:改了它,这条拒绝照旧。剩下 +的正确补救只有两条,都在工程内: + +``` + This project states its own compiler, and that outranks the graph: + [toolchain] default = "gcc@16.1.0" ← 改成 "llvm",或删掉这一行 + Removing it lets mcpp take the compiler the graph asks for. +``` + +⚠️ 这一条不是文案润色。今天那两条建议里,**唯一能解决问题的那条会改变本机每一 +个工程**,而它被排在第一位;方案 A 落地后它连问题都解决不了。建议文本与引擎行 +为一起变,否则会留下一条指向不存在的机制的指引。 + +⚠️ **族没装时不新增行为。** 落到既有的 payload 安装路径;`MCPP_NO_AUTO_INSTALL` +下走既有的拒绝并带上安装写法。这一条不引入新的网络效应,只是把选择权从用户手里 +移到了图上。 + +### 4.1.1 ⭐⭐ 这次选择不写任何东西,而且这是位置带来的,不是额外加的开关 + +> ⚠️ **本节的结论对了一半,见 [§8.1](#81-️️41-说不需要新的开关这句话是不完整的)。** +> 首次运行那条分支确实进不去,但 `write_default_toolchain` 有**三个**调用点,另外 +> 两个的条件不是它。以下保留原文,因为它被推翻的方式本身是这份文档的一条判据。 + +`resolve_target_toolchain` 在整个 `prepare.cppm` 里**只有两个调用点**: + +``` +2255: return resolve_target_toolchain(); // 它自己的一次性递归 +5165: if (auto r = resolve_target_toolchain(); !r) +``` + +也就是说,它整个函数体——包括那条**首次运行安装并持久化**的分支——都在图之后 +才执行。分支链(1850 / 2010 / 2012 / **2061**)的最后一格是首次运行,它的进入条 +件是 `!tcSpec.has_value()`;而全部三处 `write_default_toolchain` +(2157 / 2206 / 2546)都在这一格里面。 + +于是把图的要求写进 `tcSpec` 的时机(5101)**早于首次运行分支被求值**: + +| | 今天 | 方案 A | +|---|---|---| +| 已有 gcc 默认的机器 | 拒绝,要求改全局默认 | 装/用 llvm,`config.toml` 不动 | +| **一台什么都没装的机器** | 装 gcc → 持久化 gcc → 再拒绝 | `tcSpec` 已是 `llvm` ⇒ **首次运行分支根本不进** ⇒ 直接装 llvm,**什么都不写** | + +⭐ **「不改全局配置」不是给方案 A 加的一条约束,而是把决定放对位置后的自然结 +果。** 不需要新的开关,也没有需要有人记得不去碰的写入点——那些写入点位于一条 +不再进入的分支上。 + +⚠️ 相应地,**图的要求不得反过来喂给持久化路径**。它是这一次构建的性质,不是这 +台机器的性质;把它写回 `~/.mcpp/config.toml` 会让下一个不含该依赖的工程继承一个 +没人要求过的编译器。E10 是执行这条承诺的那个判据。 + +### 4.2 B —— 补全参照词表 + +新增 `triple::resolve_request(Triple&)`(或等价的自由函数),仅在 +`prepare.cppm` 的请求站点、且 `!envExplicit` 时调用,位置在 +`[target.X]` 段查找之前(1521)——查找键必须是解析后的身份。 + +规则,按序: + +1. 词法默认命中一个 tier ≠ `planned` 的行 ⇒ 用它。(x86_64-linux → gnu) +2. 否则,同 (arch, os) 下恰好一个 tier ≠ `planned` 的行 ⇒ 用它。 + (aarch64-linux → **musl**) +3. 否则(该组为空,或全 `planned`)⇒ 保留词法默认,让 1540/1550 的既有诊断照旧 + 触发,但用**该组存在的行**改写消息(见 4.3)。 +4. 多个受支持的兄弟行且词法默认不在其中 ⇒ 拒绝并列出候选。今天词表里没有这一 + 格;规则先写下,免得第一次出现时靠猜。 + +⭐ **规则 1 让这件事自己退休。** `aarch64-linux-gnu` 一旦升到 `verified`,规则 1 +先命中,补全自动回到 gnu,不需要有人记得回来删规则 2。 + +⚠️ **短拼写不承担「早期加入」。** 若工程写了 +`[target.aarch64-linux-gnu] toolchain = …` 想提前用 planned 行,它要写全三段。 +一个拼写回答一个问题;让短拼写既表示「给我受支持的那个」又表示「并且照顾我的提 +前加入段」,是让它同时回答两个。 + +### 4.3 C —— 两条诊断说真话,并且留下记号 + +- `unknown target` 在 (arch, os) 组非空时不再使用。`riscv64-linux` 应得到 + `planned` 那条消息,主语是 `riscv64-linux-musl`。 +- 消息里引用的三元组必须是**用户写下的那个**,或明确写成「你写的 X,它指的是 + Y」。今天 `--target aarch64-linux` 的报错主语是 `aarch64-linux-gnu`,用户没有 + 打过这个字符串。 +- `unknown target` 路径补 `refusal::record`。新增 `Code::UnknownTarget`, + `--format json` 的 `reason` 从 `other` 变成具名值。 + +### 4.4 D —— `why toolchain` 的 C 库只有一个答案 + +`cLibrary` 与 `layers[].c-abi` 在图供给 C 库时说的是两件事。两条路可选,倾向 +第二条: + +1. 图供给 c-abi 时,`cLibrary.mode` 置为一个表示「不适用」的值。 +2. **保留两者并改名**,让字段名说清它们各自回答什么: + `payloadLinkModel`(payload 的链接搜索模型)与 `layers[].c-abi`(产物里的 + C 库)。二者本就不是同一个问题,今天的字段名让它们看起来是。 + +⚠️ 这一条与 A/B 无依赖,可以单独走。它是**机器接口的自洽性**问题:两个字段在 +同一份文档里对同一个事实给出不同答案,而消费方无从判断该信哪个。 + +--- + +## 5. 验收体系 + +判据全部走 `--format json`,不做字符串搜索。 + +| # | 判据 | 方式 | +|---|---|---| +| E1 | 图要 llvm、全局默认 gcc、llvm 已装 ⇒ `status=ok`,`compiler.family=clang` | `why toolchain --format json` | +| E2a | 同上,但工程写了 `[toolchain] default = "gcc@16.1.0"` ⇒ `refused` / `layer-requirement` | 同上 | +| E2b | 同上,但工程写了 `[target.] toolchain = "gcc@16.1.0"` ⇒ `refused` / `layer-requirement` | 同上 | +| **E10** | **E1 前后 `~/.mcpp/config.toml` 的 sha256 相同** | `sha256sum` 对照 | +| **E11** | 无任何工具链的机器上跑 E1 ⇒ 只装 llvm,不出现 `First run … installing gcc` | 构建日志 + `config.toml` 仍无 `default` | +| E3 | E1 的状态行含图的选择缘由,且 `data` 里该来源可读 | 同上 | +| E4 | 两个包要求不同 family ⇒ `refused`,消息同时点名两个包 | 同上 | +| E5 | `--target aarch64-linux` ⇒ `status=ok`,产物目录 `target/aarch64-linux-musl/` | `why` + 目录存在 | +| E6 | `--target x86_64-linux` ⇒ 产物目录仍是 `x86_64-linux-gnu/` (不回归) | 同上 | +| E7 | `--target riscv64-linux` ⇒ `reason=tier-planned`,消息主语含 `riscv64-linux-musl` | `why toolchain --format json` | +| E8 | `parse("x86_64-linux")->str() == "x86_64-linux-gnu"` 仍然成立 | 既有单测,不改 | +| E9 | 图供给 c-abi 时,C 库在 JSON 里只有一个答案 | `why` + `readelf -d` 对照 | + +⚠️ **E5/E6 必须成对。** 只测 aarch64 会让「把 linux 的默认整个换成 musl」这种 +过头的实现看起来是对的。E6 是那条对照。 + +⚠️ **E2a/E2b 必须成对存在。** A 的全部风险在于它改写了谁的决定;没有这两条, +「不改写用户写下的东西」这条承诺就没有任何东西在执行它。两条分别覆盖工程级和 +目标级两种写法——只测一种,另一种的豁免会静默生效。 + +⚠️ **E10 是一条「什么都没发生」的判据,因此必须落到 sha256 而不是落到构建成 +功。** 构建成功与配置被改写可以同时为真,而那正是这次要消除的行为。 + +⚠️ **E11 需要一台没有工具链的机器,本机永远看不见。** 它属于 CI 的 fresh-install +轴(bootstrap 之后、任何 `toolchain install` 之前)。判据是**日志里不出现 +`installing gcc`** ——「装了 llvm」是恒真的,两条路径都会装 llvm,区别只在有没有 +先装一个没人要的 gcc。 + +- e2e 编号从 `299` 起(现有最大 `298`)。 +- E5/E6 在 `tests/matrix/scan.sh` 里有天然位置:短拼写是**请求**这一列的一个新 + 取值,`expected.tsv` 增两格,四台构建机各一次。 +- ⚠️ E5 的产物目录判据要落到**目录名**,不要落到构建成功。补全错到 gnu 而工具链 + 仍然能出 aarch64 产物的世界里,只看「构建成功」是恒绿的。 + +--- + +## 6. 共同形状,以及为什么值得单列 + +2026.8.25.x 修的那七条是**谓词回答了比它被问的更窄的问题**。这两条不是: + +| | 8.25.x 家族 | 本文两条 | +|---|---|---| +| 谓词 | 问错了 | 问对了 | +| 答案 | 错的 | 对的 | +| 缺陷位置 | 判据本身 | 答案没有接到决定上 | + +- `requires` 被完整解析、完整核对,只是没有参与选择。 +- `envExplicit` 被完整记录、在报告里被完整使用,只是没有参与查表。 + +⭐ **两者都是「多存了一个字段而没有多接一根线」。** 这种缺陷不会在读判据时被发 +现,因为判据是对的;它只在用户问「你既然已经知道了,为什么还要我说一遍」时暴 +露。1.1 的用户原话就是这句。 + +⚠️ 由此得到一条可复用的检查:**新增一个记录性字段时,列出它的读者。** 只有一个 +读者(报告)而没有决策读者,通常意味着这根线没接完。`envExplicit` 今天的读者是 +两处报告和零处决定。 + +--- + +## 7. 不做什么 + +- **不改 `parse()` 的填充。** 身份必须全量、词法、与宿主无关(§3.3)。 +- **补全不看 `host_can_serve`,不看图。** 目标身份不得依赖构建它的机器。词表是 + 编译期数据,这是补全能参照的唯一一张表。 +- **不让图要求压过用户写下的工具链。** `[toolchain]` / `[target.X]` / + `MCPP_TOOLCHAIN` 保持今天的拒绝(E2a/E2b)。这是**唯一**保留拒绝的局面。 +- **不写任何持久状态。** 不改 `~/.mcpp/config.toml`,不改工程的 `mcpp.toml`。 + 图的要求是这一次构建的性质,不是这台机器的性质,也不是这个工程的声明。 + §4.1.1:这不是一条需要有人遵守的约束,而是决定放对位置后的结果——那些写入点 + 位于一条不再进入的分支上。 +- **不建议用户去改全局默认。** 拒绝仅剩的那种局面里全局默认与本次构建无关, + 建议它是答非所问(§4.1 末)。 +- **不给短拼写加「提前加入」语义**(§4.2)。 +- **aarch64 的其余目标仍然延缓**,见 + [`2026-08-26-aarch64-linux-ecosystem-closure.md`](2026-08-26-aarch64-linux-ecosystem-closure.md)。 + §4.2 规则 1 保证 `aarch64-linux-gnu` 升级后补全自动跟随。 + +--- + +## 8. 落地(2026.8.26.2)—— 与本文的三处出入 + +四项 A/B/C/D 全部实施。**实施过程推翻了本文的一处论断,并挖出两处本文没有想到 +的缺陷。** + +### 8.1 ⚠️⚠️ §4.1.1 说「不需要新的开关」,这句话是**不完整的** + +原文: + +> ⭐ **「不改全局配置」不是给方案 A 加的一条约束,而是把决定放对位置后的自然结 +> 果。** 不需要新的开关,也没有需要有人记得不去碰的写入点 —— 那些写入点位于一条 +> 不再进入的分支上。 + +对的只有**一处**。首次运行那条分支的条件是 `!tcSpec.has_value()`,方案 A 让它有 +值,于是确实进不去。但 `write_default_toolchain` 有**三个**调用点,另外两个的条 +件不是它: + +| 调用点 | 条件 | A 之后可达? | +|---|---|---| +| 首次运行安装 | `!tcSpec.has_value()` | 否 | +| Windows 首次运行改道 | `windowsGnuFirstRun && tcSpec.has_value()` | **是** | +| MSVC 不可用时的修复 | `!tc_origin_is_user_explicit(tcOrigin)` | **是** | + +一台没装工具链的 Windows 机器,构建**一个**要求 llvm 的工程,会把 llvm 写成**这 +台机器**的默认值,交给之后每一个什么都没要求的工程。 + +⭐ 修法不是在两处各写一个条件,而是给这条规则一个名字: +`tc_origin_may_persist(TcOrigin)`,两处都调用它,一条单测陈述它。理由写在函数 +上方:今天有两处,第三处会由一个没读过这段注释的人写出来,而一个有名字的谓词是 +他能找到的东西。 + +⚠️ **这条缺陷是读出来的,不是跑出来的** —— 它需要一台没有工具链的 Windows 机 +器。E10(config.toml 的 sha256)跑在已经配好工具链的环境里,两条分支一条都到不 +了,所以判据只能落到规则本身。这正是 §5 那句「E11 本机永远看不见」的更强版本: +有些判据连 CI 都给不了,只能由单测陈述规则。 + +### 8.2 ⚠️ 能力行的补救办法抄了约定行的 + +第一版的 `TargetPin` 拒绝对两种行给同一段建议:「依赖一个供给该目标系统的包, +这样就不需要行里那个载荷了」。 + +对约定行成立(`graphSuppliesSystem` 正是取消它的东西),对**能力行不成立** —— +`targetPinIsCapability` 让 pin 无论图供给什么都保持生效。于是那段文字给出的指 +示,被它正上方那句话("no other family emits this target")已经否掉了。 + +⭐ 这与 §2.3.1 是同一条:**一条修不好它所印在其下的那次失败的建议,比没有建议更 +糟**。我在同一个 PR 里,隔着三屏,把自己刚指出的错误又犯了一遍。 + +### 8.3 ⭐ `compiler.chosenBy` 落地,以及三条测试自己的缺陷 + +- `why toolchain --format json` 新增 `compiler.chosenBy = {origin, requiredBy, + replaced}`(§4.1 承诺过)。e2e 301 因此改为断言这个字段而不是状态行的措辞。 +- ⚠️ **jq 的 `//` 把 `false` 当成缺席**。303 用 `.suppliesTarget // "MISSING"` + 读一个布尔字段,`false` 是一个合法答案却读成了「字段不存在」。 + windows-x86_64 上实测报「字段缺失」,而字段就在那里。判据要用 `has()`。 +- ⚠️ **301 第一版在错误的目录里量基线**:从 runner 的起始目录问 `why toolchain`, + 读到的是 mcpp 自己仓库 `mcpp.toml` 里写的 gcc,于是选了 llvm 去要求,而实测工程 + 的全局默认本来就是 llvm —— 全部断言通过,而那条要求什么都没改变。基线必须取自 + **同一份 manifest 去掉依赖**。 +- ⚠️ **281 断言的是旧建议**。它 grep `mcpp toolchain default`,而方案 A 之后唯一 + 还能走到那条拒绝的局面里,那条建议连问题都解决不了。已改成断言新建议**并且** + 断言旧建议不出现。 + +--- + +## 9. 顺序与代价 + +| 项 | 依赖 | 触及 | 风险 | +|---|---|---|---| +| C(诊断说真话 + 记号) | 无 | `prepare.cppm` 1540/1550、`refusal.cppm` | 低,纯消息与记号 | +| B(补全参照词表) | C 先落更好读 | `triple.cppm` 新函数、`prepare.cppm` 请求站点 | 低,改动面两格(§3.2) | +| D(JSON C 库自洽) | 无 | `doctor.cppm` | 低,但改字段名是接口变更,须进 `kindVersion` 讨论 | +| A(图声明的编译器) | 无 | `prepare.cppm` 5071–5108、`TcOrigin`、状态行、`why` | 中,它改写决定 —— E2 是闸 | + +C、B、D 相互独立;A 独立于三者。四项可并行,合入顺序按上表从上到下最易复查。 diff --git a/.github/workflows/ci-target-matrix.yml b/.github/workflows/ci-target-matrix.yml index 6c8ec106..3e6e0455 100644 --- a/.github/workflows/ci-target-matrix.yml +++ b/.github/workflows/ci-target-matrix.yml @@ -80,7 +80,10 @@ jobs: command -v jq >/dev/null || { echo "::error::jq is missing on ${{ matrix.host }}"; exit 1; } fail=0 for t in tests/e2e/295_*.sh tests/e2e/296_*.sh \ - tests/e2e/297_*.sh tests/e2e/298_*.sh; do + tests/e2e/297_*.sh tests/e2e/298_*.sh \ + tests/e2e/299_*.sh tests/e2e/300_*.sh \ + tests/e2e/301_*.sh tests/e2e/302_*.sh \ + tests/e2e/303_*.sh; do echo "=== $t ===" bash "$t" 2>&1 | tee "$(basename "$t").log" || true rc=${PIPESTATUS[0]} @@ -173,6 +176,55 @@ jobs: "OK: a convention may be overridden, but not merely removed" \ "llvm is not installed here" || fail=1 fi + + # ── 2026.8.26.2: an answer mcpp already had, now used ───────────── + # + # ⭐ 299/300/303 CARRY NO SKIP AT ALL, so they use `check` on every + # host. They read the vocabulary and the query's own document — + # neither depends on which payloads this machine happens to hold, and + # a version of them that skipped anywhere would be a version that + # could skip everywhere. + check 299_a_request_that_named_no_c_library_resolves_to_a_row_that_exists.sh \ + "OK: a request that named no C library resolves to a row that exists" || fail=1 + check 300_a_registered_family_is_not_reported_unknown.sh \ + "OK: a registered family is not reported unknown" || fail=1 + # ⚠️ 303's third half stacks a musl c-abi over this host's own target, + # and not every host stacks that — an MSVC-ABI host answers the + # layering question first, correctly, and there is then no + # two-answer document to check. Granted by reason; linux-x86_64 below + # is the denominator that must run the whole file. + if [ "${{ matrix.host }}" = linux-x86_64 ]; then + check 303_the_query_gives_one_answer_for_the_c_library.sh \ + "OK: the query gives one answer for the C library" || fail=1 + else + check_or_declared_skip 303_the_query_gives_one_answer_for_the_c_library.sh \ + "OK: the query gives one answer for the C library" \ + "refuses a musl c-abi over its own target" || fail=1 + fi + + # ⚠️ 301/302 NEED TWO COMPILER FAMILIES, AND THAT IS A PROPERTY OF THE + # MACHINE RATHER THAN OF THE CLAIM. "A requirement that DIFFERS from + # mcpp's own answer is applied" cannot be stated where only one family + # exists — macOS installs llvm only, and aarch64 Linux has no llvm + # payload at all (see 298's note). + # + # ⭐ AND THE DENOMINATOR IS linux-x86_64, WHICH HAS BOTH. Without a + # host required to actually run these, a reason accepted everywhere is + # a test that runs nowhere. + if [ "${{ matrix.host }}" = linux-x86_64 ]; then + check 301_the_graphs_compiler_is_taken_and_nothing_is_written.sh \ + "OK: the graph's compiler is taken and nothing is written" || fail=1 + check 302_a_stated_compiler_outranks_the_graph_and_two_requirements_do_not_stack.sh \ + "OK: a stated compiler outranks the graph and two requirements do not stack" || fail=1 + else + check_or_declared_skip 301_the_graphs_compiler_is_taken_and_nothing_is_written.sh \ + "OK: the graph's compiler is taken and nothing is written" \ + "no other family is" || fail=1 + # 302's half two needs no second family and always runs; only half + # one is skipped, so the file still reaches its conclusion. + check 302_a_stated_compiler_outranks_the_graph_and_two_requirements_do_not_stack.sh \ + "OK: a stated compiler outranks the graph and two requirements do not stack" || fail=1 + fi [ "$fail" = 0 ] || exit 1 scan: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae48eb2..87e11ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,121 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.26.2] — 2026-08-26 + +已经解析出的答案,没有被用来做决定。完整分析见 +[`.agents/docs/2026-08-26-resolved-but-not-consulted.md`](.agents/docs/2026-08-26-resolved-but-not-consulted.md)。 + +⭐ **这不是 2026.8.25.x 那个「谓词回答了比自己更窄的问题」的家族。** 那一族是判据 +问错了;这一族里谓词问对了、答案也算对了,只是那个答案**没有接到决定上**。两条都 +是「多存了一个字段而没有多接一根线」,因此读判据时看不出来 —— 只在用户问「你既然 +已经知道了,为什么还要我说一遍」时暴露。 + +### 修复 + +- **⭐⭐ 依赖声明的编译器被检查,但从未被采纳。** + + ``` + $ cat mcpp.toml + [dependencies] + openkal-llvm-runtime = "0.1.3" # requires = ["mcpp:compiler=llvm"] + + $ mcpp build # 全局默认 gcc@16.1.0,而 llvm@22.1.8 已装 + error: `openkal-llvm-runtime@0.1.3` requires the compiler to be `llvm`. + Select that compiler … mcpp toolchain default llvm + ``` + + 这次拒绝没有换来任何信息:它要求的东西已经在本机,版本也已确定 + (`MCPP_TOOLCHAIN=llvm@22.1.8 mcpp build` 用时 1.02s)。付出的代价是让用户改一 + 处**全局**状态去满足**一个**工程的一条依赖。 + + 真因是位置:`prepare.cppm` 有一个标题写着「the toolchain, resolved now that + the graph exists」的接缝,它扫 `pkg.manifest.provides` 来决定编译器,**却不扫 + `requires_`** —— 后者在一千行之后才被收集,只用来否决这个决定。 + + ⭐⭐ **修好之后不写任何东西,而这是位置带来的,不是额外加的开关。** + `resolve_target_toolchain` 只有两个调用点,整个函数体(含首次运行的 + 安装并持久化分支与全部三处 `write_default_toolchain`)都在图之后。把图的要求 + 写进 `tcSpec` 的时机早于首次运行分支被求值,于是: + + | | 之前 | 现在 | + |---|---|---| + | 已有 gcc 默认的机器 | 拒绝,要求改全局默认 | 装/用 llvm,`config.toml` 不动 | + | 什么都没装的机器 | 装 gcc → 持久化 gcc → 再拒绝 | 首次运行分支根本不进,直接装 llvm | + + ⭐ 状态行点名是哪个包要求的、顶掉了什么;`why toolchain --format json` 新增 + `compiler.chosenBy = {origin, requiredBy, replaced}`,让「为什么是 llvm」不必去 + 解析那行提示 —— 那正是机器接口存在的理由所要消除的字符串匹配。 + + 拒绝只剩一种局面:工程自己在 `[toolchain]` 或 `[target.X]` 写下了相反的编译器。 + ⚠️ 那种局面里全局默认与本次构建无关,因此原来那条 `mcpp toolchain default llvm` + 的建议**连问题都解决不了**,已改为指向那条陈述本身。两个包要求不同的族则是错误 + 而不是一次挑选,并同时点名两个包。 + +- **⭐⭐ tier 闸问的是补全后的身份,而不是请求。** + + ``` + $ mcpp build --target aarch64-linux + error: target 'aarch64-linux-gnu' is registered but not yet supported (planned) + $ mcpp build --target aarch64-linux-musl + Finished dev [unoptimized + debuginfo] in 0.99s + ``` + + `parse` 把缺失的 env 段按词法填成 `gnu`,那是为了让**身份**完整(输出目录、缓存 + 键),`envExplicit` 就是为记住这个区别而存在的 —— 而 tier 闸不看它。被问的是 + 「aarch64 的 Linux」,被回答的是「aarch64-linux-**gnu**」,报错还引用了一个用户 + 从没打过的字符串。 + + 省略了 env 段的请求现在对着词表补全,规则 1(词法默认受支持就用它)排在最前, + 因此 `x86_64-linux` 一动不动,而这件事能自己退休。⚠️ **`parse()` 未改**:身份 + 必须保持词法、全量、与宿主无关。 + +- **⚠️ `unknown target 'riscv64-linux'` 说的是假话。** + + `riscv64-linux-musl` 就在词表里(`planned`)。词法填充产生了一个**完全不存在** + 的行,于是一个已登记的目标族被报成未知。而且这条路径没有 `refusal::record`, + `--format json` 把它报成 `reason: "other"`。现在它给出 planned 的诊断并点名 + `riscv64-linux-musl`;真正的拼写错误仍报 unknown,但带上了新的 + `unknown-target` 记号。 + +- **⚠️ `why toolchain --format json` 的两个字段互相矛盾。** + + `cLibrary` 说 glibc/payload,`layers[].c-abi` 说 musl/graph。产物给出裁决 —— + 静态、无解释器、无 `DT_NEEDED`、11 个 openkal 符号 —— glibc 不在里面。两者各自 + 准确,回答的却是不同的问题,而消费方无从判断该信哪个。新增 + `cLibrary.suppliesTarget`;⭐ 是**增字段**而非改名或给 `mode` 加取值,因为 + docs/11 §6 承诺字段只增不删、含义永不改变。 + +- **⚠️⚠️ 而「不写任何东西」需要一个有名字的规则,不只是一个位置。** + + `write_default_toolchain` 有三个调用点。首次运行那个的条件是 + `!tcSpec.has_value()`,方案确实让它进不去;另外两个 —— Windows 首次运行改道、 + MSVC 不可用时的修复 —— 条件不是它,**都可达**。一台没装工具链的 Windows 机器 + 构建一个要求 llvm 的工程,会把 llvm 写成这台机器的默认值。 + + ⭐ 修法是给规则一个名字:`tc_origin_may_persist(TcOrigin)`,两处都调用,一条单 + 测陈述它。⚠️ 这条缺陷是**读出来的**:它需要一台没有工具链的 Windows 机器,而 + `config.toml` 的 sha256 判据跑在已配好的环境里,两条分支一条都到不了。 + +### 兼容性 + +⭐ **没有任何一次原本成功的构建换了行为。** 图声明编译器的情形里,原来的结局是 +`check_requirements` 拒绝 —— 也就是说那些构建本来就不成功;`--target aarch64-linux` +与 `riscv64-linux` 原来是拒绝;`x86_64-linux` / `x86_64-windows` / `riscv64-none` / +`aarch64-macos` 的补全结果一字未变(单测逐行遍历整张词表守住这一条)。机器接口只 +增字段。`[toolchain] default = "system"`(PATH 编译器这条逃生口)不被替换。 + +### 判据 + +- e2e `299`–`303`,全部走 `--format json` 分类而非字符串搜索,并接入 + `target matrix` 的第一层 —— **四台构建机各跑一遍**。 +- ⭐ `301` 的判据是 `~/.mcpp/config.toml` 的 **sha256**,不是「构建成功」:构建成功 + 与配置被改写可以同时为真,而那正是这次要消除的行为。 +- ⭐ `299` 的第二半是对照 —— `x86_64-linux` 必须仍是 gnu。只测 aarch64 会让「把 + linux 的默认整个换成 musl」这种过头实现看起来是对的。 +- 单测 `TripleRequest.*` 七条,含一条遍历整张词表的「每个受支持的行都能从它自己的 + 拼写到达」。 + ## [2026.8.26.1] — 2026-08-26 写出 `--target` 这个动作,曾被当成「这个构建的系统来自依赖图」。完整分析见 diff --git a/docs/03-toolchains.md b/docs/03-toolchains.md index 80906480..61f4050f 100644 --- a/docs/03-toolchains.md +++ b/docs/03-toolchains.md @@ -81,6 +81,58 @@ The pair persists as `[toolchain] default = "gcc@16.1.0"` + configs with combined spellings like `default = "gcc@15.1.0-musl"` keep working unchanged.) +### What decides a build's compiler + +Five things can name it. They are ranked, and the rank is what makes the two +statements a project can write outrank everything mcpp keeps on its own: + +| | source | may mcpp revise it | +|---|---|---| +| 1 | `[target.] toolchain` in `mcpp.toml` | no | +| 2 | `[toolchain] default` in `mcpp.toml` (or `MCPP_TOOLCHAIN`) | no | +| 3 | `requires = ["mcpp:compiler="]` from a dependency | — | +| 4 | the target row's pin, when the row's payload supplies the target side | yes | +| 5 | `mcpp toolchain default`, then mcpp's first-run default | yes | + +**A dependency may require a compiler family.** A C++ runtime is configured for +one family and records that configuration in the headers it ships, so a package +supplying one states which compiler it was built for. When that requirement +differs from a value at rank 4 or 5 — answers mcpp derived itself — mcpp takes +the required family for that build: + +``` +$ mcpp build + Resolving toolchain + Resolved llvm@22.1.8 → …/xim-x-llvm/22.1.8/bin/clang++ + required by openkal-llvm-runtime@0.1.3 (`requires = ["mcpp:compiler=llvm"]`), + not your gcc@16.1.0 — this project only +``` + +⭐ **Nothing is written.** Not `~/.mcpp/config.toml`, not the project's +`mcpp.toml`. The requirement is a property of this build, so it applies to this +build; the machine's default stays whatever it was, for every other project. +The version comes from what is already installed — the same resolution +`mcpp toolchain default ` performs — and only from the ecosystem's own +pin when nothing of that family is present. + +**A compiler the project states is not revised.** At ranks 1–2 the project has +said what it builds with, and a dependency disagreeing is a real contradiction: + +``` +error: `openkal-llvm-runtime@0.1.3` requires the compiler to be `llvm`. + compiler gcc (16.1.0, payload) + required llvm (required by openkal-llvm-runtime@0.1.3) + This build's compiler is stated in [toolchain] in mcpp.toml, and a compiler + the project states outranks one its dependencies ask for. + Change it to `llvm`, or remove it — with nothing stated, mcpp takes the + compiler the graph requires and changes no configuration to do it. +``` + +**Two dependencies requiring different families is an error, not a pick.** One +build has one compiler; resolving by graph-traversal order would decide it by an +order the author neither writes nor can predict, and would satisfy one package +while failing the other inside a header. + ## Inspecting Toolchain Status ```bash diff --git a/docs/11-machine-output.md b/docs/11-machine-output.md index 6cae2679..83e8612e 100644 --- a/docs/11-machine-output.md +++ b/docs/11-machine-output.md @@ -262,17 +262,55 @@ It resolves and reports; it does not build. `data` is: | `requested` | `{target, toolchain}` — what was asked for | | `status` | `ok` or `refused` | | `reason` | a refusal token, or `none` | -| `compiler` | `{family, version, driver}` — the driver that would run | +| `compiler` | `{family, version, driver, chosenBy}` — the driver that would run, and why | | `triple` | `{requested, toolchain, llvm}` | -| `cLibrary` | `{mode, path, origin}` — `mode` is `sysroot` / `payload-first` / `none`; `origin` is `payload` / `subos` / `host` / `none` | +| `cLibrary` | `{mode, path, origin, suppliesTarget}` — `mode` is `sysroot` / `payload-first` / `none`; `origin` is `payload` / `subos` / `host` / `none` | | `layers[]` | the five target-side layers: `{layer, interface, impl, origin, subset}` | +⭐ **`compiler.chosenBy` answers "why this one".** `{origin, requiredBy, +replaced}` — `origin` is the same phrase the build's status line uses +(`[toolchain] in mcpp.toml`, `your default`, `target default`, +`required by the dependency graph`, `first-run default`). `requiredBy` names the +package when a `requires = ["mcpp:compiler=…"]` decided it, and `replaced` names +the spec that was displaced; both are empty when nothing was. + +```jsonc +"compiler": { "family": "clang", "version": "22.1.8", "driver": "…/clang++", + "chosenBy": { "origin": "required by the dependency graph", + "requiredBy": "openkal-llvm-runtime@0.1.3", + "replaced": "gcc@16.1.0" } } +``` + +Without it a consumer asking *why* would have to parse the status line — the +substring matching this document exists to remove. + +⚠️ **`cLibrary` and `layers[].c-abi` answer two questions, and `suppliesTarget` +says which one governs.** `cLibrary` describes the *payload's* link model — the +search paths a payload-supplied C library would use. `layers[].c-abi` describes +the *build*. When a dependency supplies the C library the two diverge, and +before `suppliesTarget` existed the document reported both with no way to tell +them apart: + +```jsonc +"cLibrary": { "origin": "payload", "path": "…/xim-x-glibc/2.44/lib64", + "suppliesTarget": false }, // ← added; the payload is not in the artifact +"layers": [ { "layer": "c-abi", "interface": "musl", + "impl": "openkal-musl@0.3.5", "origin": "graph" } ] +``` + +A field was added rather than `cLibrary` renamed or `mode` widened, because §6 +promises that fields are added and never removed and that a field's meaning +never changes. + ⭐ **`reason` is a token, not a sentence.** The refusal's message is still written for a person and still names the target, the rule and the way out — but a program classifying the outcome reads `reason`: | `reason` | | |---|---| +| `unknown-target` | the spelling names no row, and no `(arch, os)` group either | +| `ambiguous-request` | several rows serve this `(arch, os)` and none is the default | +| `compiler-requirement-conflict` | the graph's required compiler cannot be used here | | `tier-planned` | the row exists in the vocabulary; nothing is wired yet | | `host-cannot-serve` | no payload here, and no dependency supplied the system | | `capability-pin` | the row's toolchain is a capability, not a preference | diff --git a/docs/16-the-target-triple.md b/docs/16-the-target-triple.md index 967f879c..bd884e1e 100644 --- a/docs/16-the-target-triple.md +++ b/docs/16-the-target-triple.md @@ -67,6 +67,7 @@ cannot rename it. mcpp build --target x86_64-linux # = x86_64-linux-gnu mcpp build --target x86_64-windows # = x86_64-windows-gnu mcpp build --target riscv64-none # = riscv64-none-elf +mcpp build --target aarch64-linux # = aarch64-linux-musl mcpp build --target aarch64-macos # macOS has no segment to decline ``` @@ -80,6 +81,45 @@ identity, which must be total, and as a request, which must be able to say nothing; mcpp keeps both, filling the segment for the identity while recording that the fill was a fill. +### The completion is chosen from the vocabulary, not from a fixed word + +The fourth line above is why the two roles have to stay separate. Filling +`aarch64-linux` lexically gives `aarch64-linux-gnu`, and that row is `planned` +— while `aarch64-linux-musl` is `verified`. Before 2026.8.26.2 the tier gate +asked about the filled value, so: + +``` +$ mcpp build --target aarch64-linux + error: target 'aarch64-linux-gnu' is registered but not yet supported (planned) +$ mcpp build --target aarch64-linux-musl + Finished dev [unoptimized + debuginfo] in 0.99s +``` + +The question asked was *aarch64, Linux*. The question answered was +*aarch64-linux-**gnu***, and the message quotes a triple that appears nowhere in +the command. `riscv64-linux` was worse: the fill named a row outside the +vocabulary entirely, so a registered family was reported as `unknown target`. + +A request that declined the segment is completed against the known-target table, +in this order: + +1. the lexical default names a supported row — take it (`x86_64-linux` → `gnu`); +2. exactly one row for this `(arch, os)` is supported — take it + (`aarch64-linux` → `musl`); +3. nothing is supported — keep the lexical form, and diagnose against the rows + that *do* exist (`riscv64-linux` → "planned; registered rows for this system: + `riscv64-linux-musl`"); +4. several are supported and the lexical default is none of them — refuse and + list them. No `(arch, os)` has this shape today. + +Rule 1 comes first so this retires itself: the day `aarch64-linux-gnu` graduates +from `planned`, the lexical answer wins again with nothing to edit. + +**Writing the segment opts out.** A written segment is a request, not a gap, so +`--target aarch64-linux-gnu` still reaches the `planned` row's refusal — which +is the escape hatch for opting into a row early with an explicit +`[target.] toolchain`. + ### Which Spelling To Use **Under the build-time system, decline it.** The graph supplies the C library diff --git a/docs/zh/03-toolchains.md b/docs/zh/03-toolchains.md index 57115a8e..5f5eacba 100644 --- a/docs/zh/03-toolchains.md +++ b/docs/zh/03-toolchains.md @@ -79,6 +79,54 @@ mcpp toolchain default gcc@16 --target x86_64-linux-musl # "默认就要全静 `[toolchain] default = "gcc@16.1.0"` + `default_target = "x86_64-linux-musl"`。 (存量 config 里 `default = "gcc@15.1.0-musl"` 这类合并拼写原样可用。) +### 谁决定一次构建的编译器 + +有五种来源会给它命名。它们是分级的,而这套分级正是让工程能写下的那两条压过 +mcpp 自己保管的一切的原因: + +| | 来源 | mcpp 可否改写 | +|---|---|---| +| 1 | `mcpp.toml` 的 `[target.] toolchain` | 否 | +| 2 | `mcpp.toml` 的 `[toolchain] default`(或 `MCPP_TOOLCHAIN`) | 否 | +| 3 | 依赖的 `requires = ["mcpp:compiler=<族>"]` | — | +| 4 | 目标行的 pin(当该行的载荷供给目标侧时) | 是 | +| 5 | `mcpp toolchain default`,以及 mcpp 的首次运行默认值 | 是 | + +**依赖可以要求一个编译器族。** 一份 C++ 运行时是为某一个族 configure 过的, +并把这份配置记在它所发布的头文件里,因此供给它的包会说明自己是为哪个编译器构建 +的。当这条要求与第 4、5 级 —— mcpp 自己推导出来的答案 —— 不同时,mcpp 就为这次 +构建取用被要求的那个族: + +``` +$ mcpp build + Resolving toolchain + Resolved llvm@22.1.8 → …/xim-x-llvm/22.1.8/bin/clang++ + required by openkal-llvm-runtime@0.1.3 (`requires = ["mcpp:compiler=llvm"]`), + not your gcc@16.1.0 — this project only +``` + +⭐ **不写任何东西。** 不写 `~/.mcpp/config.toml`,也不写工程的 `mcpp.toml`。 +这条要求是**这次构建**的性质,就只作用于这次构建;这台机器的默认值保持原样,对 +其他每一个工程都是。版本取自已经装好的那些 —— 与 `mcpp toolchain default <族>` +走的是同一条解析 —— 只有该族一个都没装时,才取生态自己的 pin。 + +**工程写下的编译器不会被改写。** 第 1、2 级上工程已经说明了它用什么构建,依赖 +与之不一致就是一次真实的矛盾: + +``` +error: `openkal-llvm-runtime@0.1.3` requires the compiler to be `llvm`. + compiler gcc (16.1.0, payload) + required llvm (required by openkal-llvm-runtime@0.1.3) + This build's compiler is stated in [toolchain] in mcpp.toml, and a compiler + the project states outranks one its dependencies ask for. + Change it to `llvm`, or remove it — with nothing stated, mcpp takes the + compiler the graph requires and changes no configuration to do it. +``` + +**两个依赖要求不同的族是错误,不是一次挑选。** 一次构建只有一个编译器;按图的 +遍历顺序来定,等于让作者既不书写也无法预测的顺序做决定,并且会满足其中一个包而 +让另一个在它自己的头文件里失败。 + ## 查看工具链状态 ```bash diff --git a/docs/zh/11-machine-output.md b/docs/zh/11-machine-output.md index aff81f66..9a9389d6 100644 --- a/docs/zh/11-machine-output.md +++ b/docs/zh/11-machine-output.md @@ -231,16 +231,50 @@ mcpp why toolchain [--target ] [--toolchain ] --format json | `requested` | `{target, toolchain}` —— 问的是什么 | | `status` | `ok` 或 `refused` | | `reason` | 拒绝的记号,或 `none` | -| `compiler` | `{family, version, driver}` —— 真正会跑的驱动器 | +| `compiler` | `{family, version, driver, chosenBy}` —— 真正会跑的驱动器,以及为什么是它 | | `triple` | `{requested, toolchain, llvm}` | -| `cLibrary` | `{mode, path, origin}`;`mode` 取 `sysroot` / `payload-first` / `none`,`origin` 取 `payload` / `subos` / `host` / `none` | +| `cLibrary` | `{mode, path, origin, suppliesTarget}`;`mode` 取 `sysroot` / `payload-first` / `none`,`origin` 取 `payload` / `subos` / `host` / `none` | | `layers[]` | 目标侧五层:`{layer, interface, impl, origin, subset}` | +⭐ **`compiler.chosenBy` 回答「为什么是它」。** `{origin, requiredBy, replaced}` +—— `origin` 与构建状态行用的是同一句话(`[toolchain] in mcpp.toml`、 +`your default`、`target default`、`required by the dependency graph`、 +`first-run default`)。当某条 `requires = ["mcpp:compiler=…"]` 做了决定时, +`requiredBy` 点名那个包,`replaced` 点名被顶掉的那个 spec;没有发生时两者都为空。 + +```jsonc +"compiler": { "family": "clang", "version": "22.1.8", "driver": "…/clang++", + "chosenBy": { "origin": "required by the dependency graph", + "requiredBy": "openkal-llvm-runtime@0.1.3", + "replaced": "gcc@16.1.0" } } +``` + +没有它,要问「为什么」的消费方只能去解析状态行 —— 而那正是这份文档存在的理由所要 +消除的字符串匹配。 + +⚠️ **`cLibrary` 与 `layers[].c-abi` 回答的是两个问题,`suppliesTarget` 说明哪一个 +管用。** `cLibrary` 描述的是**载荷**的链接模型 —— 一份由载荷供给的 C 库会用到的 +搜索路径。`layers[].c-abi` 描述的是**这次构建**。当依赖供给 C 库时两者分叉,而在 +`suppliesTarget` 之前,同一份文档同时报出两者却没有任何字段说明该信哪个: + +```jsonc +"cLibrary": { "origin": "payload", "path": "…/xim-x-glibc/2.44/lib64", + "suppliesTarget": false }, // ← 新增;载荷并不在产物里 +"layers": [ { "layer": "c-abi", "interface": "musl", + "impl": "openkal-musl@0.3.5", "origin": "graph" } ] +``` + +是**新增一个字段**而不是给 `cLibrary` 改名或给 `mode` 加取值,因为 §6 承诺字段 +只增不删、且一个字段的含义永不改变。 + ⭐ **`reason` 是一个记号,不是一句话。** 拒绝的消息仍然写给人看,仍然点名目标、 规则与出路;而一个要给结果分类的程序读 `reason`: | `reason` | | |---|---| +| `unknown-target` | 这个拼写不指向任何一行,`(arch, os)` 也没有对应的组 | +| `ambiguous-request` | 该 `(arch, os)` 有多行受支持,而词法默认不在其中 | +| `compiler-requirement-conflict` | 图要求的编译器在这里用不了 | | `tier-planned` | 词表里有这一行,还没有任何东西接线 | | `host-cannot-serve` | 本机没有载荷,依赖也没有供给这个系统 | | `capability-pin` | 这一行的工具链是能力陈述,不是偏好 | diff --git a/docs/zh/16-the-target-triple.md b/docs/zh/16-the-target-triple.md index 0e8a32fa..80f696b6 100644 --- a/docs/zh/16-the-target-triple.md +++ b/docs/zh/16-the-target-triple.md @@ -59,6 +59,7 @@ LLVM 词表里「非 MSVC 的那套 ABI」的标签,继承自 MinGW,而 clang mcpp build --target x86_64-linux # = x86_64-linux-gnu mcpp build --target x86_64-windows # = x86_64-windows-gnu mcpp build --target riscv64-none # = riscv64-none-elf +mcpp build --target aarch64-linux # = aarch64-linux-musl mcpp build --target aarch64-macos # macOS 本来就没有这一段可省 ``` @@ -69,6 +70,39 @@ mcpp build --target aarch64-macos # macOS 本来就没有这一段可省 也是请求 —— 请求必须能什么都不说;mcpp 两者都保留:为身份填上那一段, 同时记住这次填充是一次填充。 +### 补全取自词表,不取自一个固定的词 + +上面第四行正是这两种角色必须分开的理由。把 `aarch64-linux` 按词法填成 +`aarch64-linux-gnu`,而那一行是 `planned` —— `aarch64-linux-musl` 才是 +`verified`。2026.8.26.2 之前,tier 闸问的是填充后的值: + +``` +$ mcpp build --target aarch64-linux + error: target 'aarch64-linux-gnu' is registered but not yet supported (planned) +$ mcpp build --target aarch64-linux-musl + Finished dev [unoptimized + debuginfo] in 0.99s +``` + +被问的问题是「aarch64 的 Linux」。被回答的问题是「aarch64-linux-**gnu**」, +而报错引用的三元组在那条命令里根本不存在。`riscv64-linux` 更严重:填充产生的 +那一行完全不在词表里,于是一个**已登记**的目标族被报成 `unknown target`。 + +省略了这一段的请求,按下列顺序对着已知目标表补全: + +1. 词法默认命中一个受支持的行 —— 用它(`x86_64-linux` → `gnu`); +2. 该 `(arch, os)` 下恰好一个受支持的行 —— 用它(`aarch64-linux` → `musl`); +3. 一个受支持的都没有 —— 保留词法形式,并对着**确实存在**的那些行给出诊断 + (`riscv64-linux` → 「planned;该系统已登记的行:`riscv64-linux-musl`」); +4. 多个受支持而词法默认不在其中 —— 拒绝并列出候选。今天没有任何 + `(arch, os)` 是这个形状。 + +规则 1 排在最前,使这件事能自己退休:`aarch64-linux-gnu` 从 `planned` 升级的 +那一天,词法答案重新胜出,不需要有人回来改任何东西。 + +**写出这一段就是退出补全。** 写出来的段是请求而不是空缺,因此 +`--target aarch64-linux-gnu` 仍会撞上 `planned` 行的拒绝 —— 那正是用显式 +`[target.] toolchain` 提前加入某一行的逃生口。 + ### 该用哪种拼法 **在构建期体系下,省略它。** 图供给 C 库与各运行时,那一段陈述的是一个 diff --git a/mcpp.toml b/mcpp.toml index a16bdb54..298dbc79 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.26.1" +version = "2026.8.26.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index fea2d783..b7e60d9c 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -36,6 +36,10 @@ import mcpp.toolchain.dialect; import mcpp.toolchain.fingerprint; import mcpp.toolchain.msvc; import mcpp.toolchain.registry; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.toolchain.lifecycle; import mcpp.toolchain.stdmod; import mcpp.freestanding.target; // the target sysroot layout (libdir) import mcpp.freestanding.linkline; // the ISA profile, for the std module command @@ -457,6 +461,7 @@ export enum class TcOrigin { TargetSection, // mcpp.toml [target.X].toolchain — user explicit GlobalDefault, // `mcpp toolchain default` — user explicit TargetPin, // triple.cppm vocabulary convention + GraphRequirement, // `requires = ["mcpp:compiler=…"]` in the graph FirstRun, // chosen and persisted by this very invocation }; @@ -485,6 +490,24 @@ export inline bool tc_origin_is_user_explicit(TcOrigin o) { return o == TcOrigin::ManifestToolchain || o == TcOrigin::TargetSection; } +// ⚠️⚠️ MAY A BUILD THAT RESOLVED THIS WAY WRITE THE MACHINE'S DEFAULT? +// +// `GraphRequirement` is the one origin that must not: it is a property of a +// package this project depends on, not of this machine. Two branches persist a +// default — the Windows first-run diversion, whose condition is +// `tcSpec.has_value()`, and the MSVC repair, whose gate is "mcpp chose this +// itself" — and a compiler chosen by `requires = ["mcpp:compiler=…"]` satisfies +// both. Measured against the design rather than a run, because it needs a +// Windows box with no toolchain: a bare machine building ONE llvm-requiring +// project would have handed llvm to every later project that asked for nothing. +// +// ⭐ NAMED RATHER THAN SPELLED INLINE AT EACH SITE. There are two today; the +// third would be written by someone who never read this note, and a predicate +// with a name is something they can find. +export inline bool tc_origin_may_persist(TcOrigin o) { + return o != TcOrigin::GraphRequirement; +} + // How a resolution came about, for the status line. A convention that replaced // nothing needs no explanation; one that replaced a user's stated preference is // a decision the user did not make and must be told about. @@ -494,6 +517,7 @@ export constexpr std::string_view tc_origin_name(TcOrigin o) { case TcOrigin::TargetSection: return "[target.] in mcpp.toml"; case TcOrigin::GlobalDefault: return "your default"; case TcOrigin::TargetPin: return "target default"; + case TcOrigin::GraphRequirement: return "required by the dependency graph"; case TcOrigin::FirstRun: return "first-run default"; case TcOrigin::None: break; } @@ -575,6 +599,19 @@ export struct BuildContext { // whether a cached build.ninja was generated for the profile being asked // for — and so `Finished ` stops being a hardcoded "release". std::string profile; + // ⭐ WHY THIS COMPILER — carried so the QUERY can answer it too. + // + // A build says so on its status line. `mcpp why toolchain --format json` + // exists precisely to answer "what would this resolve to, and why", and a + // consumer that had to parse the prose to learn that a dependency chose the + // compiler would be doing the substring matching the machine interface was + // introduced to remove. + struct CompilerChoice { + std::string origin; // tc_origin_name(): who decided + std::string requiredBy; // the package, when the graph decided + std::string replaced; // the spec displaced, when one was + }; + CompilerChoice compilerChoice; // Resolved global-cache mode. Read side is honored in prepare_build; write // side in run_build_plan. CacheMode cacheMode = CacheMode::Global; @@ -916,6 +953,15 @@ prepare_build(bool print_fingerprint, // learned by experiment — writing the same value a second time in // `[target.]` and observing that it works. std::string pinReplacedDefault; + // ⭐ THE PACKAGE WHOSE `requires` CHOSE THE COMPILER, AND WHAT IT ASKED FOR. + // + // Non-empty only when the graph's requirement actually changed the answer. + // Reported on the status line for the same reason `pinReplacedDefault` is: + // a compiler the user did not name is a decision they did not make, and one + // reported without its reason is a rule learned by experiment. + std::string graphCompilerRequiredBy; // "openkal-llvm-runtime@0.1.3" + std::string graphCompilerFamily; // "llvm" + std::string graphCompilerReplaced; // the spec it displaced, for the line // The C library the target triple asked for, taken before the triple is // canonicalised. Empty when the project declined to name one. std::string requestedCAbi; @@ -1513,8 +1559,35 @@ prepare_build(bool print_fingerprint, // override and the vocabulary-table convention (pin + default linkage). if (!overrides.target_triple.empty()) { namespace triple = mcpp::toolchain::triple; + // ⚠️ THE SPELLING THE PROJECT WROTE, KEPT FOR EVERY DIAGNOSTIC BELOW. + // `overrides.target_triple` is canonicalised further down, and until + // this variable existed the refusals quoted the canonical form: + // `--target aarch64-linux` produced "target 'aarch64-linux-gnu' is + // registered but not yet supported", a string the reader never typed + // and cannot find in their own command. + const std::string requestedSpelling = overrides.target_triple; auto parsed = triple::parse(overrides.target_triple); + // ⚠️⚠️ THE REQUEST IS COMPLETED FROM THE VOCABULARY BEFORE ANYTHING + // READS IT, AND THE ORDER RELATIVE TO THE `[target.X]` LOOKUP IS PART + // OF THE CONTRACT. + // + // `parse` fills a missing env segment lexically so the identity stays + // total — `x86_64-linux` IS `x86_64-linux-gnu`, and a unit test says so. + // Every gate below then asked about the filled value instead of about + // the request. See `triple::resolve_request` for the two measurements. + // + // The lookup that follows keys on `parsed->str()`, so completing after + // it would match sections against a triple this build is not going to + // use. A project wanting the `planned` row keeps its escape hatch by + // WRITING the segment: `--target aarch64-linux-gnu` skips completion + // entirely, because a written segment is a request rather than a gap. + triple::RequestResolution req; + if (parsed) { + req = triple::resolve_request(*parsed); + parsed = req.triple; + } + // [target.X] lookup is spelling-independent: a section keyed // `x86_64-w64-mingw32` matches `--target x86_64-windows-gnu` and // vice versa. Unparseable keys/inputs compare exactly (escape hatch). @@ -1537,23 +1610,79 @@ prepare_build(bool print_fingerprint, // toolchain (the worst failure mode — you think you cross-compiled). // An explicit [target.X] section is the escape hatch for custom // triples outside the vocabulary. + // Several rows serve this (arch, os) and the lexical default names none + // of them, so there is nothing to complete the request WITH. Refusing + // and listing them is the only honest answer; picking one would be an + // invented convention. No group has this shape today — the rule is here + // so the first one that does gets a diagnosis rather than a guess. + if (parsed && req.ambiguous && !hasExplicitSection) { + std::string opts; + for (auto s : req.supported) { + if (!opts.empty()) opts += ", "; + opts += std::string(s); + } + refusal::record(refusal::Code::AmbiguousRequest); + return std::unexpected(std::format( + "target '{}' does not say which C library, and several are " + "supported here.\n" + " candidates: {}\n" + " Name one of them.", + requestedSpelling, opts)); + } if (!known && !hasExplicitSection) { - auto sug = triple::did_you_mean(overrides.target_triple); + // ⚠️ "UNKNOWN" IS A CLAIM ABOUT THE VOCABULARY, AND IT WAS FALSE FOR + // A WHOLE arch+os FAMILY. + // + // Measured on 2026.8.26.1: `--target riscv64-linux` reported + // `unknown target 'riscv64-linux'` while `riscv64-linux-musl` was + // sitting in `kKnownTargets` as `planned`. The lexical fill had + // produced `riscv64-linux-gnu` — a row that genuinely does not + // exist — and the gate reported on the fill. + // + // A non-empty sibling group means the family IS registered, so this + // is the planned refusal wearing the wrong word. It names the row + // that exists, which is also the one the reader would have to write + // to opt in. + if (!req.siblings.empty()) { + std::string rows; + for (auto s : req.siblings) { + if (!rows.empty()) rows += ", "; + rows += std::string(s); + } + refusal::record(refusal::Code::TierPlanned); + return std::unexpected(std::format( + "target '{}' is registered but not yet supported (planned) — " + "no toolchain is published for it yet.\n" + " registered rows for this system: {}\n" + " An explicit [target.] toolchain override can " + "opt in early.", + requestedSpelling, rows)); + } + auto sug = triple::did_you_mean(requestedSpelling); + refusal::record(refusal::Code::UnknownTarget); return std::unexpected(std::format( "unknown target '{}'{}\n" " known targets: `mcpp toolchain list`; a custom triple needs an\n" " explicit [target.{}] section in mcpp.toml", - overrides.target_triple, + requestedSpelling, sug ? std::format(" — did you mean '{}'?", *sug) : "", - overrides.target_triple)); + requestedSpelling)); } if (known && known->tier == "planned" && !hasToolchainOverride) { refusal::record(refusal::Code::TierPlanned); + // The subject is what the user wrote. When completion filled a + // segment, both are shown — otherwise the sentence is about a + // string that appears nowhere in their command. + const std::string subject = + requestedSpelling == parsed->str() + ? std::format("'{}'", requestedSpelling) + : std::format("'{}' (which resolves to '{}')", + requestedSpelling, parsed->str()); return std::unexpected(std::format( - "target '{}' is registered but not yet supported (planned) — " + "target {} is registered but not yet supported (planned) — " "no toolchain is published for it yet.\n" " An explicit [target.{}] toolchain override can opt in early.", - parsed->str(), parsed->str())); + subject, parsed->str())); } // Known, supported — and IMPOSSIBLE ON THIS HOST. // @@ -1992,7 +2121,21 @@ prepare_build(bool print_fingerprint, // make, and a status line that reports the outcome without the // reason leaves them to discover the rule by experiment. std::string chosenBy; - if (!pinReplacedDefault.empty()) + // ⭐ A COMPILER THE GRAPH ASKED FOR IS ANNOUNCED WITH THE PACKAGE + // THAT ASKED. Without the name this reads as mcpp ignoring the + // user's default; with it, it reads as the dependency it is. + // The second line appears only when something was displaced — + // "replacing nothing" is not worth a line. + if (!graphCompilerRequiredBy.empty()) + chosenBy = std::format( + "\n required by {} (`requires = " + "[\"mcpp:compiler={}\"]`){}", + graphCompilerRequiredBy, graphCompilerFamily, + graphCompilerReplaced.empty() + ? std::string{} + : std::format(", not your {} — this project only", + graphCompilerReplaced)); + else if (!pinReplacedDefault.empty()) chosenBy = std::format( "\n target default for {}, replacing your " "{} — override with `[target.{}] toolchain`", @@ -2197,7 +2340,19 @@ prepare_build(bool print_fingerprint, // `mcpp toolchain list` shows the same pair the build actually used. // Persisting only the target would leave the toolchain axis implicit // (derived from the vocabulary pin) and the two views would disagree. - if (windowsGnuFirstRun && tcSpec.has_value()) { + // + // ⚠️⚠️ NOT WHEN THE DEPENDENCY GRAPH SUPPLIED THE ANSWER. This branch's + // condition is `tcSpec.has_value()`, and since 2026.8.26.2 a package's + // `requires = ["mcpp:compiler=…"]` can be what made it true — so a bare + // Windows box building ONE project with an llvm-requiring dependency + // would have persisted llvm as the MACHINE's default, and the next + // project, which asked for nothing, would inherit it. + // + // A requirement is a property of the package that states it. It decides + // this build and nothing else; the first-run answer for the machine is + // still the one this branch was written for. + if (windowsGnuFirstRun && tcSpec.has_value() + && tc_origin_may_persist(tcOrigin)) { mcpp::ui::info("First run", std::format("no toolchain configured and no Visual Studio found — " "using {} for {} (MinGW-w64, self-contained)", @@ -2481,8 +2636,16 @@ prepare_build(bool print_fingerprint, // honoured exactly. const bool userChoseMsvcItself = tc->compiler == mcpp::toolchain::CompilerId::MSVC; + // ⚠️ AND NOT A COMPILER THE GRAPH REQUIRED. The repair below rewrites + // the machine's default to winlibs GCC, which is right when mcpp's + // own default cannot work here. A family a package REQUIRED is not + // mcpp's default to revise: switching to gcc would satisfy nothing — + // `check_requirements` refuses the build three thousand lines later — + // while having changed the user's configuration on the way there. + // Refusing at the decision is what the rest of this release is about. const bool mayRepair = !tc_origin_is_user_explicit(tcOrigin) + && tc_origin_may_persist(tcOrigin) && !userChoseMsvcItself && !mcpp::platform::env::offline_mode() && !mcpp::platform::env::no_auto_install() @@ -5070,6 +5233,116 @@ prepare_build(bool print_fingerprint, // the answer's shape, so it reads the manifests rather than resolving them. { bool graphSuppliesSystem = false; + // ⭐⭐ `requires` IS READ HERE TOO, AND UNTIL THIS LOOP IT WAS ONLY EVER + // CHECKED — A THOUSAND LINES LATER, AGAINST A DECISION THIS BLOCK HAD + // ALREADY MADE WITHOUT IT. + // + // `provides` and `requires` are the two halves of one vocabulary and + // they were read at opposite ends of the function: this block consulted + // the first to decide the compiler, and `check_requirements` used the + // second only to reject the outcome. Measured on 2026.8.26.1, one + // three-line manifest, `llvm@22.1.8` already installed: + // + // [dependencies] + // openkal-llvm-runtime = "0.1.3" # requires mcpp:compiler=llvm + // + // $ mcpp build # global default gcc@16.1.0 + // error: `openkal-llvm-runtime@0.1.3` requires the compiler to be `llvm`. + // Select that compiler … mcpp toolchain default llvm + // $ MCPP_TOOLCHAIN=llvm@22.1.8 mcpp build + // Finished dev [unoptimized + debuginfo] in 1.02s + // + // Nothing was missing. The engine knew which compiler was wanted, the + // payload was on the machine, and the remedy it printed was to change + // the default for EVERY project on the box because ONE project's + // dependency asked. + // + // ⚠️ AND THIS IS THE PLACE, NOT MERELY *A* PLACE. `resolve_target_toolchain` + // has exactly two call sites — its own one-shot recursion, and the one + // at the bottom of this block — so every branch inside it, INCLUDING the + // first-run install-and-persist path and all three + // `write_default_toolchain` calls, is downstream of this line. Setting + // `tcSpec` here therefore selects the compiler without writing anything: + // on a machine with no toolchain at all the first-run branch is not even + // reached, because its condition is `!tcSpec.has_value()`. + // + // That is the whole design. "Do not touch the user's configuration" is + // not a rule anyone has to remember here — the writes live on a branch + // this no longer enters. + std::string reqCompiler, reqCompilerBy; + + // ⭐ A FAMILY NAME BECOMES A CONCRETE SPEC THE SAME WAY IT DOES FOR + // `mcpp toolchain default `, AND FOR THE SAME REASON. + // + // `requires = ["mcpp:compiler=llvm"]` names a family; the build path + // needs `@` and refuses anything else + // (`expected '@'`). There are two honest sources for the + // missing half and they are tried in this order: + // + // 1. what is already installed — highest version wins, nothing is + // downloaded, and it is literally the same two functions + // `toolchain_set_default` calls; + // 2. the vocabulary's own pins — the version this ecosystem ships for + // that family, already written down once per row. Deriving it from + // there rather than from a fresh constant means the answer moves + // when the ecosystem moves, with nobody having to remember a + // second place. + // + // ⚠️ NOT `pins::kFirstRun*`. Those are per-HOST first-run defaults — + // `llvm@20.1.7` on macOS, `gcc@16.1.0` on Linux x86_64 — so reading them + // would make the version a package requires depend on which machine + // built it. A requirement is a property of the package. + auto resolve_required_family = + [&](const std::string& family) + -> std::expected { + auto spec = mcpp::toolchain::parse_toolchain_spec(family); + if (!spec) { + refusal::record(refusal::Code::CompilerRequirementConflict); + return std::unexpected(std::format( + "`{}` requires the compiler to be `{}`, and mcpp has no " + "compiler family by that name.\n" + " known families: gcc, llvm, msvc.", + reqCompilerBy, family)); + } + + if (auto cfg = get_cfg(); cfg) { + auto pkg = mcpp::toolchain::to_xim_package(*spec); + if (auto picked = mcpp::toolchain::resolve_version_match( + "", mcpp::toolchain::list_installed_versions( + (*cfg)->xlingsHome() / "data" / "xpkgs", + pkg.ximName))) + return std::format("{}@{}", family, *picked); + } + + std::vector fromVocabulary; + for (auto const& row : mcpp::toolchain::triple::known_targets()) { + if (row.pin.empty()) continue; + auto p = mcpp::toolchain::parse_toolchain_spec( + std::string(row.pin)); + if (!p || p->version.empty()) continue; + if (mcpp::toolchain::family_name(p->family) != family) continue; + fromVocabulary.push_back(p->version); + } + if (auto picked = mcpp::toolchain::resolve_version_match( + "", std::move(fromVocabulary))) + return std::format("{}@{}", family, *picked); + + // Neither source has one. Saying which family and which two places + // were consulted is the difference between an actionable message + // and "something went wrong". + // ⚠️ RECORDED, like every other refusal in this function. An + // unnamed branch reports `other`, and this release exists partly + // because one of those had a perfectly good name. + refusal::record(refusal::Code::CompilerRequirementConflict); + return std::unexpected(std::format( + "`{}` requires the compiler to be `{}`, and mcpp has no version " + "of it to use.\n" + " none is installed, and no target row pins one.\n" + " install one — `mcpp toolchain install {} ` " + "(`mcpp toolchain list --available {}`).", + reqCompilerBy, family, family, family)); + }; + for (auto const& pkg : packages) { for (auto const& entry : pkg.manifest.provides) { auto cap = mcpp::targetside::parse_capability(entry); @@ -5079,6 +5352,41 @@ prepare_build(bool print_fingerprint, graphSuppliesSystem = true; } } + for (auto const& entry : pkg.manifest.requires_) { + auto cap = mcpp::targetside::parse_capability(entry); + if (!cap || !*cap) continue; + if ((*cap)->layer != mcpp::targetside::CapLayer::Compiler) continue; + // A bare `mcpp:compiler` asks only that one exist, which it + // always does. Only a named family selects anything. + if ((*cap)->interfaceName.empty()) continue; + const auto pkgId = pkg.manifest.package.version.empty() + ? pkg.manifest.package.name + : std::format("{}@{}", pkg.manifest.package.name, + pkg.manifest.package.version); + // ⚠️ TWO DIFFERENT FAMILIES IS AN ERROR RATHER THAN A PICK, the + // same rule `provides` already follows one screen down. Choosing + // by graph-traversal order would make the answer depend on an + // order the author neither writes nor can predict — and unlike a + // conflicting `provides`, this one would silently satisfy one + // package's requirement and fail the other's inside a header. + if (!reqCompiler.empty() && reqCompiler != (*cap)->interfaceName) { + refusal::record(refusal::Code::CompilerRequirementConflict); + return std::unexpected(std::format( + "two packages require different compilers, and a build " + "has only one.\n" + " {:<28} requires `{}`\n" + " {:<28} requires `{}`\n" + " Both cannot hold. Drop one of them, or take a " + "version of one that is\n" + " configured for the other's compiler.", + reqCompilerBy, reqCompiler, pkgId, + (*cap)->interfaceName)); + } + if (reqCompiler.empty()) { + reqCompiler = (*cap)->interfaceName; + reqCompilerBy = pkgId; + } + } } // ⚠️ AND A FREESTANDING PIN SURVIVES IT. `graphSuppliesSystem` spans // kernel-abi and c-abi, and it correctly cancels a HOSTED row's @@ -5106,6 +5414,132 @@ prepare_build(bool print_fingerprint, tcSpec = targetPinCandidate; tcOrigin = TcOrigin::TargetPin; } + + // ⭐⭐ THE GRAPH'S REQUIREMENT, TAKEN AS AN INSTRUCTION RATHER THAN AS A + // TEST TO FAIL LATER. + // + // Everything above this line decides the compiler from what mcpp knows + // about the TARGET. A package saying `requires = ["mcpp:compiler=llvm"]` + // is saying something about ITSELF — its C++ runtime was configured for + // one family and its headers record that configuration — and it is the + // most specific statement in the build. Below the user's own word, above + // every default mcpp keeps. + // + // ⚠️ THE RANK IS NOT NEW. `TcOrigin` already sorts these, and + // `tc_origin_is_user_explicit` already answers "may mcpp revise this". + // The defect was never that the answer was wrong; it was that nobody + // asked. `GlobalDefault` is deliberately not user-explicit — see the + // note on that function — so a remembered default is exactly the kind of + // value this may replace. + // ⚠️ `system` IS LEFT ALONE, AND IT IS THE ONE VALUE HERE THAT IS AN + // ESCAPE HATCH RATHER THAN AN ANSWER. + // + // It means "the PATH compiler, whatever it is" — a deliberate opt-out + // of the payload model. Substituting a payload for it would defeat + // exactly what the user asked for, and mcpp cannot even tell whether + // the requirement is already satisfied: the family of a PATH compiler + // is not knowable from the spec. `check_requirements` reports the + // mismatch further down against what the driver actually turned out to + // be, which is the only place that answer exists. + const bool tcIsSystemEscapeHatch = + tcSpec.has_value() && *tcSpec == "system"; + if (!reqCompiler.empty() && !tcIsSystemEscapeHatch) { + std::string haveFamily; + if (tcSpec.has_value()) + if (auto s = mcpp::toolchain::parse_toolchain_spec(*tcSpec); s) + haveFamily = + std::string(mcpp::toolchain::family_name(s->family)); + + if (haveFamily != reqCompiler) { + // ⚠️ THE PROJECT'S OWN WORD IS NOT REVISED, AND THIS IS THE ONLY + // CASE THAT STILL REFUSES. `[toolchain]`, `[target.X].toolchain` + // and `MCPP_TOOLCHAIN` are statements about THIS build; the + // graph disagreeing with one of them is a real contradiction and + // `check_requirements` reports it further down with both names. + // Nothing to do here but leave the value alone. + if (tc_origin_is_user_explicit(tcOrigin)) { + // fall through to check_requirements + } + // ⚠️⚠️ A ROW'S PIN THAT SURVIVED TO HERE CANNOT BE OVERRIDDEN BY + // A REQUIREMENT, AND THE REASON IS THE SAME ONE THE PIN EXISTS + // FOR. + // + // The block above applied it only when the graph does NOT supply + // the system, or when the row names a capability. In the first + // case the row's payload is what carries this target's headers + // and C library, and a different compiler brings none — measured + // as `crtbeginT.o (bare name)` and as a host `crtbegin.o`, both + // accurate about the symptom and silent about the decision. In + // the second the row names the only compiler that emits the + // target at all. + // + // Either way the requirement cannot be honoured, and saying so + // here — where both halves are known — beats a compiler + // complaining about a file the reader never named. + else if (tcOrigin == TcOrigin::TargetPin) { + // ⚠️⚠️ THE TWO ROWS REFUSE UNDER ONE RULE AND FOR TWO + // REASONS, AND ONE REMEDY DOES NOT SERVE BOTH. + // + // A CONVENTION pin is cancelled by a graph that supplies the + // target's system — that is `graphSuppliesSystem`, one + // screen up — so "depend on a package that supplies it" is + // exactly the way out. + // + // A CAPABILITY pin is not: `targetPinIsCapability` keeps it + // applied no matter what the graph supplies, because no + // other family emits the target at all. Offering the same + // remedy there prints an instruction that the sentence + // directly above it has already ruled out — the failure + // this release removes from `check_requirements`, reproduced + // three screens away. + std::string_view why = targetPinIsCapability + ? "The row names its compiler as a capability: no other " + "family emits this target." + : "The row's payload is what supplies this target's " + "headers and C library,\n and nothing in the " + "dependency graph supplies them instead."; + std::string remedy = targetPinIsCapability + ? std::format( + " Drop the package that requires `{}`, or " + "take a version of it built\n" + " for `{}`.", + reqCompiler, targetPinCandidate) + : std::format( + " Depend on a package that supplies this " + "target's system (its kernel\n" + " interface and C library) so the row's " + "payload is not needed, or drop\n" + " the package that requires `{}`.", + reqCompiler); + refusal::record(refusal::Code::CompilerRequirementConflict); + return std::unexpected(std::format( + "`{}` requires the compiler to be `{}`, and target '{}' " + "cannot be built with it here.\n" + " target row {:<14} ({})\n" + " required {:<14} (required by {})\n" + " {}\n{}", + reqCompilerBy, reqCompiler, + targetRowName.empty() ? overrides.target_triple + : targetRowName, + targetPinCandidate, + targetPinIsCapability ? "capability" : "convention", + reqCompiler, reqCompilerBy, + why, remedy)); + } + // Free to take it. `tcSpec` is either absent (nothing configured + // anywhere) or one of mcpp's own remembered answers. + else { + auto pickedSpec = resolve_required_family(reqCompiler); + if (!pickedSpec) + return std::unexpected(pickedSpec.error()); + graphCompilerReplaced = tcSpec.value_or(""); + graphCompilerRequiredBy = reqCompilerBy; + graphCompilerFamily = reqCompiler; + tcSpec = *pickedSpec; + tcOrigin = TcOrigin::GraphRequirement; + } + } + } // ⚠️⚠️ OVERRIDING THE CONVENTION IS ALLOWED; OVERRIDING IT AND SUPPLYING // NOTHING IN ITS PLACE IS NOT, AND UNTIL THIS BLOCK IT LOOKED THE SAME. // @@ -6286,7 +6720,13 @@ prepare_build(bool print_fingerprint, // runtime configured for one compiler family being handed to another — // otherwise fails inside that runtime's own headers, in a message that // names a file the reader has never opened and no decision mcpp made. - if (auto why = tsd::check_requirements(resolvedTargetSide, requirements)) { + // The origin travels with the check: reaching a compiler-layer refusal + // now means the project stated its own compiler, and the remedy has to + // name that statement rather than a global default it is not using. + if (auto why = tsd::check_requirements( + resolvedTargetSide, requirements, + tc_origin_is_user_explicit(tcOrigin) ? tc_origin_name(tcOrigin) + : std::string_view{})) { refusal::record(refusal::Code::LayerRequirement); return std::unexpected(*why); } @@ -6953,6 +7393,10 @@ prepare_build(bool print_fingerprint, ctx.runtimeSelection = runtimeSelection; ctx.runtimeBinding = runtimeBindingSnapshot; ctx.profile = effectiveProfile; + ctx.compilerChoice = { std::string(tc_origin_name(tcOrigin)), + graphCompilerRequiredBy, + graphCompilerReplaced.empty() ? pinReplacedDefault + : graphCompilerReplaced }; ctx.cacheMode = cacheMode; ctx.projectRoot= *root; ctx.outputDir = target_dir(*tc, fp, workRoot); diff --git a/src/build/refusal.cppm b/src/build/refusal.cppm index 41ddc826..73901ed3 100644 --- a/src/build/refusal.cppm +++ b/src/build/refusal.cppm @@ -41,6 +41,14 @@ export namespace mcpp::build::refusal { // rather than a silent merge into a neighbouring reason. enum class Code { None, // no refusal + UnknownTarget, // the spelling names no row, and no (arch, os) group + AmbiguousRequest, // several rows serve (arch, os) and none is the default + // The compiler a package requires cannot be used for this build: the + // project stated a different one, two packages disagree, the target row + // names a family that is the only one able to emit it, or no version of the + // required family exists to use. One code, because what a consumer does + // about all four is the same — read the message. + CompilerRequirementConflict, TierPlanned, // the row exists in the vocabulary, nothing is wired HostCannotServe, // no payload here, and no graph supplied the system CapabilityPin, // the row's toolchain is a capability, not a preference @@ -57,6 +65,10 @@ enum class Code { constexpr std::string_view name(Code c) { switch (c) { case Code::None: return "none"; + case Code::UnknownTarget: return "unknown-target"; + case Code::AmbiguousRequest: return "ambiguous-request"; + case Code::CompilerRequirementConflict: + return "compiler-requirement-conflict"; case Code::TierPlanned: return "tier-planned"; case Code::HostCannotServe: return "host-cannot-serve"; case Code::CapabilityPin: return "capability-pin"; diff --git a/src/doctor.cppm b/src/doctor.cppm index e5f5aa84..acbc8a15 100644 --- a/src/doctor.cppm +++ b/src/doctor.cppm @@ -886,10 +886,20 @@ export int why_toolchain_json(std::string_view target, std::string_view tcSpec) data["status"] = "ok"; data["reason"] = "none"; + // ⭐ AND WHY THIS ONE. The build's status line says it; a consumer of the + // machine interface asking "what would this resolve to, and why" would + // otherwise have to parse that prose — the substring matching this document + // exists to remove. `requiredBy` and `replaced` are empty unless something + // was required and something was displaced. data["compiler"] = { {"family", std::string(tc.compiler_name())}, {"version", tc.version}, {"driver", tc.binaryPath.string()}, + {"chosenBy", { + {"origin", ctx->compilerChoice.origin}, + {"requiredBy", ctx->compilerChoice.requiredBy}, + {"replaced", ctx->compilerChoice.replaced}, + }}, }; data["triple"] = { {"requested", std::string(target)}, @@ -920,12 +930,42 @@ export int why_toolchain_json(std::string_view target, std::string_view tcSpec) const auto& srPath = lm.mode == mcpp::toolchain::CLibMode::Sysroot ? lm.sysroot : lm.crtDir; + // ⚠️⚠️ AND WHETHER THIS MODEL IS THE ONE IN THE ARTIFACT, BECAUSE THE + // DOCUMENT USED TO ANSWER THE SAME QUESTION TWICE AND DIFFERENTLY. + // + // Measured on 2026.8.26.1, one `mcpp why toolchain --format json` over an + // openkal project: + // + // "cLibrary": { "origin": "payload", "path": "…/xim-x-glibc/2.44/lib64" } + // "layers": [ { "layer": "c-abi", "interface": "musl", + // "impl": "openkal-musl@0.3.5", "origin": "graph" } ] + // + // The artifact settles it — statically linked, no interpreter, no + // `DT_NEEDED`, eleven openkal symbols — so glibc is not in it. Both fields + // were accurate about different questions: `cLibrary` describes the + // PAYLOAD's link model (the search paths a payload-supplied C library would + // use), `layers[].c-abi` describes the BUILD. A consumer had no way to tell + // which one governed, which is the same defect as a name that contradicts a + // fact printed under it. + // + // ⭐ ONE FIELD ADDED, NONE CHANGED. `mcpp.why.toolchain` promises that + // fields are added and never removed and that a field's meaning never + // changes (docs/11 §6). Renaming `cLibrary`, or widening `mode` with a + // `graph` value, would break that promise for a document whose whole point + // is to be depended on. `suppliesTarget` says which of the two governs and + // sends the reader to the entry that does. + const bool payloadSuppliesCLib = + lm.mode != mcpp::toolchain::CLibMode::None + && ts.cAbi.origin != mcpp::targetside::Origin::Graph; data["cLibrary"] = { {"mode", lm.mode == mcpp::toolchain::CLibMode::Sysroot ? "sysroot" : lm.mode == mcpp::toolchain::CLibMode::PayloadFirst ? "payload-first" : "none"}, {"path", srPath.string()}, {"origin", std::string(path_origin(srPath))}, + // false = the graph supplies this build's C library; read the `c-abi` + // entry of `layers` for the one that is actually in the artifact. + {"suppliesTarget", payloadSuppliesCLib}, }; auto layer = [](std::string_view label, diff --git a/src/targetside/model.cppm b/src/targetside/model.cppm index 382b11a7..b6c83c23 100644 --- a/src/targetside/model.cppm +++ b/src/targetside/model.cppm @@ -582,8 +582,22 @@ inline std::optional check_layering(const TargetSide& ts) { // reported by naming both — which is what a reader needs and what an engine // hardcoding a table of families could not produce for a family it had not // heard of. +// ⚠️ `compilerStatedBy` NAMES WHERE THE COMPILER CAME FROM, AND THE ADVICE IS +// WRONG WITHOUT IT. +// +// Until 2026.8.26.2 a compiler requirement mcpp could satisfy by itself still +// refused, and the first remedy it offered was `mcpp toolchain default ` — +// a GLOBAL change, made because ONE project's dependency asked. Now the graph's +// requirement is applied wherever mcpp's own answer was revisable, so the only +// way to reach this branch is a compiler the project stated itself. In that +// situation the global default is not what is being used and changing it fixes +// nothing: the advice has to point at the statement that actually decided. +// +// Empty = the caller does not know (unit tests, and any future caller); the +// generic wording then applies. inline std::optional -check_requirements(const TargetSide& ts, std::span reqs) { +check_requirements(const TargetSide& ts, std::span reqs, + std::string_view compilerStatedBy = {}) { constexpr std::string_view kPad = " "; for (auto const& r : reqs) { // An entry with no `=` asks only that the layer be @@ -599,14 +613,24 @@ check_requirements(const TargetSide& ts, std::span reqs) { // every other layer by the dependency graph. std::string advice = r.layer == CapLayer::Compiler - ? std::format( - " Select that compiler — yours outranks mcpp's own " - "default:\n" - " mcpp toolchain default {}\n" - " or, for one target only:\n" - " [target.]\n" - " toolchain = \"{}\"", - r.interfaceName, r.interfaceName) + ? (compilerStatedBy.empty() + ? std::format( + " Select that compiler for this project:\n" + " [toolchain]\n" + " default = \"{}\"\n" + " or, for one target only:\n" + " [target.]\n" + " toolchain = \"{}\"", + r.interfaceName, r.interfaceName) + : std::format( + " This build's compiler is stated in {}, and a " + "compiler the project states\n" + " outranks one its dependencies ask for.\n" + " Change it to `{}`, or remove it — with nothing " + "stated, mcpp takes the\n" + " compiler the graph requires and changes no " + "configuration to do it.", + compilerStatedBy, r.interfaceName)) : std::format( " Depend on a package that declares `provides = " "[\"mcpp:{}={}\"]`,\n" diff --git a/src/toolchain/lifecycle.cppm b/src/toolchain/lifecycle.cppm index 646d0e89..9f535158 100644 --- a/src/toolchain/lifecycle.cppm +++ b/src/toolchain/lifecycle.cppm @@ -63,6 +63,12 @@ std::vector parse_version_components(std::string_view s) { // "15.1" → highest 15.1.Y // "15.1.0" → exact match (or empty if not present) // Empty result = no match. +// ⭐ EXPORTED SO THAT "WHICH VERSION OF THIS FAMILY" HAS ONE ANSWER. +// `mcpp toolchain default llvm` resolves a bare family through these two, and +// `prepare_build` now has to answer the same question when the dependency graph +// asks for a compiler family by name. A second implementation there would be +// the same decision derived twice — the shape this codebase keeps paying for. +export std::optional resolve_version_match(std::string_view partial, std::vector available) @@ -92,6 +98,7 @@ resolve_version_match(std::string_view partial, } // Enumerate installed `/xim-x-//` subdirs. +export std::vector list_installed_versions(const std::filesystem::path& pkgsDir, std::string_view ximName) diff --git a/src/toolchain/triple.cppm b/src/toolchain/triple.cppm index d009e61a..db1eb3e8 100644 --- a/src/toolchain/triple.cppm +++ b/src/toolchain/triple.cppm @@ -364,6 +364,97 @@ inline const TargetInfo* find_known_target(const Triple& t) { inline bool is_known_target(const Triple& t) { return find_known_target(t) != nullptr; } +// ── Completing a request that declined to name a C library ────────────────── +// +// ⚠️⚠️ `parse` FILLS THE ENV SEGMENT LEXICALLY, AND THE TIER GATE USED TO ASK +// ABOUT THE FILLED VALUE RATHER THAN ABOUT THE REQUEST. +// +// The fill is an IDENTITY operation and has to stay exactly as it is: total, +// lexical, and independent of the host (see the note on `Triple::envExplicit` +// and the one beside the fill itself). `x86_64-linux` is the identity +// `x86_64-linux-gnu` on every machine, and a unit test says so. +// +// What it is NOT is an answer to "does mcpp support this". Measured on +// 2026.8.26.1: +// +// $ mcpp build --target aarch64-linux +// error: target 'aarch64-linux-gnu' is registered but not yet supported +// $ mcpp build --target aarch64-linux-musl +// Finished dev [unoptimized + debuginfo] in 0.99s +// +// The question asked was "aarch64, Linux". The question answered was +// "aarch64-linux-GNU", and the error even quotes a triple the user never typed. +// The same fill sends `riscv64-linux` to `riscv64-linux-gnu`, a row that does +// not exist at all, so a registered target family is reported as UNKNOWN. +// +// This function is the request's own completion, applied only where a request +// is read and only when the segment was not written. It consults the vocabulary +// — compile-time data, therefore the same on every host, so target identity +// still does not depend on where the build ran. +// +// ⭐ RULE ONE MAKES THIS RETIRE ITSELF. When `aarch64-linux-gnu` graduates from +// `planned`, rule one matches first and the completion goes back to the lexical +// answer with nobody editing this function. +struct RequestResolution { + Triple triple; // the identity to use from here on + // The lexical fill was replaced by a row from the vocabulary. For the + // report: the user wrote one thing and mcpp resolved it to another. + bool completedFromVocabulary = false; + std::vector siblings; // every row sharing (arch, os) + std::vector supported; // of those, the ones not `planned` + // Several rows are supported and the lexical fill names none of them, so + // there is no basis to pick. No (arch, os) group has this shape today; the + // rule is written down so the first one does not get an invented answer. + bool ambiguous = false; +}; + +inline RequestResolution resolve_request(const Triple& parsed) { + RequestResolution r; + r.triple = parsed; + // A written segment is a request, not a gap: honour it, including when it + // names a `planned` row (the tier gate is what refuses that, and its + // subject is then genuinely what the user typed). + if (parsed.envExplicit || parsed.arch.empty() || parsed.os.empty()) + return r; + + const std::string prefix = parsed.arch + "-" + parsed.os; + for (auto& k : kKnownTargets) { + // Exact (macOS rows carry no env) or `arch-os-`. The separator + // check is what keeps a prefix from spanning two different OS names. + const bool exact = k.canonical == prefix; + const bool sub = k.canonical.size() > prefix.size() + && k.canonical.starts_with(prefix) + && k.canonical[prefix.size()] == '-'; + if (!exact && !sub) continue; + r.siblings.push_back(k.canonical); + if (k.tier != "planned") r.supported.push_back(k.canonical); + } + + const std::string lexical = parsed.str(); + for (auto s : r.supported) + if (s == lexical) return r; // rule 1: the fill is supported + + if (r.supported.size() == 1) { // rule 2: the only supported row + auto only = r.supported.front(); + r.triple.env = only.size() > prefix.size() + ? std::string(only.substr(prefix.size() + 1)) + : std::string{}; + // ⚠️ STILL NOT EXPLICIT. `envExplicit` records what the PROJECT asked + // for and feeds the C-library-request check; mcpp choosing a row is not + // the project naming a C library. Setting it here would make + // `check_request` compare mcpp's own answer against itself, and would + // print `aarch64-linux-musl` where the user wrote `aarch64-linux`. + r.completedFromVocabulary = true; + return r; + } + // rule 4 before rule 3: several supported rows and the fill names none. + if (r.supported.size() > 1) r.ambiguous = true; + // rule 3: nothing supported (empty group, or every row `planned`). Keep the + // lexical identity and let the caller diagnose from `siblings`, which is + // what lets the message name a row that actually exists. + return r; +} + // The effective target C library for one build. // // SINGLE READ POINT, and it is one because it was two. `prepare_build` derived diff --git a/src/version.cppm b/src/version.cppm index 62c6d062..46a2ab27 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.26.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.26.2"; } // namespace mcpp diff --git a/tests/e2e/281_target_side_rules.sh b/tests/e2e/281_target_side_rules.sh index 53a61fa7..357419f8 100755 --- a/tests/e2e/281_target_side_rules.sh +++ b/tests/e2e/281_target_side_rules.sh @@ -54,8 +54,25 @@ out="$(MCPP_TOOLCHAIN=gcc@16.1.0 "$MCPP" build 2>&1)" || rc=$? [[ "$rc" -ne 0 ]] || { echo "a requirement gcc does not meet was accepted:"; echo "$out"; exit 1; } grep -q "requires the compiler to be" <<< "$out" || { echo "the refusal does not name the requirement:"; echo "$out"; exit 1; } -grep -q "mcpp toolchain default" <<< "$out" || { +# ⚠️ THE NEXT STEP MUST BE ONE THAT WOULD ACTUALLY WORK, AND UNTIL 2026.8.26.2 +# THE FIRST ONE OFFERED WAS `mcpp toolchain default llvm`. +# +# That is a GLOBAL change — the default for every project on the machine — +# printed because ONE project's dependency asked. And since the graph's +# requirement is now applied wherever mcpp's own answer was revisable, the only +# way to reach this refusal is a compiler the project STATED (here, +# `MCPP_TOOLCHAIN`). In that situation the global default is not what is being +# used, so changing it fixes nothing: the advice has to name the statement that +# decided. +grep -q "remove it" <<< "$out" || { echo "the refusal names no next step:"; echo "$out"; exit 1; } +# ⚠️ `if`, NOT `grep … && { … }`. Under `set -e` a trailing `&&` list whose +# left side fails takes the script down — and here grep FAILING is the passing +# case. This repo has paid for that shape more than once. +if grep -q "mcpp toolchain default" <<< "$out"; then + echo "the refusal offers a global change that would not fix this failure:" + echo "$out"; exit 1 +fi # The evidence the decision rests on must be printed even though the compiler # layer comes from the payload and is suppressed in an ordinary report. grep -qE "^\s+compiler\s+gcc" <<< "$out" || { diff --git a/tests/e2e/299_a_request_that_named_no_c_library_resolves_to_a_row_that_exists.sh b/tests/e2e/299_a_request_that_named_no_c_library_resolves_to_a_row_that_exists.sh new file mode 100755 index 00000000..e04963e5 --- /dev/null +++ b/tests/e2e/299_a_request_that_named_no_c_library_resolves_to_a_row_that_exists.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# requires: unix-shell jq +# A target request that declines to name a C library resolves to a SUPPORTED row. +# +# ⭐⭐ `parse` FILLS THE ENV SEGMENT LEXICALLY, AND THE TIER GATE USED TO ASK +# ABOUT THE FILLED VALUE RATHER THAN ABOUT THE REQUEST. +# +# The fill exists so the IDENTITY stays total: `x86_64-linux` IS +# `x86_64-linux-gnu`, that is the output directory and the cache key, and a unit +# test pins it. What it is not is an answer to "does mcpp support this". +# +# ⚠️ MEASURED ON 2026.8.26.1, same machine, same graph, two spellings: +# +# $ mcpp build --target aarch64-linux +# error: target 'aarch64-linux-gnu' is registered but not yet supported +# $ mcpp build --target aarch64-linux-musl +# Finished dev [unoptimized + debuginfo] in 0.99s +# +# The question asked was "aarch64, Linux". The question answered was +# "aarch64-linux-GNU" — and the refusal quotes a triple the reader never typed. +# `examples/06-openkal-cross` teaches the short spelling for three platforms; +# the fourth was the one that could not be written. +# +# ⭐⭐ BOTH DIRECTIONS, BECAUSE "SEND EVERY BARE -linux TO musl" ALSO FIXES +# aarch64 AND WOULD BREAK EVERY PROJECT ON THE PLANET. Half two is the control: +# `x86_64-linux` must still be gnu, because gnu is a supported row there. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/src" +cd "$work" +printf '[package]\nname = "reqprobe"\nversion = "0.1.0"\n' > mcpp.toml +printf 'extern "C" int main(int, char**, char**) { return 0; }\n' > src/main.cpp + +# ⭐ CLASSIFICATION FROM THE MACHINE INTERFACE. `data.reason` is a finite token; +# a substring search over prose stops asserting the moment the prose is reworded +# — measured in this repo, twice, in one session. +reason_for() { + "$MCPP" why toolchain --target "$1" --format json 2>/dev/null \ + | jq -r '.data.reason // "-"' | tr -d '\r' +} + +# ⚠️⚠️ THE CLAIM IS ABOUT COMPLETION, NOT ABOUT SERVABILITY, AND ONLY ONE OF +# THOSE IS THE SAME ON EVERY BUILD HOST. +# +# `aarch64-linux-musl` pins the musl-gcc payload. macOS has no gcc payload at +# all, so this row legitimately answers `host-cannot-serve` there — a DIFFERENT +# question, correctly answered, and a test that demanded `none` would have gone +# red on two of the four hosts for a reason unrelated to what it is checking. +# +# What is host-independent is which ROW the request resolved to, and that is +# visible either way: on success in `data.triple`, and under a refusal in the +# first line of the message, which names its subject. +# +# ⚠️ THE SUBJECT, NOT THE DOCUMENT. Grepping the whole JSON was the first draft +# and it is contaminated: `host-cannot-serve` lists every target this host CAN +# serve, so `x86_64-linux-musl` appears in a message that is about something +# else entirely. The list is an answer to a different question sitting in the +# same string. +resolved_row() { # request → the row it resolved to + local doc t + doc="$("$MCPP" why toolchain --target "$1" --format json 2>/dev/null | tr -d '\r')" + t="$(printf '%s' "$doc" | jq -r '.data.triple.llvm // empty')" + if [ -n "$t" ]; then printf '%s' "$t"; return; fi + printf '%s' "$doc" | jq -r '.diagnostics[0].message // empty' \ + | head -1 | sed -n "s/^[^']*'\\([^']*\\)'.*/\\1/p" +} +message_of() { + "$MCPP" why toolchain --target "$1" --format json 2>/dev/null \ + | jq -r '.diagnostics[].message' | tr -d '\r' +} + +# ── Half one: the short spelling reaches the row that exists ────────────── +r="$(reason_for aarch64-linux)" +row="$(resolved_row aarch64-linux)" +case "$r" in + tier-planned|unknown-target) + echo "FAIL: the tier gate still answers about the lexical fill (reason '$r')" + echo " aarch64-linux-musl is 'verified'; aarch64-linux-gnu is 'planned'" + message_of aarch64-linux | sed 's/^/ /' + exit 1 ;; + none) + echo " ok --target aarch64-linux resolves instead of refusing" ;; + host-cannot-serve) + echo " ok --target aarch64-linux completed (this host serves no such payload)" ;; + *) + echo "FAIL: --target aarch64-linux refused for reason '$r'" + message_of aarch64-linux | sed 's/^/ /' + exit 1 ;; +esac + +# ⚠️ AND THE ROW IS THE IDENTITY, NOT THE SPELLING. The output directory is the +# identity. Asserting only "it did not refuse with tier-planned" would stay +# green in a world where the completion picked some other row entirely. +case "$row" in + *-musl) echo " ok and it resolved to the musl row ($row)" ;; + *) echo "FAIL: aarch64-linux resolved to '$row', not a musl row"; exit 1 ;; +esac +case "$row" in + aarch64*) ;; + *) echo "FAIL: aarch64-linux resolved to '$row', which is not aarch64"; exit 1 ;; +esac + +# ── Half two: the control ──────────────────────────────────────────────── +# +# x86_64-linux-gnu is `verified`, so the lexical fill names a supported row and +# nothing may move. This is the assertion that a fix aimed at aarch64 did not +# redefine what `-linux` means everywhere — and it is the half that would catch +# "send every bare -linux to musl", which passes half one perfectly. +hostr="$(reason_for x86_64-linux)" +hostrow="$(resolved_row x86_64-linux)" +case "$hostrow" in + *-gnu) echo " ok and x86_64-linux is still gnu ($hostrow, reason '$hostr')" ;; + *) echo "FAIL: x86_64-linux resolved to '$hostrow' (reason '$hostr')"; exit 1 ;; +esac + +# ── Half three: a written segment is a request, not a gap ──────────────── +# +# The escape hatch. Someone who wants the `planned` row writes it out, and the +# tier gate then refuses a string that IS in their command. +wr="$(reason_for aarch64-linux-gnu)" +if [ "$wr" = tier-planned ]; then + echo " ok and writing -gnu still opts into the planned row's refusal" +else + echo "FAIL: --target aarch64-linux-gnu gave reason '$wr', expected tier-planned" + exit 1 +fi + +echo "OK: a request that named no C library resolves to a row that exists" diff --git a/tests/e2e/300_a_registered_family_is_not_reported_unknown.sh b/tests/e2e/300_a_registered_family_is_not_reported_unknown.sh new file mode 100755 index 00000000..b815a028 --- /dev/null +++ b/tests/e2e/300_a_registered_family_is_not_reported_unknown.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# requires: unix-shell jq +# "unknown target" is a claim about the vocabulary, and it was false for a whole +# arch+os family. +# +# ⚠️ MEASURED ON 2026.8.26.1: +# +# $ mcpp why toolchain --target riscv64-linux --format json +# unknown target 'riscv64-linux' +# "reason": "other" +# +# `riscv64-linux-musl` is in `kKnownTargets` as `planned`. The lexical env fill +# had produced `riscv64-linux-gnu` — a row that genuinely does not exist — and +# the gate reported on the fill. So a registered family was called unknown, and +# the refusal carried no code at all: `other` is what the machine interface +# prints for a branch nobody named, and this branch had a perfectly good name. +# +# ⭐ TWO ASSERTIONS, BECAUSE THE WORD AND THE CODE FAIL SEPARATELY. A message +# fixed without a code still reports `other`; a code added without fixing the +# message still tells the reader their target does not exist. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/src" +cd "$work" +printf '[package]\nname = "unkprobe"\nversion = "0.1.0"\n' > mcpp.toml +printf 'extern "C" int main(int, char**, char**) { return 0; }\n' > src/main.cpp + +json_for() { "$MCPP" why toolchain --target "$1" --format json 2>/dev/null; } + +# ── Half one: a registered-but-planned family ──────────────────────────── +j="$(json_for riscv64-linux)" +reason="$(printf '%s' "$j" | jq -r '.data.reason // "-"' | tr -d '\r')" +msg="$(printf '%s' "$j" | jq -r '.diagnostics[].message' | tr -d '\r')" + +if [ "$reason" != tier-planned ]; then + echo "FAIL: riscv64-linux refused as '$reason', expected tier-planned" + printf '%s\n' "$msg" | sed 's/^/ /' + exit 1 +fi +echo " ok a registered family refuses as planned, not as unknown" + +# ⭐ AND THE MESSAGE NAMES A ROW THAT EXISTS. Telling someone their target is +# planned is only actionable once they can see which spelling is the registered +# one — the same rule that made `x86_64-linux` have to work before the +# name/fact warning was worth printing. +if printf '%s\n' "$msg" | grep -q 'riscv64-linux-musl'; then + echo " ok and it names the row that exists" +else + echo "FAIL: the refusal does not name riscv64-linux-musl" + printf '%s\n' "$msg" | sed 's/^/ /' + exit 1 +fi + +# ⚠️ AND IT DOES NOT QUOTE A TRIPLE THE READER NEVER TYPED. The old message was +# about `riscv64-linux-gnu`, a string that appears nowhere in the command and +# nowhere in the vocabulary. +if printf '%s\n' "$msg" | grep -q 'riscv64-linux-gnu'; then + echo "FAIL: the refusal quotes 'riscv64-linux-gnu', which the reader never wrote" + printf '%s\n' "$msg" | sed 's/^/ /' + exit 1 +fi +echo " ok and it does not quote a triple nobody wrote" + +# ── Half two: a genuine typo is still unknown, and now carries its code ── +j2="$(json_for x86_64-linux-mus)" +reason2="$(printf '%s' "$j2" | jq -r '.data.reason // "-"' | tr -d '\r')" +msg2="$(printf '%s' "$j2" | jq -r '.diagnostics[].message' | tr -d '\r')" + +if [ "$reason2" != unknown-target ]; then + echo "FAIL: a typo'd triple refused as '$reason2', expected unknown-target" + printf '%s\n' "$msg2" | sed 's/^/ /' + exit 1 +fi +echo " ok a typo is still unknown, and the refusal now carries that code" + +if printf '%s\n' "$msg2" | grep -q 'x86_64-linux-musl'; then + echo " ok and the suggestion survived" +else + echo "FAIL: the did-you-mean suggestion was lost" + printf '%s\n' "$msg2" | sed 's/^/ /' + exit 1 +fi + +echo "OK: a registered family is not reported unknown" diff --git a/tests/e2e/301_the_graphs_compiler_is_taken_and_nothing_is_written.sh b/tests/e2e/301_the_graphs_compiler_is_taken_and_nothing_is_written.sh new file mode 100755 index 00000000..a4984bea --- /dev/null +++ b/tests/e2e/301_the_graphs_compiler_is_taken_and_nothing_is_written.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# requires: unix-shell jq +# A compiler the dependency graph requires is USED, not merely checked — and +# selecting it writes no configuration. +# +# ⭐⭐ `provides` AND `requires` ARE TWO HALVES OF ONE VOCABULARY AND THEY WERE +# READ AT OPPOSITE ENDS OF `prepare_build`. The block that decides the toolchain +# once the graph exists consulted the first; the second was collected a thousand +# lines later and used only to reject the outcome. +# +# ⚠️ MEASURED ON 2026.8.26.1, three-line manifest, llvm already installed: +# +# $ mcpp build # global default gcc@16.1.0 +# error: `openkal-llvm-runtime@0.1.3` requires the compiler to be `llvm`. +# Select that compiler … mcpp toolchain default llvm +# $ MCPP_TOOLCHAIN=llvm@22.1.8 mcpp build +# Finished dev [unoptimized + debuginfo] in 1.02s +# +# Nothing was missing. The remedy printed was a GLOBAL change — the default for +# every project on the machine — made because ONE project's dependency asked. +# +# ⭐ AND THE SECOND HALF IS THE POINT OF THE FIRST. `resolve_target_toolchain` +# has two call sites and both are downstream of the graph, so every +# `write_default_toolchain` lives on a branch this selection no longer enters. +# "Touch no configuration" is not a rule someone has to remember; it is where +# the decision sits. The sha256 is what enforces it. +# +# ⚠️ THE CRITERION IS A HASH, NOT "THE BUILD SUCCEEDED". A build that succeeds +# and rewrites the user's default is exactly the behaviour being removed, and +# the two are indistinguishable from the exit code. +set -e + +MCPP="${MCPP:-mcpp}" + +# ⭐ THE REQUIRED FAMILY IS CHOSEN AGAINST THIS MACHINE, NOT HARDCODED. The +# claim is "a requirement that differs from mcpp's own answer is applied", so +# the test needs a family that (a) is installed here and (b) is not the one +# already resolving. Hardcoding `llvm` would silently assert nothing on a box +# whose default is already llvm. +installed="$("$MCPP" toolchain list --format json 2>/dev/null \ + | jq -r '[.data.toolchains[].family] | unique | .[]' | tr -d '\r')" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/needs/src" "$work/app/src" +printf 'extern "C" int main(int, char**, char**) { return 0; }\n' > "$work/app/src/main.cpp" +printf 'export module needs_it;\nexport int needs_it() { return 0; }\n' \ + > "$work/needs/src/needs_it.cppm" + +# ⚠️⚠️ THE BASELINE IS MEASURED IN THE PROJECT UNDER TEST, AND MEASURING IT +# ANYWHERE ELSE MAKES THIS FILE ASSERT NOTHING. +# +# The first draft asked `why toolchain` from whatever directory the runner +# happened to start in — mcpp's own repo, whose `mcpp.toml` states gcc. It read +# `gcc`, chose `llvm` as the family to require, and then ran in a scratch +# project where the GLOBAL default was already llvm. Every assertion passed +# while the requirement had changed nothing at all. +# +# The baseline has to come from the same manifest, minus the dependency. +printf '[package]\nname = "app"\nversion = "0.1.0"\n' > "$work/app/mcpp.toml" +cd "$work/app" +current="$("$MCPP" why toolchain --format json 2>/dev/null \ + | jq -r '.data.compiler.family // "-"' | tr -d '\r')" +# `compiler.family` reports the driver identity (`clang`); packages and users +# write the family (`llvm`). One name for the axis, as compiler_family() says. +case "$current" in clang) current=llvm ;; esac + +want="" +for f in $installed; do + [ "$f" = "$current" ] && continue + case "$f" in gcc|llvm) want="$f"; break ;; esac +done +if [ -z "$want" ]; then + echo "SKIP: this project already resolves '$current' and no other family is" + echo " installed here, so no requirement could differ from it" + exit 0 +fi + +printf '[package]\nname = "needs-%s"\nversion = "0.1.0"\nrequires = ["mcpp:compiler=%s"]\n' \ + "$want" "$want" > "$work/needs/mcpp.toml" +printf '[package]\nname = "app"\nversion = "0.1.0"\n\n[dependencies]\nneeds-%s = { path = "../needs" }\n' \ + "$want" > "$work/app/mcpp.toml" + +# ⚠️⚠️ A HASHER THAT DOES NOT EXIST MAKES THIS CRITERION PASS BY MEASURING +# NOTHING. macOS has no `sha256sum` (it is `shasum -a 256`), and the first draft +# would then have compared two empty strings — which are equal. The whole point +# of this half is that a build can succeed AND rewrite the configuration; a +# check that cannot tell them apart is worse than no check. +if command -v sha256sum >/dev/null 2>&1; then + hash_of() { sha256sum "$1" | cut -d' ' -f1; } +elif command -v shasum >/dev/null 2>&1; then + hash_of() { shasum -a 256 "$1" | cut -d' ' -f1; } +else + echo "FAIL: no sha256 tool on this host, and this half's whole claim is a hash" + exit 1 +fi + +cfg="${MCPP_HOME:-$HOME/.mcpp}/config.toml" +before="" +if [ -f "$cfg" ]; then + before="$(hash_of "$cfg")" + [ -n "$before" ] || { echo "FAIL: could not hash $cfg"; exit 1; } +fi + +j="$("$MCPP" why toolchain --format json 2>/dev/null)" +reason="$(printf '%s' "$j" | jq -r '.data.reason // "-"' | tr -d '\r')" +got="$(printf '%s' "$j" | jq -r '.data.compiler.family // "-"' | tr -d '\r')" +case "$got" in clang) got=llvm ;; esac + +if [ "$reason" != none ]; then + echo "FAIL: the graph required '$want' and mcpp refused with '$reason'" + printf '%s' "$j" | jq -r '.diagnostics[].message' | sed 's/^/ /' + exit 1 +fi +if [ "$got" != "$want" ]; then + echo "FAIL: the graph required '$want', mcpp resolved '$got'" + exit 1 +fi +echo " ok the graph required '$want' and mcpp took it (was '$current')" + +# ── And it changed no configuration ────────────────────────────────────── +after="" +[ -f "$cfg" ] && after="$(hash_of "$cfg")" +# ⚠️ AND BOTH SIDES MUST BE NON-EMPTY. `"" = ""` is the shape this half exists +# to refuse, whatever produced the emptiness. +if [ -z "$before" ] || [ -z "$after" ]; then + echo "FAIL: one side of the comparison is empty (before='$before' after='$after')," + echo " so nothing was actually compared" + exit 1 +fi +if [ "$before" = "$after" ]; then + echo " ok and $cfg is byte-identical" +else + echo "FAIL: selecting the graph's compiler rewrote the global configuration" + echo " before $before" + echo " after $after" + exit 1 +fi + +# ── And the query says who asked ───────────────────────────────────────── +# +# ⚠️ A COMPILER THE USER DID NOT NAME, REPORTED WITHOUT ITS REASON, IS A RULE +# THAT CAN ONLY BE LEARNED BY EXPERIMENT. The same argument that put a reason on +# the target row's substitution applies here, and more so: this one overrides a +# value the user set with `mcpp toolchain default`. +# +# ⭐ FROM `compiler.chosenBy`, NOT FROM THE PROSE. A consumer asking "why this +# compiler" reading the status line would be doing the substring matching the +# machine interface exists to remove — and so would this test. +by="$(printf '%s' "$j" | jq -r '.data.compiler.chosenBy.requiredBy // ""' | tr -d '\r')" +was="$(printf '%s' "$j" | jq -r '.data.compiler.chosenBy.replaced // ""' | tr -d '\r')" +case "$by" in + needs-"$want"@*) echo " ok and the query names the package that asked ($by)" ;; + "") echo "FAIL: compiler.chosenBy.requiredBy is empty — the document does not" + echo " say a dependency decided this"; exit 1 ;; + *) echo "FAIL: compiler.chosenBy.requiredBy is '$by', expected needs-$want@…" + exit 1 ;; +esac +if [ -n "$was" ]; then + echo " ok and names what it displaced ($was)" +else + echo "FAIL: compiler.chosenBy.replaced is empty, yet '$current' was displaced" + exit 1 +fi + +# ⭐ AND THE HUMAN-FACING LINE STILL CARRIES IT. The token is for programs; the +# person running the build reads the status line, and a decision reported there +# without its reason is the rule learned by experiment. +out="$("$MCPP" build 2>&1 || true)" +ok=1 +printf '%s\n' "$out" | grep -q "needs-$want" || ok=0 +printf '%s\n' "$out" | grep -q "mcpp:compiler=$want" || ok=0 +if [ "$ok" = 1 ]; then + echo " ok and the status line names it too" +else + echo "FAIL: the resolution does not say who required this compiler" + printf '%s\n' "$out" | head -6 | sed 's/^/ /' + exit 1 +fi + +echo "OK: the graph's compiler is taken and nothing is written" diff --git a/tests/e2e/302_a_stated_compiler_outranks_the_graph_and_two_requirements_do_not_stack.sh b/tests/e2e/302_a_stated_compiler_outranks_the_graph_and_two_requirements_do_not_stack.sh new file mode 100755 index 00000000..b2e4073b --- /dev/null +++ b/tests/e2e/302_a_stated_compiler_outranks_the_graph_and_two_requirements_do_not_stack.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# requires: unix-shell jq +# The graph's compiler requirement is applied where mcpp's own answer was +# revisable — and only there. +# +# ⭐⭐ THE RANK IS NOT NEW. `TcOrigin` already sorted these and +# `tc_origin_is_user_explicit` already answered "may mcpp revise this"; the +# defect the previous test covers was that nobody asked. This file is the other +# side: the one case that must still refuse, and the one that cannot be resolved +# by choosing. +# +# half one `[toolchain] default = ` the project stated it → refuse +# half two two packages, two families no compiler satisfies both +# +# ⚠️ AND THE ADVICE IS PART OF THE CLAIM. Until 2026.8.26.2 the first remedy +# offered was `mcpp toolchain default ` — global, and in the only case +# that still reaches here it does not even work, because a project-level +# statement is what decided. A remedy that cannot fix the failure it is printed +# under is worse than none. +set -e + +MCPP="${MCPP:-mcpp}" + +installed="$("$MCPP" toolchain list --format json 2>/dev/null \ + | jq -r '[.data.toolchains[] | select(.family=="gcc" or .family=="llvm") + | .family + "@" + .version] | unique | .[]' | tr -d '\r')" +gccspec="$(printf '%s\n' "$installed" | grep '^gcc@' | head -1)" +llvmspec="$(printf '%s\n' "$installed" | grep '^llvm@' | head -1)" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/needs-gcc/src" "$work/needs-llvm/src" "$work/app/src" +printf '[package]\nname = "needs-gcc"\nversion = "0.1.0"\nrequires = ["mcpp:compiler=gcc"]\n' \ + > "$work/needs-gcc/mcpp.toml" +printf '[package]\nname = "needs-llvm"\nversion = "0.1.0"\nrequires = ["mcpp:compiler=llvm"]\n' \ + > "$work/needs-llvm/mcpp.toml" +printf 'export module needs_gcc;\nexport int ng() { return 1; }\n' > "$work/needs-gcc/src/a.cppm" +printf 'export module needs_llvm;\nexport int nl() { return 2; }\n' > "$work/needs-llvm/src/b.cppm" +printf 'extern "C" int main(int, char**, char**) { return 0; }\n' > "$work/app/src/main.cpp" +cd "$work/app" + +# ── Half one: the project states its own compiler ──────────────────────── +if [ -n "$gccspec" ] && [ -n "$llvmspec" ]; then + printf '[package]\nname = "app"\nversion = "0.1.0"\n\n[toolchain]\ndefault = "%s"\n\n[dependencies]\nneeds-llvm = { path = "../needs-llvm" }\n' \ + "$gccspec" > mcpp.toml + j="$("$MCPP" why toolchain --format json 2>/dev/null)" + reason="$(printf '%s' "$j" | jq -r '.data.reason // "-"' | tr -d '\r')" + msg="$(printf '%s' "$j" | jq -r '.diagnostics[].message' | tr -d '\r')" + + if [ "$reason" != layer-requirement ]; then + echo "FAIL: a stated compiler was overridden by the graph (reason '$reason')" + printf '%s\n' "$msg" | sed 's/^/ /' + exit 1 + fi + echo " ok a compiler the project stated is not revised" + + # ⭐ AND THE REMEDY POINTS AT THE STATEMENT THAT DECIDED. + ok=1 + printf '%s\n' "$msg" | grep -q '\[toolchain\]' || ok=0 + printf '%s\n' "$msg" | grep -q 'mcpp.toml' || ok=0 + if [ "$ok" = 1 ]; then + echo " ok and the remedy names it" + else + echo "FAIL: the refusal does not name where the compiler was stated" + printf '%s\n' "$msg" | sed 's/^/ /' + exit 1 + fi + + # ⚠️ AND IT DOES NOT SEND THE READER TO A GLOBAL SETTING. Changing + # `mcpp toolchain default` here fixes nothing: the project's own statement + # is what is being used. + if printf '%s\n' "$msg" | grep -q 'mcpp toolchain default'; then + echo "FAIL: the remedy is a global change that would not fix this failure" + printf '%s\n' "$msg" | sed 's/^/ /' + exit 1 + fi + echo " ok and it does not offer a global change that would not work" +else + echo "SKIP(half one): both gcc and llvm must be installed to state one of them" +fi + +# ── Half two: two packages, two families ───────────────────────────────── +# +# ⚠️ ONE SUPPLIER PER LAYER, AND TWO IS AN ERROR RATHER THAN A PICK — the rule +# `provides` already follows. Resolving by graph-traversal order would make the +# answer depend on an order the author neither writes nor can predict, and would +# silently satisfy one package while failing the other inside a header. +printf '[package]\nname = "app"\nversion = "0.1.0"\n\n[dependencies]\nneeds-gcc = { path = "../needs-gcc" }\nneeds-llvm = { path = "../needs-llvm" }\n' \ + > mcpp.toml +j2="$("$MCPP" why toolchain --format json 2>/dev/null)" +reason2="$(printf '%s' "$j2" | jq -r '.data.reason // "-"' | tr -d '\r')" +msg2="$(printf '%s' "$j2" | jq -r '.diagnostics[].message' | tr -d '\r')" + +if [ "$reason2" != compiler-requirement-conflict ]; then + echo "FAIL: two conflicting requirements gave reason '$reason2'" + printf '%s\n' "$msg2" | sed 's/^/ /' + exit 1 +fi +echo " ok two packages requiring different compilers is an error" + +ok=1 +printf '%s\n' "$msg2" | grep -q 'needs-gcc' || ok=0 +printf '%s\n' "$msg2" | grep -q 'needs-llvm' || ok=0 +if [ "$ok" = 1 ]; then + echo " ok and both packages are named" +else + echo "FAIL: the conflict does not name both packages" + printf '%s\n' "$msg2" | sed 's/^/ /' + exit 1 +fi + +# ── Half three: a capability row's remedy is not a convention row's ────── +# +# ⚠️⚠️ THE TWO ROWS REFUSE UNDER ONE RULE AND FOR TWO REASONS, AND ONE REMEDY +# DOES NOT SERVE BOTH. +# +# A convention pin is cancelled by a graph that supplies the target's system, so +# "depend on a package that supplies it" is the way out. A capability pin is +# not — it stays applied whatever the graph supplies, because no other family +# emits the target. Printed there, that remedy is an instruction the sentence +# directly above it has already ruled out. +# +# ⭐ FOUND BY READING THE MESSAGE, NOT BY A FAILING BUILD. This half exists so +# the next rewording cannot put it back. +# +# ⚠️ The refusal is decided from the VOCABULARY (the row's pin) before any +# payload is resolved, so this half is host-independent and needs nothing +# installed. +printf '[package]\nname = "app"\nversion = "0.1.0"\n\n[dependencies]\nneeds-gcc = { path = "../needs-gcc" }\n' \ + > mcpp.toml +j3="$("$MCPP" why toolchain --target riscv64-none-elf --format json 2>/dev/null)" +reason3="$(printf '%s' "$j3" | jq -r '.data.reason // "-"' | tr -d '\r')" +msg3="$(printf '%s' "$j3" | jq -r '.diagnostics[].message' | tr -d '\r')" + +if [ "$reason3" != compiler-requirement-conflict ]; then + echo "FAIL: a capability row against a gcc requirement gave reason '$reason3'" + printf '%s\n' "$msg3" | sed 's/^/ /' + exit 1 +fi +echo " ok a capability row refuses a requirement it cannot satisfy" + +if printf '%s\n' "$msg3" | grep -qi 'capability'; then + echo " ok and it says the pin is a capability" +else + echo "FAIL: the refusal does not say the row's pin is a capability" + printf '%s\n' "$msg3" | sed 's/^/ /' + exit 1 +fi +if printf '%s\n' "$msg3" | grep -q "supplies this target's system"; then + echo "FAIL: the remedy offers to supply the system, which a capability pin ignores" + printf '%s\n' "$msg3" | sed 's/^/ /' + exit 1 +fi +echo " ok and it does not offer a remedy a capability pin ignores" + +echo "OK: a stated compiler outranks the graph and two requirements do not stack" diff --git a/tests/e2e/303_the_query_gives_one_answer_for_the_c_library.sh b/tests/e2e/303_the_query_gives_one_answer_for_the_c_library.sh new file mode 100755 index 00000000..4e986df9 --- /dev/null +++ b/tests/e2e/303_the_query_gives_one_answer_for_the_c_library.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# requires: unix-shell jq +# `mcpp why toolchain --format json` answers "which C library" once. +# +# ⚠️⚠️ IT USED TO ANSWER TWICE AND DIFFERENTLY, IN ONE DOCUMENT. +# +# Measured on 2026.8.26.1 over an openkal project: +# +# "cLibrary": { "origin": "payload", "path": "…/xim-x-glibc/2.44/lib64" } +# "layers": [ { "layer": "c-abi", "interface": "musl", +# "impl": "openkal-musl@0.3.5", "origin": "graph" } ] +# +# The artifact settles it — statically linked, no interpreter, no `DT_NEEDED`, +# eleven openkal symbols — so glibc is not in it. Both fields were accurate +# about different questions: `cLibrary` describes the PAYLOAD's link model, +# `layers[].c-abi` describes the BUILD. A consumer had no way to tell which one +# governed, which is a machine interface contradicting itself. +# +# ⭐ ONE FIELD ADDED, NONE CHANGED. docs/11 §6 promises that fields are added +# and never removed and that a field's meaning never changes; renaming +# `cLibrary` or widening `mode` would break that for a document whose whole +# point is to be depended on. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/src" +cd "$work" +printf 'extern "C" int main(int, char**, char**) { return 0; }\n' > src/main.cpp + +probe() { # → " " + "$MCPP" why toolchain --format json 2>/dev/null | tr -d '\r' \ + | jq -r '[(.data.layers[] | select(.layer=="c-abi") | .origin), + (.data.cLibrary.suppliesTarget | tostring)] | @tsv' +} + +# ── Half one: the field exists at all ──────────────────────────────────── +# +# ⚠️ A `null` HERE IS NOT A FAILING ASSERTION, IT IS AN ABSENT ONE. Without +# this check the comparisons below would read "null" on both sides and agree. +printf '[package]\nname = "clibprobe"\nversion = "0.1.0"\n' > mcpp.toml +base="$("$MCPP" why toolchain --format json 2>/dev/null | tr -d '\r')" +basereason="$(printf '%s' "$base" | jq -r '.data.reason // "QUERY-FAILED"')" +if [ "$basereason" != none ]; then + echo "FAIL: the query refused ('$basereason') on a dependency-free project" + printf '%s' "$base" | jq -r '.diagnostics[].message' | sed 's/^/ /' + exit 1 +fi +# ⚠️⚠️ `has`, NOT `// "MISSING"`. jq's `//` returns its right side when the left +# is null OR FALSE — so `"suppliesTarget": false`, which is a perfectly good +# answer, read as absent. Measured on windows-x86_64: the field was there and +# this check reported it missing. +if printf '%s' "$base" | jq -e '.data.cLibrary | has("suppliesTarget")' >/dev/null; then + echo " ok the query says which of its two C-library answers governs" +else + echo "FAIL: cLibrary.suppliesTarget is absent — the document still cannot say" + echo " which of its two C-library answers governs" + printf '%s' "$base" | jq -c '.data.cLibrary' | sed 's/^/ /' + exit 1 +fi + +# ── Half two: the control — with no graph, the payload is not denied ───── +# +# ⚠️ THE CLAIM IS SELF-CONSISTENCY, NOT A PARTICULAR VALUE. `suppliesTarget` is +# also false when there is no payload C-library model at all (`mode: none`), +# which is a legitimate state on some hosts. What must never happen is +# `suppliesTarget: false` while the c-abi layer says the payload supplied it. +read -r origin supplies < "$work/libc/mcpp.toml" +printf 'export module fake_libc;\nexport int fl() { return 0; }\n' \ + > "$work/libc/src/fake_libc.cppm" +# ⚠️ `libc`, NOT `../libc`. The root package IS `$work`, so `../libc` points +# outside the fixture — measured, the dependency did not resolve, `data.layers` +# came back null, and the skip below reported "no contradiction to check". +printf '[package]\nname = "clibprobe"\nversion = "0.1.0"\n\n[dependencies]\nfake-libc = { path = "libc" }\n' \ + > mcpp.toml + +# ⚠️⚠️ A REFUSAL MUST NOT BE READ AS "NOTHING TO CHECK". The first draft went +# straight to the skip when `origin2` was empty — and empty is what a FAILED +# query produces, not only an inapplicable one. A criterion whose "no" and whose +# "could not measure" print the same line asserts nothing. +doc2="$("$MCPP" why toolchain --format json 2>/dev/null | tr -d '\r')" +reason2="$(printf '%s' "$doc2" | jq -r '.data.reason // "QUERY-FAILED"')" +if [ "$reason2" != none ]; then + # ⚠️ NAMED, NOT SILENT. A musl C library over this host's own target is not + # a combination every host can stack — on an MSVC-ABI host the layering + # check answers first, and that is a different question correctly answered. + # Printing the reason is what keeps this distinguishable from a defect; + # a bare `exit 0` here would make the file's conclusion unreachable and + # unremarked. + # ⚠️⚠️ AND IT DOES NOT PRINT THE CONCLUSION LINE. The skip means the central + # claim was NOT checked here; emitting `OK:` anyway would make CI's + # "did it reach its conclusion" step accept a file that asserted two thirds + # of itself. The skip is granted by REASON in the workflow, and + # linux-x86_64 is the denominator that must run the whole thing. + echo "SKIP: this host refuses a musl c-abi over its own target ('$reason2')," + echo " so there is no two-answer document to check here" + printf '%s' "$doc2" | jq -r '.diagnostics[].message' | head -3 | sed 's/^/ /' + exit 0 +fi +read -r origin2 supplies2 </dev/null | tr -d '\r' \ + | jq -c '{cLibrary:.data.cLibrary, + cabi:(.data.layers[]|select(.layer=="c-abi"))}' | sed 's/^/ /' + exit 1 +fi + +echo "OK: the query gives one answer for the C library" diff --git a/tests/unit/test_targetside.cpp b/tests/unit/test_targetside.cpp index 45d6d228..eee4e6fc 100644 --- a/tests/unit/test_targetside.cpp +++ b/tests/unit/test_targetside.cpp @@ -574,9 +574,25 @@ TEST(TargetSideRequirements, ARequirementIsCheckedAgainstWhatResolved) { auto why = ts::check_requirements(r, reqs); ASSERT_TRUE(why.has_value()); EXPECT_NE(why->find("requires the compiler to be `llvm`"), std::string::npos); - EXPECT_NE(why->find("mcpp toolchain default llvm"), std::string::npos) + EXPECT_NE(why->find("default = \"llvm\""), std::string::npos) << "a diagnostic that names no next step is a diagnostic the reader " "must still go and research"; + // ⚠️ AND THE STEP IT NAMES MUST NOT BE A GLOBAL ONE. Until 2026.8.26.2 the + // first remedy offered was `mcpp toolchain default llvm` — the default for + // every project on the machine, changed because ONE project's dependency + // asked. mcpp now applies the graph's requirement itself wherever its own + // answer was revisable, so a global change is both unnecessary and, in the + // only case that still reaches here, ineffective. + EXPECT_EQ(why->find("mcpp toolchain default"), std::string::npos) + << "the remedy must stay inside the project that has the problem"; + + // With the caller naming where the compiler was stated, the advice points + // at that statement instead of at the generic pair of spellings. + auto stated = ts::check_requirements(r, reqs, "[toolchain] in mcpp.toml"); + ASSERT_TRUE(stated.has_value()); + EXPECT_NE(stated->find("[toolchain] in mcpp.toml"), std::string::npos); + EXPECT_NE(stated->find("remove it"), std::string::npos) + << "removing the statement is what lets mcpp take the graph's compiler"; in.compilerFamily = "llvm"; EXPECT_EQ(ts::check_requirements(ts::resolve(in), reqs), std::nullopt); diff --git a/tests/unit/test_toolchain_triple.cpp b/tests/unit/test_toolchain_triple.cpp index a518b35f..ec6af7b9 100644 --- a/tests/unit/test_toolchain_triple.cpp +++ b/tests/unit/test_toolchain_triple.cpp @@ -4,6 +4,7 @@ import std; import mcpp.toolchain.triple; using namespace mcpp::toolchain::triple; +namespace triple = mcpp::toolchain::triple; // ── parse: canonical spellings round-trip ──────────────────────────────────── @@ -67,6 +68,88 @@ TEST(Triple, NormalizesBareLinuxToGnuEnv) { EXPECT_EQ(parse("x86_64-linux")->str(), "x86_64-linux-gnu"); } +// ── resolve_request: the REQUEST is completed from the vocabulary ──────────── +// +// `parse` fills lexically so the IDENTITY stays total and host-independent (the +// test just above pins that). `resolve_request` answers the other half — which +// row a request that named no C library should resolve to — and it is the one +// that consults `kKnownTargets`. + +TEST(TripleRequest, ASupportedLexicalDefaultIsKept) { + // x86_64-linux-gnu is `verified`, so nothing moves. This is the control for + // the case below: a fix that sent every bare `-linux` to musl would pass the + // aarch64 test and break every project on the planet. + auto r = triple::resolve_request(*parse("x86_64-linux")); + EXPECT_EQ(r.triple.str(), "x86_64-linux-gnu"); + EXPECT_FALSE(r.completedFromVocabulary); + EXPECT_FALSE(r.ambiguous); +} + +TEST(TripleRequest, TheOnlySupportedSiblingIsTaken) { + // aarch64-linux-gnu is `planned`; aarch64-linux-musl is `verified`. Measured + // on 2026.8.26.1: `--target aarch64-linux` refused as planned while + // `--target aarch64-linux-musl` built. + auto r = triple::resolve_request(*parse("aarch64-linux")); + EXPECT_EQ(r.triple.str(), "aarch64-linux-musl"); + EXPECT_TRUE(r.completedFromVocabulary); + // ⚠️ mcpp CHOOSING A ROW IS NOT THE PROJECT NAMING A C LIBRARY. `envExplicit` + // feeds the request/fact comparison and the report's display name; setting + // it here would make mcpp compare its own answer against itself. + EXPECT_FALSE(r.triple.envExplicit); +} + +TEST(TripleRequest, AWrittenSegmentIsARequestAndIsNotRevised) { + // The escape hatch: writing the segment opts into the `planned` row, and the + // tier gate then refuses something the user actually typed. + auto r = triple::resolve_request(*parse("aarch64-linux-gnu")); + EXPECT_EQ(r.triple.str(), "aarch64-linux-gnu"); + EXPECT_FALSE(r.completedFromVocabulary); +} + +TEST(TripleRequest, AFamilyWithNoSupportedRowKeepsTheLexicalFillAndReportsItsRows) { + // riscv64-linux-musl is `planned` and riscv64-linux-gnu does not exist at + // all, so the fill named a row outside the vocabulary and the refusal came + // out as `unknown target 'riscv64-linux'` — false, the family is registered. + auto r = triple::resolve_request(*parse("riscv64-linux")); + EXPECT_EQ(r.triple.str(), "riscv64-linux-gnu"); + EXPECT_FALSE(r.completedFromVocabulary); + ASSERT_EQ(r.siblings.size(), 1u); + EXPECT_EQ(r.siblings[0], "riscv64-linux-musl"); + EXPECT_TRUE(r.supported.empty()); +} + +TEST(TripleRequest, MacosCarriesNoEnvAndIsLeftAlone) { + EXPECT_EQ(triple::resolve_request(*parse("aarch64-macos")).triple.str(), + "aarch64-macos"); + // x86_64-macos is `planned` with no sibling: the tier gate still speaks. + auto r = triple::resolve_request(*parse("x86_64-macos")); + EXPECT_EQ(r.triple.str(), "x86_64-macos"); + EXPECT_TRUE(r.supported.empty()); +} + +TEST(TripleRequest, WindowsAndBareMetalDefaultsAreSupportedRows) { + // Both lexical fills (`gnu` on Windows, `elf` freestanding) name supported + // rows, so completion is a no-op — recorded so that a future row change + // which breaks that shows up here rather than in a user's build. + EXPECT_EQ(triple::resolve_request(*parse("x86_64-windows")).triple.str(), + "x86_64-windows-gnu"); + EXPECT_EQ(triple::resolve_request(*parse("riscv64-none")).triple.str(), + "riscv64-none-elf"); +} + +TEST(TripleRequest, EverySupportedRowIsReachableFromItsOwnSpelling) { + // The completion must never turn a written, supported triple into a + // different one. Checked across the whole vocabulary rather than by example, + // because the failure mode is one row nobody thought to test. + for (auto const& row : triple::known_targets()) { + if (row.tier == "planned") continue; + auto t = parse(row.canonical); + ASSERT_TRUE(t.has_value()) << row.canonical; + EXPECT_EQ(triple::resolve_request(*t).triple.str(), + std::string(row.canonical)); + } +} + TEST(Triple, NormalizesDumpmachineSpellings) { // What real toolchains report via -dumpmachine. EXPECT_EQ(parse("x86_64-pc-linux-gnu")->str(), "x86_64-linux-gnu"); diff --git a/tests/unit/test_windows_defaults.cpp b/tests/unit/test_windows_defaults.cpp index e5316a85..e37e354f 100644 --- a/tests/unit/test_windows_defaults.cpp +++ b/tests/unit/test_windows_defaults.cpp @@ -76,6 +76,31 @@ TEST(WindowsDefaults, OriginClassification) { EXPECT_FALSE(tc_origin_is_user_explicit(TcOrigin::TargetPin)); EXPECT_FALSE(tc_origin_is_user_explicit(TcOrigin::FirstRun)); EXPECT_FALSE(tc_origin_is_user_explicit(TcOrigin::None)); + // A compiler the dependency graph required is not the user's word either — + // mcpp derived it, so the two paths that revise mcpp's own answers may. + EXPECT_FALSE(tc_origin_is_user_explicit(TcOrigin::GraphRequirement)); +} + +// ⚠️⚠️ AND IT IS THE ONE ORIGIN THAT MAY NEVER BECOME THE MACHINE'S DEFAULT. +// +// Two branches persist one: the Windows first-run diversion, whose condition is +// `tcSpec.has_value()`, and the MSVC repair, whose gate is "mcpp chose this +// itself". A compiler chosen by `requires = ["mcpp:compiler=…"]` satisfies both +// — so a bare Windows box building ONE llvm-requiring project would have handed +// llvm to every later project that asked for nothing. +// +// ⭐ THIS IS THE ONLY PLACE THE INVARIANT CAN BE MEASURED WITHOUT A BARE +// WINDOWS MACHINE. The e2e that checks `config.toml`'s sha256 runs where a +// toolchain is already configured, so it never reaches either branch; here the +// rule itself is the subject. +TEST(WindowsDefaults, OnlyMcppsOwnAnswersMayBePersisted) { + using mcpp::build::TcOrigin; + using mcpp::build::tc_origin_may_persist; + EXPECT_FALSE(tc_origin_may_persist(TcOrigin::GraphRequirement)); + EXPECT_TRUE (tc_origin_may_persist(TcOrigin::GlobalDefault)); + EXPECT_TRUE (tc_origin_may_persist(TcOrigin::TargetPin)); + EXPECT_TRUE (tc_origin_may_persist(TcOrigin::FirstRun)); + EXPECT_TRUE (tc_origin_may_persist(TcOrigin::None)); } // The fallback target must be PE/GNU, not the host's MSVC-ABI triple — the